fix(tui): route prompts by next-step capability
This commit is contained in:
@@ -53,7 +53,7 @@ export class ReactLoopAgent implements Agent {
|
||||
/** Whether observers see a running interval; consecutive turns share it. */
|
||||
private busy = false
|
||||
/** Whether next-step input belongs to the current admission or open turn. */
|
||||
private acceptingNextStep = false
|
||||
acceptsNextStep = false
|
||||
/** Abort owner for the current admission or turn. */
|
||||
private abort: AbortController | undefined
|
||||
/** Coalesced retry capability scoped to the active request-error waterfall. */
|
||||
@@ -99,7 +99,7 @@ export class ReactLoopAgent implements Agent {
|
||||
const { target, wakeup } = options
|
||||
const id = AgentMessageId(randomUUID())
|
||||
if (target === 'next-step' && !wakeup) {
|
||||
if (this.acceptingNextStep) {
|
||||
if (this.acceptsNextStep) {
|
||||
this.outbox.push({ content, source })
|
||||
return id
|
||||
}
|
||||
@@ -107,7 +107,7 @@ export class ReactLoopAgent implements Agent {
|
||||
return id
|
||||
}
|
||||
|
||||
const placement: InboxPlacement = target === 'next-step' && this.acceptingNextStep ? 'steering' : 'queued'
|
||||
const placement: InboxPlacement = target === 'next-step' && this.acceptsNextStep ? 'steering' : 'queued'
|
||||
const message: AgentMessage = {
|
||||
id,
|
||||
content,
|
||||
@@ -215,7 +215,7 @@ export class ReactLoopAgent implements Agent {
|
||||
|
||||
const admission = new AbortController()
|
||||
this.abort = admission
|
||||
this.acceptingNextStep = true
|
||||
this.acceptsNextStep = true
|
||||
// Claimed admission is part of the running interval: it is cancellable
|
||||
// activity, so observers (and their cancel routing) must see it.
|
||||
if (!this.busy) {
|
||||
@@ -257,7 +257,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// still owns the slot here and releasing it unconditionally is exact.
|
||||
this.abort = undefined
|
||||
if (admitted === undefined) {
|
||||
this.acceptingNextStep = false
|
||||
this.acceptsNextStep = false
|
||||
// A synchronously aborted admission would otherwise publish idle
|
||||
// inside send()'s own synchronous extent, before any post-send
|
||||
// subscriber could observe the transition.
|
||||
@@ -284,7 +284,7 @@ export class ReactLoopAgent implements Agent {
|
||||
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
|
||||
const controller = new AbortController()
|
||||
this.abort = controller
|
||||
this.acceptingNextStep = true
|
||||
this.acceptsNextStep = true
|
||||
if (!this.busy) {
|
||||
this.busy = true
|
||||
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
|
||||
@@ -390,7 +390,7 @@ export class ReactLoopAgent implements Agent {
|
||||
// Every step-close happens before this point on both success and
|
||||
// failure paths (step(), the request-failed branch, the catch), so the
|
||||
// finally owes only the turn boundary.
|
||||
this.acceptingNextStep = false
|
||||
this.acceptsNextStep = false
|
||||
try {
|
||||
if (this.turnOpen) {
|
||||
// Re-entrant turn/end listeners must route new input to a later turn.
|
||||
|
||||
@@ -338,6 +338,7 @@ describe('steering from late extension points is never stranded', () => {
|
||||
if (event.type === 'turn/start') turns.push(event.data.turn)
|
||||
if (event.type === 'turn/end' && !steeredOnce) {
|
||||
steeredOnce = true
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
agent.steer({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -183,6 +183,7 @@ describe('agent/prompt-submit', () => {
|
||||
send(agent, 'admitted prompt')
|
||||
await entered.promise
|
||||
expect(agent.status).toBe('running')
|
||||
expect(agent.acceptsNextStep).toBe(true)
|
||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||
|
||||
agent.inject({
|
||||
@@ -195,6 +196,7 @@ describe('agent/prompt-submit', () => {
|
||||
|
||||
decision.resolve({ kind: 'allow' })
|
||||
await idle
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
|
||||
const staged = events(agent).filter(event =>
|
||||
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
|
||||
@@ -230,6 +232,7 @@ describe('agent/prompt-submit', () => {
|
||||
const blockedIdle = waitForIdle(ctx, agent)
|
||||
send(agent, 'blocked prompt')
|
||||
await entered.promise
|
||||
expect(agent.acceptsNextStep).toBe(true)
|
||||
agent.inject({
|
||||
content: [{ type: 'text', text: 'staged context' }],
|
||||
source: { kind: 'plugin', plugin: 'test' },
|
||||
@@ -238,6 +241,7 @@ describe('agent/prompt-submit', () => {
|
||||
decision.resolve({ kind: 'block', reason: 'policy' })
|
||||
await blockedIdle
|
||||
|
||||
expect(agent.acceptsNextStep).toBe(false)
|
||||
expect(events(agent)).toEqual([])
|
||||
expect(adapter.requests).toEqual([])
|
||||
|
||||
|
||||
@@ -147,6 +147,12 @@ export interface Agent {
|
||||
readonly session: Session
|
||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||
readonly status: AgentStatus
|
||||
/**
|
||||
* Whether a `next-step` send currently stages for prompt admission or the
|
||||
* open turn. Unlike {@link status}, this excludes admission exit and turn
|
||||
* settlement, when a waking `next-step` send becomes a queued follow-up.
|
||||
*/
|
||||
readonly acceptsNextStep: boolean
|
||||
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||
readonly ctx: Context
|
||||
|
||||
|
||||
@@ -17,11 +17,12 @@ import type {
|
||||
|
||||
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
const id = SessionId(rawId)
|
||||
return {
|
||||
const agent: Agent = {
|
||||
id,
|
||||
options: {},
|
||||
session: new Session(id),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: new Context(),
|
||||
send: () => AgentMessageId('stub'),
|
||||
followup: () => AgentMessageId('stub'),
|
||||
@@ -30,8 +31,8 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||
cancel() {},
|
||||
retry() {},
|
||||
whenIdle() { return Promise.resolve() },
|
||||
...overrides,
|
||||
}
|
||||
return Object.assign(agent, overrides)
|
||||
}
|
||||
|
||||
describe('AgentRegistry', () => {
|
||||
|
||||
Reference in New Issue
Block a user