test(queue): close CI coverage gaps

This commit is contained in:
kingwl committed 2026-07-30 00:55:02 +08:00
1 parent c97b1d025e
commit 77479f5ab0
6 files changed
+87 -2

No files matched your search

@@ -11,7 +11,7 @@
{"type":"assistant/chunk","seq":9,"time":1783951000009,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
{"type":"assistant/message","seq":10,"time":1784449176722,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek","model":"deepseek-v4-flash"},"id":"1ac37046-d1c0-4ef6-9ea9-963e4b46d1cf"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"}
{"type":"tool/call","seq":11,"time":1784449176722,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}}
{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interfaLine truncated
{"type":"tool/result","seq":12,"time":1784449176732,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise<ToolExecutionResult>\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly status: AgentStatus;\n readonly acceptsNextStep: boolean;\n readonly ctx: Context;\n send(message: UserMessage, options: SendOptions): void;\n updateInbox(id: InboxItemId, action: InboxAction): InboxActionResult;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise<void>;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: numberLine truncated
{"type":"step/end","seq":13,"time":1784449176732,"data":{"turn":1,"step":1}}
{"type":"step/start","seq":14,"time":1784449176733,"data":{"turn":1,"step":2}}
{"type":"assistant/chunk","seq":15,"time":1783951000015,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}
@@ -467,6 +467,7 @@ describe('fixture session face', () => {
await runtime.sessions.add({ id: 's1' })
const bare = runtime.sessions.behavior('s1')
expect(() => bare.prompt()).toThrow(/prompt is not stubbed/)
expect(() => bare.updateQueue()).toThrow(/updateQueue is not stubbed/)
expect(() => bare.cancel()).toThrow(/cancel is not stubbed/)
expect(() => bare.command()).toThrow(/command is not stubbed/)
expect(() => bare.loadOlder()).toThrow(/loadOlder is not stubbed/)
+8
View File
@@ -146,10 +146,12 @@ export class ReactLoopAgent implements Agent {
if (queuedIndex === -1 && outboxIndex === -1) return 'not-found'
const pending = queuedIndex === -1 ? this.outbox[outboxIndex] : this.queued[queuedIndex]
/* v8 ignore next 2 -- indices are derived from these arrays in this synchronous method. */
if (pending === undefined || pending.item === undefined) {
throw new Error(`agent "${this.id}" inbox index changed during synchronous update`)
}
/* v8 ignore next -- InboxAction is a closed discriminated union; all variants are covered below. */
switch (action.kind) {
case 'edit': {
const item: InboxItem = Object.freeze({
@@ -158,10 +160,12 @@ export class ReactLoopAgent implements Agent {
})
if (queuedIndex !== -1) {
const queued = this.queued[queuedIndex]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during edit`)
this.queued[queuedIndex] = { ...queued, item }
} else {
const outbox = this.outbox[outboxIndex]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during edit`)
this.outbox[outboxIndex] = { ...outbox, message: item.message, item }
}
@@ -177,11 +181,13 @@ export class ReactLoopAgent implements Agent {
case 'promote': {
if (queuedIndex !== -1) {
const queued = this.queued.splice(queuedIndex, 1)[0]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (queued === undefined) throw new Error(`agent "${this.id}" queued item disappeared during promotion`)
this.queued.unshift({ item: queued.item, wakeup: true })
this.scheduleKick()
} else {
const outbox = this.outbox.splice(outboxIndex, 1)[0]
/* v8 ignore next -- the index was resolved from this array without an async boundary. */
if (outbox === undefined) throw new Error(`agent "${this.id}" steering item disappeared during promotion`)
this.outbox.unshift(outbox)
}
@@ -189,6 +195,7 @@ export class ReactLoopAgent implements Agent {
return 'applied'
}
default:
/* v8 ignore next -- InboxAction is a closed discriminated union. */
return assertNever(action)
}
}
@@ -710,6 +717,7 @@ export class ReactLoopAgent implements Agent {
for (const item of this.outbox.splice(0, limit)) {
if (item.steering) {
steered = true
/* v8 ignore next -- only inbox-backed steer entries carry steering:true. */
if (item.item === undefined) throw new Error(`agent "${this.id}" steering outbox item has no inbox identity`)
emitAgentEvent(this.loopCtx, this, 'agent/inbox/dequeue', item.item)
this.session.append(
@@ -124,6 +124,63 @@ describe('addressable inbox operations', () => {
.toEqual(['first', 'promote me', 'edited'])
expect(agent.updateInbox(promote.id, { kind: 'remove' })).toBe('not-found')
})
it('edits, removes, and promotes steering occurrences before admission commits', async () => {
const adapter = new MockAdapter([textResponse('done')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(SessionId('steering-inbox-actions'), { provider: 'mock', model: 'mock' })
const entered = Promise.withResolvers<undefined>()
const decision = Promise.withResolvers<{ kind: 'allow' }>()
ctx.on('agent/prompt-submit', async () => {
entered.resolve(undefined)
return decision.promise
})
const pending: InboxItem[] = []
const updates: { id: string; action: string; text: string }[] = []
const discards: string[][] = []
ctx.on('agent/inbox/enqueue', (subject, item) => {
if (subject === agent && item.placement === 'steering') pending.push(item)
})
ctx.on('agent/inbox/update', (subject, item, action) => {
if (subject === agent) updates.push({ id: item.id, action, text: inboxText(item) })
})
ctx.on('agent/inbox/discard', (subject, items) => {
if (subject === agent) discards.push(items.map(item => item.id))
})
const idle = waitForIdle(ctx, agent)
send(agent, 'admitted prompt')
await entered.promise
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'remove me' }], source: { kind: 'user' } }))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'edit me' }], source: { kind: 'user' } }))
agent.steer(createUserMessage({ content: [{ type: 'text', text: 'promote me' }], source: { kind: 'user' } }))
expect(pending.map(inboxText)).toEqual(['remove me', 'edit me', 'promote me'])
const remove = pending[0]!
const edit = pending[1]!
const promote = pending[2]!
expect(agent.updateInbox(edit.id, {
kind: 'edit',
content: [{ type: 'text', text: 'edited' }],
})).toBe('applied')
expect(agent.updateInbox(remove.id, { kind: 'remove' })).toBe('applied')
expect(agent.updateInbox(promote.id, { kind: 'promote' })).toBe('applied')
expect(updates).toEqual([
{ id: edit.id, action: 'edit', text: 'edited' },
{ id: promote.id, action: 'promote', text: 'promote me' },
])
expect(discards).toEqual([[remove.id]])
decision.resolve({ kind: 'allow' })
await idle
expect(agent.session.events
.filter(event => event.type === 'steering/message')
.map(event => event.type === 'steering/message'
? event.data.message.content.flatMap(block => block.type === 'text' ? [block.text] : []).join('')
: ''))
.toEqual(['promote me', 'edited'])
})
})
describe('assistant replay provenance', () => {
@@ -59,6 +59,20 @@ describe('agent loop', () => {
},
)
it('seeds a valid AgentOptions.maxTokens into the first model request', async () => {
const adapter = new MockAdapter([textResponse('bounded')])
const ctx = await harness(adapter)
const agent = ctx.agentLoop.create(
SessionId('valid-max-tokens'),
{ provider: 'mock', model: 'mock', maxTokens: 256 },
)
send(agent, 'use the configured output limit')
await waitForIdle(ctx, agent)
expect(adapter.requests[0]?.maxTokens).toBe(256)
})
it('runs a simple turn: queued message → model → idle, with ordered events', async () => {
const adapter = new MockAdapter([textResponse('hello there')])
const ctx = await harness(adapter)
@@ -210,7 +210,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
if (!response.result.ok) expect(response.result.error.code).toBe('session-not-found')
})
it('covers create/prompt/cancel/describe passthrough', async () => {
it('covers create/prompt/updateQueue/cancel/describe passthrough', async () => {
const c = client()
expect((await c.sessions.create({})).result.ok).toBe(true)
expect((await c.sessions.models({ sessionId: 's' as never })).result.ok).toBe(true)
@@ -233,6 +233,11 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
const renamed = await c.sessions.rename({ sessionId: 's' as never, title: 'named' })
expect(renamed.result).toMatchObject({ ok: true, value: { title: 'named', seq: 0 } })
expect((await c.sessions.prompt({ sessionId: 's' as never, mode: 'queue', content: [{ type: 'text', text: 'x' }] })).result.ok).toBe(true)
expect((await c.sessions.updateQueue({
sessionId: 's' as never,
itemId: 'item-1' as never,
action: { kind: 'remove' },
})).result.ok).toBe(true)
expect((await c.sessions.cancel({ sessionId: 's' as never })).result.ok).toBe(true)
expect((await c.host.describe({})).result.ok).toBe(true)
})