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:
@@ -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)
|
||||
|
||||
@@ -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'],
|
||||
|
||||
Reference in New Issue
Block a user