fix(tui): route prompts by next-step capability
This commit is contained in:
@@ -41,6 +41,7 @@ function sessionAgent(session: Session, id = 'agent'): Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
session,
|
session,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
acceptsNextStep: true,
|
||||||
ctx: new Context(),
|
ctx: new Context(),
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ function stubAgent(cwd?: string, seed: SessionEvent[] = []): Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
session,
|
session,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
acceptsNextStep: false,
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
inject(input) {
|
inject(input) {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
/** Whether observers see a running interval; consecutive turns share it. */
|
/** Whether observers see a running interval; consecutive turns share it. */
|
||||||
private busy = false
|
private busy = false
|
||||||
/** Whether next-step input belongs to the current admission or open turn. */
|
/** 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. */
|
/** Abort owner for the current admission or turn. */
|
||||||
private abort: AbortController | undefined
|
private abort: AbortController | undefined
|
||||||
/** Coalesced retry capability scoped to the active request-error waterfall. */
|
/** Coalesced retry capability scoped to the active request-error waterfall. */
|
||||||
@@ -99,7 +99,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
const { target, wakeup } = options
|
const { target, wakeup } = options
|
||||||
const id = AgentMessageId(randomUUID())
|
const id = AgentMessageId(randomUUID())
|
||||||
if (target === 'next-step' && !wakeup) {
|
if (target === 'next-step' && !wakeup) {
|
||||||
if (this.acceptingNextStep) {
|
if (this.acceptsNextStep) {
|
||||||
this.outbox.push({ content, source })
|
this.outbox.push({ content, source })
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
@@ -107,7 +107,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
return id
|
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 = {
|
const message: AgentMessage = {
|
||||||
id,
|
id,
|
||||||
content,
|
content,
|
||||||
@@ -215,7 +215,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
|
|
||||||
const admission = new AbortController()
|
const admission = new AbortController()
|
||||||
this.abort = admission
|
this.abort = admission
|
||||||
this.acceptingNextStep = true
|
this.acceptsNextStep = true
|
||||||
// Claimed admission is part of the running interval: it is cancellable
|
// Claimed admission is part of the running interval: it is cancellable
|
||||||
// activity, so observers (and their cancel routing) must see it.
|
// activity, so observers (and their cancel routing) must see it.
|
||||||
if (!this.busy) {
|
if (!this.busy) {
|
||||||
@@ -257,7 +257,7 @@ export class ReactLoopAgent implements Agent {
|
|||||||
// still owns the slot here and releasing it unconditionally is exact.
|
// still owns the slot here and releasing it unconditionally is exact.
|
||||||
this.abort = undefined
|
this.abort = undefined
|
||||||
if (admitted === undefined) {
|
if (admitted === undefined) {
|
||||||
this.acceptingNextStep = false
|
this.acceptsNextStep = false
|
||||||
// A synchronously aborted admission would otherwise publish idle
|
// A synchronously aborted admission would otherwise publish idle
|
||||||
// inside send()'s own synchronous extent, before any post-send
|
// inside send()'s own synchronous extent, before any post-send
|
||||||
// subscriber could observe the transition.
|
// 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`)
|
if (this.abort !== undefined) throw new Error(`agent "${this.id}" is already running`)
|
||||||
const controller = new AbortController()
|
const controller = new AbortController()
|
||||||
this.abort = controller
|
this.abort = controller
|
||||||
this.acceptingNextStep = true
|
this.acceptsNextStep = true
|
||||||
if (!this.busy) {
|
if (!this.busy) {
|
||||||
this.busy = true
|
this.busy = true
|
||||||
emitAgentEvent(this.loopCtx, this, 'agent/status', 'running')
|
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
|
// Every step-close happens before this point on both success and
|
||||||
// failure paths (step(), the request-failed branch, the catch), so the
|
// failure paths (step(), the request-failed branch, the catch), so the
|
||||||
// finally owes only the turn boundary.
|
// finally owes only the turn boundary.
|
||||||
this.acceptingNextStep = false
|
this.acceptsNextStep = false
|
||||||
try {
|
try {
|
||||||
if (this.turnOpen) {
|
if (this.turnOpen) {
|
||||||
// Re-entrant turn/end listeners must route new input to a later turn.
|
// 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/start') turns.push(event.data.turn)
|
||||||
if (event.type === 'turn/end' && !steeredOnce) {
|
if (event.type === 'turn/end' && !steeredOnce) {
|
||||||
steeredOnce = true
|
steeredOnce = true
|
||||||
|
expect(agent.acceptsNextStep).toBe(false)
|
||||||
agent.steer({ content: [{ type: 'text', text: 'too late for this turn' }], source: { kind: 'user' } })
|
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')
|
send(agent, 'admitted prompt')
|
||||||
await entered.promise
|
await entered.promise
|
||||||
expect(agent.status).toBe('running')
|
expect(agent.status).toBe('running')
|
||||||
|
expect(agent.acceptsNextStep).toBe(true)
|
||||||
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
expect(events(agent).some(event => event.type === 'turn/start')).toBe(false)
|
||||||
|
|
||||||
agent.inject({
|
agent.inject({
|
||||||
@@ -195,6 +196,7 @@ describe('agent/prompt-submit', () => {
|
|||||||
|
|
||||||
decision.resolve({ kind: 'allow' })
|
decision.resolve({ kind: 'allow' })
|
||||||
await idle
|
await idle
|
||||||
|
expect(agent.acceptsNextStep).toBe(false)
|
||||||
|
|
||||||
const staged = events(agent).filter(event =>
|
const staged = events(agent).filter(event =>
|
||||||
event.type === 'turn/start' || event.type === 'user/message' || event.type === 'steering/message')
|
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)
|
const blockedIdle = waitForIdle(ctx, agent)
|
||||||
send(agent, 'blocked prompt')
|
send(agent, 'blocked prompt')
|
||||||
await entered.promise
|
await entered.promise
|
||||||
|
expect(agent.acceptsNextStep).toBe(true)
|
||||||
agent.inject({
|
agent.inject({
|
||||||
content: [{ type: 'text', text: 'staged context' }],
|
content: [{ type: 'text', text: 'staged context' }],
|
||||||
source: { kind: 'plugin', plugin: 'test' },
|
source: { kind: 'plugin', plugin: 'test' },
|
||||||
@@ -238,6 +241,7 @@ describe('agent/prompt-submit', () => {
|
|||||||
decision.resolve({ kind: 'block', reason: 'policy' })
|
decision.resolve({ kind: 'block', reason: 'policy' })
|
||||||
await blockedIdle
|
await blockedIdle
|
||||||
|
|
||||||
|
expect(agent.acceptsNextStep).toBe(false)
|
||||||
expect(events(agent)).toEqual([])
|
expect(events(agent)).toEqual([])
|
||||||
expect(adapter.requests).toEqual([])
|
expect(adapter.requests).toEqual([])
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,12 @@ export interface Agent {
|
|||||||
readonly session: Session
|
readonly session: Session
|
||||||
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
/** The current lifecycle state, mirrored on every `agent/status` transition. */
|
||||||
readonly status: AgentStatus
|
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. */
|
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
|
||||||
readonly ctx: Context
|
readonly ctx: Context
|
||||||
|
|
||||||
|
|||||||
@@ -17,11 +17,12 @@ import type {
|
|||||||
|
|
||||||
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
||||||
const id = SessionId(rawId)
|
const id = SessionId(rawId)
|
||||||
return {
|
const agent: Agent = {
|
||||||
id,
|
id,
|
||||||
options: {},
|
options: {},
|
||||||
session: new Session(id),
|
session: new Session(id),
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
acceptsNextStep: false,
|
||||||
ctx: new Context(),
|
ctx: new Context(),
|
||||||
send: () => AgentMessageId('stub'),
|
send: () => AgentMessageId('stub'),
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
@@ -30,8 +31,8 @@ function stubAgent(rawId: string, overrides: Partial<Agent> = {}): Agent {
|
|||||||
cancel() {},
|
cancel() {},
|
||||||
retry() {},
|
retry() {},
|
||||||
whenIdle() { return Promise.resolve() },
|
whenIdle() { return Promise.resolve() },
|
||||||
...overrides,
|
|
||||||
}
|
}
|
||||||
|
return Object.assign(agent, overrides)
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('AgentRegistry', () => {
|
describe('AgentRegistry', () => {
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function stubAgent(id: string): { agent: Agent; session: Session } {
|
|||||||
session,
|
session,
|
||||||
ctx: new Context(),
|
ctx: new Context(),
|
||||||
get status() { return status },
|
get status() { return status },
|
||||||
|
get acceptsNextStep() { return status === 'running' },
|
||||||
send: () => AgentMessageId('stub'),
|
send: () => AgentMessageId('stub'),
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ function stubAgentForSession(session: Session): StubAgent {
|
|||||||
session,
|
session,
|
||||||
ctx: new Context(),
|
ctx: new Context(),
|
||||||
get status() { return status },
|
get status() { return status },
|
||||||
|
get acceptsNextStep() { return status === 'running' },
|
||||||
send: () => AgentMessageId('stub'),
|
send: () => AgentMessageId('stub'),
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ function stubAgent(rawId: string, supplied?: Session): StubAgent {
|
|||||||
options: {},
|
options: {},
|
||||||
session,
|
session,
|
||||||
get status() { return status },
|
get status() { return status },
|
||||||
|
get acceptsNextStep() { return status === 'running' },
|
||||||
ctx: new Context(),
|
ctx: new Context(),
|
||||||
send: () => AgentMessageId('stub'),
|
send: () => AgentMessageId('stub'),
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ function stubAgent(session: Session): Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
session,
|
session,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
acceptsNextStep: false,
|
||||||
ctx: new Context(),
|
ctx: new Context(),
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ function config(): ResolvedConfig {
|
|||||||
function agent(ctx: Context): Agent {
|
function agent(ctx: Context): Agent {
|
||||||
const id = SessionId('agent')
|
const id = SessionId('agent')
|
||||||
return {
|
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(),
|
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 session = ctx.sessions.create(SessionId('mode-owner'))
|
||||||
const ownerFiber = await ctx.plugin(() => {})
|
const ownerFiber = await ctx.plugin(() => {})
|
||||||
const owner: Agent = {
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
ctx.agents.register(owner)
|
ctx.agents.register(owner)
|
||||||
@@ -291,7 +291,7 @@ describe('pty-local plugin shape', () => {
|
|||||||
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
|
const session = ctx.sessions.create(SessionId('pending-mode-owner'))
|
||||||
const ownerFiber = await ctx.plugin(() => {})
|
const ownerFiber = await ctx.plugin(() => {})
|
||||||
const owner: Agent = {
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
ctx.agents.register(owner)
|
ctx.agents.register(owner)
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
|||||||
const id = SessionId(rawId)
|
const id = SessionId(rawId)
|
||||||
const scope = ctx.plugin(() => {})
|
const scope = ctx.plugin(() => {})
|
||||||
return {
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
session: new Session(id),
|
session: new Session(id),
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
acceptsNextStep: false,
|
||||||
ctx: scopeFiber.ctx,
|
ctx: scopeFiber.ctx,
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function agent(ctx: Context): Agent {
|
|||||||
const scope = ctx.plugin(() => {})
|
const scope = ctx.plugin(() => {})
|
||||||
const id = SessionId('pty-loader-agent')
|
const id = SessionId('pty-loader-agent')
|
||||||
const value: 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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
ctx.agents.register(value)
|
ctx.agents.register(value)
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function fakeAgent(ctx: Context, rawId: string): Agent {
|
|||||||
const scope = ctx.plugin(() => {})
|
const scope = ctx.plugin(() => {})
|
||||||
const id = SessionId(rawId)
|
const id = SessionId(rawId)
|
||||||
const agent: Agent = {
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
}
|
}
|
||||||
ctx.agents.register(agent)
|
ctx.agents.register(agent)
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ function agentForCwd(cwd: string): Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
session,
|
session,
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
|
acceptsNextStep: false,
|
||||||
send: () => AgentMessageId('stub'),
|
send: () => AgentMessageId('stub'),
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ function stubAgent(ctx: Context, rawId: string): Agent {
|
|||||||
options: {},
|
options: {},
|
||||||
session: new Session(id),
|
session: new Session(id),
|
||||||
status: 'idle' as const,
|
status: 'idle' as const,
|
||||||
|
acceptsNextStep: false,
|
||||||
ctx: scopeFiber.ctx,
|
ctx: scopeFiber.ctx,
|
||||||
followup: () => AgentMessageId('stub'),
|
followup: () => AgentMessageId('stub'),
|
||||||
steer: () => AgentMessageId('stub'),
|
steer: () => AgentMessageId('stub'),
|
||||||
|
|||||||
@@ -2893,7 +2893,7 @@ export function createTuiChat(
|
|||||||
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
appendNotice(`Agent "${agent.id}" is disposed.`, 'error')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (agent.status === 'running') {
|
if (agent.acceptsNextStep) {
|
||||||
// Steering is never subject to prompt admission; an attached snapshot
|
// Steering is never subject to prompt admission; an attached snapshot
|
||||||
// drains beside it at the same step boundary through the outbox.
|
// drains beside it at the same step boundary through the outbox.
|
||||||
if (attachedContext !== undefined) {
|
if (attachedContext !== undefined) {
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ interface FakeAgent extends Agent {
|
|||||||
|
|
||||||
export interface TuiHarnessOptions {
|
export interface TuiHarnessOptions {
|
||||||
status?: AgentStatus
|
status?: AgentStatus
|
||||||
|
/** Override the fake agent's next-step capability independently of status. */
|
||||||
|
acceptsNextStep?: boolean
|
||||||
config?: Config
|
config?: Config
|
||||||
/** Leave the session event log empty instead of seeding one turn and step. */
|
/** Leave the session event log empty instead of seeding one turn and step. */
|
||||||
omitInitialLifecycle?: boolean
|
omitInitialLifecycle?: boolean
|
||||||
@@ -191,6 +193,9 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
|
|||||||
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
options: options.agentOptions ?? { provider: 'deepseek', model: 'deepseek-v4-flash' },
|
||||||
session,
|
session,
|
||||||
status: options.status ?? 'idle',
|
status: options.status ?? 'idle',
|
||||||
|
get acceptsNextStep() {
|
||||||
|
return options.acceptsNextStep ?? this.status === 'running'
|
||||||
|
},
|
||||||
ctx,
|
ctx,
|
||||||
sent,
|
sent,
|
||||||
sentOptions,
|
sentOptions,
|
||||||
|
|||||||
@@ -1934,6 +1934,45 @@ describe('pi-tui chat lifecycle and transcript', () => {
|
|||||||
await dispose(result)
|
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 () => {
|
it('releases the reference-admission wrapper on the ordinary allowed path', async () => {
|
||||||
const result = await setup({
|
const result = await setup({
|
||||||
async configureContext(ctx) {
|
async configureContext(ctx) {
|
||||||
@@ -3707,7 +3746,7 @@ describe('terminal mounting', () => {
|
|||||||
ctx.provide('tools', { get: () => undefined } as never)
|
ctx.provide('tools', { get: () => undefined } as never)
|
||||||
const session = ctx.sessions.create(SessionId('main'))
|
const session = ctx.sessions.create(SessionId('main'))
|
||||||
ctx.agents.register({
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
const terminal = new FakeTerminal()
|
const terminal = new FakeTerminal()
|
||||||
@@ -3731,7 +3770,7 @@ describe('terminal mounting', () => {
|
|||||||
ctx.provide('tools', { get: () => undefined } as never)
|
ctx.provide('tools', { get: () => undefined } as never)
|
||||||
const session = ctx.sessions.create(SessionId('main'))
|
const session = ctx.sessions.create(SessionId('main'))
|
||||||
ctx.agents.register({
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
const terminal = new FakeTerminal()
|
const terminal = new FakeTerminal()
|
||||||
@@ -3765,14 +3804,14 @@ describe('terminal mounting', () => {
|
|||||||
|
|
||||||
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
const otherSession = ctx.sessions.create(SessionId('other-session'))
|
||||||
ctx.agents.register({
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
expect(terminal.started).toBe(0)
|
expect(terminal.started).toBe(0)
|
||||||
|
|
||||||
const session = ctx.sessions.create(SessionId('late-session'))
|
const session = ctx.sessions.create(SessionId('late-session'))
|
||||||
const agent = {
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
} as Agent
|
} as Agent
|
||||||
ctx.agents.register(agent)
|
ctx.agents.register(agent)
|
||||||
@@ -3802,7 +3841,7 @@ describe('terminal mounting', () => {
|
|||||||
|
|
||||||
const session = ctx.sessions.create(SessionId('main-session'))
|
const session = ctx.sessions.create(SessionId('main-session'))
|
||||||
ctx.agents.register({
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
await tick()
|
await tick()
|
||||||
@@ -3844,7 +3883,7 @@ describe('terminal mounting', () => {
|
|||||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||||
session.append('step/start', { turn: 1, step: 1 })
|
session.append('step/start', { turn: 1, step: 1 })
|
||||||
ctx.agents.register({
|
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(),
|
followup: () => AgentMessageId('stub'), steer: () => AgentMessageId('stub'), inject: () => AgentMessageId('stub'), send: () => AgentMessageId('stub'), cancel() {}, retry() {}, whenIdle: () => Promise.resolve(),
|
||||||
})
|
})
|
||||||
const terminal = new FakeTerminal()
|
const terminal = new FakeTerminal()
|
||||||
|
|||||||
Reference in New Issue
Block a user