Sfoglia il codice sorgente

fix(tui): route prompts by next-step capability

_Kerman 2 mesi fa
parent
commit
f63d937496

+ 1 - 0
packages/context/time-context/tests/time-context.spec.ts

@@ -41,6 +41,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
     options: {},
     session,
     status: 'running',
+    acceptsNextStep: true,
     ctx: new Context(),
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),

+ 1 - 0
packages/context/workspace-context/tests/workspace-context.spec.ts

@@ -177,6 +177,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
     options: {},
     session,
     status: 'idle',
+    acceptsNextStep: false,
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),
     inject(input) {

+ 7 - 7
packages/core/agent-loop/src/agent.ts

@@ -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.

+ 1 - 0
packages/core/agent-loop/tests/contract-regressions.spec.ts

@@ -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' } })
       }
     })

+ 4 - 0
packages/core/agent-loop/tests/interception.spec.ts

@@ -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([])
 

+ 6 - 0
packages/core/agent/src/types.ts

@@ -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
 

+ 3 - 2
packages/core/agent/tests/agent.spec.ts

@@ -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', () => {

+ 1 - 0
packages/goal/command-goal/tests/command-goal.spec.ts

@@ -31,6 +31,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
     session,
     ctx: new Context(),
     get status() { return status },
+    get acceptsNextStep() { return status === 'running' },
     send: () => AgentMessageId('stub'),
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),

+ 1 - 0
packages/goal/goal/tests/goal.spec.ts

@@ -46,6 +46,7 @@ function stubAgentForSession(session: Session): StubAgent {
     session,
     ctx: new Context(),
     get status() { return status },
+    get acceptsNextStep() { return status === 'running' },
     send: () => AgentMessageId('stub'),
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),

+ 1 - 0
packages/goal/tool-goal/tests/tool-goal.spec.ts

@@ -30,6 +30,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
     options: {},
     session,
     get status() { return status },
+    get acceptsNextStep() { return status === 'running' },
     ctx: new Context(),
     send: () => AgentMessageId('stub'),
     followup: () => AgentMessageId('stub'),

+ 1 - 0
packages/host/apiproxy/tests/api-proxy-workspace.spec.ts

@@ -43,6 +43,7 @@ function stubAgent(session: Session): Agent {
     options: {},
     session,
     status: 'idle',
+    acceptsNextStep: false,
     ctx: new Context(),
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),

+ 3 - 3
packages/pty/pty-local/tests/index.spec.ts

@@ -41,7 +41,7 @@ function config(): ResolvedConfig {
 function agent(ctx: Context): Agent {
   const id = SessionId('agent')
   return {
-    id, options: {}, session: new Session(id), status: 'idle', ctx,
+    id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
     followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
   }
 }
@@ -248,7 +248,7 @@ describe('pty-local plugin shape', () => {
     const session = ctx.sessions.create(SessionId('mode-owner'))
     const ownerFiber = await ctx.plugin(() => {})
     const owner: Agent = {
-      id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
+      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     }
     ctx.agents.register(owner)
@@ -291,7 +291,7 @@ describe('pty-local plugin shape', () => {
     const session = ctx.sessions.create(SessionId('pending-mode-owner'))
     const ownerFiber = await ctx.plugin(() => {})
     const owner: Agent = {
-      id: session.id, options: {}, session, status: 'idle', ctx: ownerFiber.ctx,
+      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx: ownerFiber.ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     }
     ctx.agents.register(owner)

+ 1 - 1
packages/pty/pty-local/tests/local.spec.ts

@@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
   const id = SessionId(rawId)
   const scope = ctx.plugin(() => {})
   return {
-    id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
+    id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
     followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
   }
 }

+ 1 - 0
packages/pty/pty/tests/service.spec.ts

@@ -26,6 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
     options: {},
     session: new Session(id),
     status: 'idle',
+    acceptsNextStep: false,
     ctx: scopeFiber.ctx,
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),

+ 1 - 1
packages/pty/tool-pty/tests/loader-composition.spec.ts

@@ -39,7 +39,7 @@ function agent(ctx: Context): Agent {
   const scope = ctx.plugin(() => {})
   const id = SessionId('pty-loader-agent')
   const value: Agent = {
-    id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
+    id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
     followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(value)

+ 1 - 1
packages/pty/tool-pty/tests/tools.spec.ts

@@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
   const scope = ctx.plugin(() => {})
   const id = SessionId(rawId)
   const agent: Agent = {
-    id, options: {}, session: new Session(id), status: 'idle', ctx: scope.ctx,
+    id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx: scope.ctx,
     followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
   }
   ctx.agents.register(agent)

+ 1 - 0
packages/skill/tool-skill/tests/tool-skill.spec.ts

@@ -45,6 +45,7 @@ function agentForCwd(cwd: string): Agent {
     options: {},
     session,
     status: 'idle',
+    acceptsNextStep: false,
     send: () => AgentMessageId('stub'),
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),

+ 1 - 0
packages/tasks/tasks-local/tests/tasks.spec.ts

@@ -23,6 +23,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
     options: {},
     session: new Session(id),
     status: 'idle' as const,
+    acceptsNextStep: false,
     ctx: scopeFiber.ctx,
     followup: () => AgentMessageId('stub'),
     steer: () => AgentMessageId('stub'),

+ 1 - 1
packages/ui/tui/src/index.ts

@@ -2893,7 +2893,7 @@ export function createTuiChat(
       appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
       return
     }
-    if (agent.status === 'running') {
+    if (agent.acceptsNextStep) {
       // Steering is never subject to prompt admission; an attached snapshot
       // drains beside it at the same step boundary through the outbox.
       if (attachedContext !== undefined) {

+ 5 - 0
packages/ui/tui/tests/harness.ts

@@ -36,6 +36,8 @@ interface FakeAgent extends Agent {
 
 export interface TuiHarnessOptions {
   status?: AgentStatus
+  /** Override the fake agent's next-step capability independently of status. */
+  acceptsNextStep?: boolean
   config?: Config
   /** Leave the session event log empty instead of seeding one turn and step. */
   omitInitialLifecycle?: boolean
@@ -191,6 +193,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
     options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
     session,
     status: options.status ?? 'idle',
+    get acceptsNextStep() {
+      return options.acceptsNextStep ?? this.status === 'running'
+    },
     ctx,
     sent,
     sentOptions,

+ 45 - 6
packages/ui/tui/tests/tui.spec.ts

@@ -1934,6 +1934,45 @@ describe('pi-tui chat lifecycle and transcript', () => {
     await dispose(result)
   })
 
+  it('keeps a referenced prompt on admission when running no longer accepts next-step input', async () => {
+    const result = await setup({
+      status: 'running',
+      acceptsNextStep: false,
+      omitInitialLifecycle: true,
+      async configureContext(ctx) {
+        ctx.provide('tools', { get: () => undefined } as never)
+        await ctx.plugin(TestSessionQueryService)
+        await ctx.plugin(SessionReferenceService)
+        const source = ctx.sessions.create(SessionId('admission-src'), {
+          meta: { cwd: process.cwd(), createdAt: 1 },
+        })
+        appendUser(source, 'source background')
+        source.append('session/title', {
+          title: 'Admission source',
+          messageSeqs: [0],
+          source: { kind: 'fallback' },
+        })
+      },
+    })
+
+    result.terminal.send(formatSessionReferenceMention({
+      sessionId: SessionId('admission-src'),
+      label: 'Admission source',
+    }))
+    result.terminal.send('\r')
+    await vi.waitFor(() => { expect(result.agent.sent).toHaveLength(1) })
+
+    expect(result.agent.steered).toHaveLength(0)
+    expect(result.agent.injected).toHaveLength(0)
+    const decision = await agentEvents(result.ctx, result.agent).waterfall(
+      'agent/prompt-submit', result.agent.sent[0]!, { kind: 'user' },
+      new AbortController().signal, () => Promise.resolve({ kind: 'allow' as const }),
+    )
+    expect(decision.kind === 'allow' && decision.additionalContexts?.[0]?.source)
+      .toMatchObject({ kind: 'session-reference', references: [{ sessionId: 'admission-src' }] })
+    await dispose(result)
+  })
+
   it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
     const result = await setup({
       async configureContext(ctx) {
@@ -3707,7 +3746,7 @@ describe('terminal mounting', () => {
     ctx.provide('tools', { get: () => undefined } as never)
     const session = ctx.sessions.create(SessionId('main'))
     ctx.agents.register({
-      id: session.id, options: {}, session, status: 'idle', ctx,
+      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     })
     const terminal = new FakeTerminal()
@@ -3731,7 +3770,7 @@ describe('terminal mounting', () => {
     ctx.provide('tools', { get: () => undefined } as never)
     const session = ctx.sessions.create(SessionId('main'))
     ctx.agents.register({
-      id: session.id, options: {}, session, status: 'idle', ctx,
+      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     })
     const terminal = new FakeTerminal()
@@ -3765,14 +3804,14 @@ describe('terminal mounting', () => {
 
     const otherSession = ctx.sessions.create(SessionId('other-session'))
     ctx.agents.register({
-      id: otherSession.id, options: {}, session: otherSession, status: 'idle', ctx,
+      id: otherSession.id, options: {}, session: otherSession, status: 'idle', acceptsNextStep: false, ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     })
     expect(terminal.started).toBe(0)
 
     const session = ctx.sessions.create(SessionId('late-session'))
     const agent = {
-      id: session.id, options: {}, session, status: 'idle', ctx,
+      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     } as Agent
     ctx.agents.register(agent)
@@ -3802,7 +3841,7 @@ describe('terminal mounting', () => {
 
     const session = ctx.sessions.create(SessionId('main-session'))
     ctx.agents.register({
-      id: session.id, options: {}, session, status: 'idle', ctx,
+      id: session.id, options: {}, session, status: 'idle', acceptsNextStep: false, ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     })
     await tick()
@@ -3844,7 +3883,7 @@ describe('terminal mounting', () => {
     session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
     session.append('step/start', { turn: 1, step: 1 })
     ctx.agents.register({
-      id: session.id, options: {}, session, status: 'running', ctx,
+      id: session.id, options: {}, session, status: 'running', acceptsNextStep: true, ctx,
       followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
     })
     const terminal = new FakeTerminal()