diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index 96e6410e1d..08bc352ae6 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/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'), diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 0ecdf5e0f4..319fb4b430 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/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) { diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6a3c68e959..feb2af9cc1 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/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. diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 1cd6fa9bc5..bbac92a244 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/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' } }) } }) diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 0e690ab8ae..508077ff75 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/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([]) diff --git a/packages/core/agent/src/types.ts b/packages/core/agent/src/types.ts index f59f0d92fb..e9720d38dd 100644 --- a/packages/core/agent/src/types.ts +++ b/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 diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index fee3ace357..aa579a7f84 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -17,11 +17,12 @@ import type { function stubAgent(rawId: string, overrides: Partial = {}): 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 { cancel() {}, retry() {}, whenIdle() { return Promise.resolve() }, - ...overrides, } + return Object.assign(agent, overrides) } describe('AgentRegistry', () => { diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 670aa5585a..8f5925d7ab 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/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'), diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 44adca93e0..38785c9cd6 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/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'), diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index 90339fafe6..37d463b3a2 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/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'), diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index e13af5e2be..8ea858fcb9 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/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'), diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index 7d05e7342a..fb99c22af5 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/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) diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index a08b04dc91..50437ff6f0 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/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(), } } diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 383a96444b..aca836f4e7 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/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'), diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index f70442ded1..63045343dc 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/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) diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 200f1fb339..9f278c0f3c 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/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) diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 5d190e1f9c..65b59c2c09 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/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'), diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index cad1d30686..b304f9796f 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/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'), diff --git a/packages/ui/tui/src/index.ts b/packages/ui/tui/src/index.ts index ba31c7ca24..3fb0c4dcec 100644 --- a/packages/ui/tui/src/index.ts +++ b/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) { diff --git a/packages/ui/tui/tests/harness.ts b/packages/ui/tui/tests/harness.ts index 5c6c095785..6408a98aed 100644 --- a/packages/ui/tui/tests/harness.ts +++ b/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 { 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()