fix: guard human interaction by runtime ownership

This commit is contained in:
Tianyi Cui
2026-08-08 15:30:08 +08:00
parent 408de0f588
commit dc64c5f1c2
27 changed files with 935 additions and 81 deletions

View File

@@ -1174,7 +1174,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>',
jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * Human-interaction requests are only valid from a top-level agent: a\n * delegated subagent has no human answerer in its own context, so asking\n * there would block forever. This mirrors the goal tools\' top-level-only\n * authority (`create_goal` rejects non-top-level agents).\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n * @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling\n * agent is a delegated subagent (`session.header.delegationDepth > 0`).\n */',
jsDoc: '/**\n * Ask the active UI provider and wait for the user\'s answer.\n *\n * When a caller supplies an agent, human interaction is valid only for the\n * exact live runtime root. Runtime ownership, not durable session lineage,\n * decides this boundary: an owned child has no human answerer and would\n * block forever, while a lineage-bearing session resumed as a new runtime\n * root may ask normally.\n *\n * @param request Questions, owner agent, and abort signal.\n * @returns The answer chosen or typed by the human.\n * @throws {UserInteractionError} code `CALLER_NOT_LIVE` when a supplied\n * agent is not the registry\'s exact live instance, or `DELEGATED_CALLER`\n * when that live agent is owned by another agent.\n */',
},
],
},

View File

@@ -4,7 +4,7 @@ import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools'
import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session'
import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import AgentRegistry, { agentEvents, type Agent } from '@deepseek-ai/dsh-agent'
import { createScope } from '@deepseek-ai/dsh-scope'
import UserInteractionService, {
UserInteractionError, type AskUserQuestionRequest,
@@ -25,7 +25,11 @@ const PLAN_CONFIG = { section: TEST_PLAN_SECTION } satisfies PlanModeConfig
* and the following `step/start` session event used by the loop.
*/
async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { active?: boolean } = {}): Promise<Agent & { session: Session }> {
async function agentWithSession(
ctx: Context,
id = 'agent-1',
{ active, owner }: { active?: boolean; owner?: Agent } = {},
): Promise<Agent & { session: Session }> {
// A live store session when a store is mounted (the command executor logs
// lifecycle events through it); bare otherwise (fold/tool-only benches).
const session = Session.create(SessionId(id))
@@ -44,8 +48,15 @@ async function agentWithSession(ctx: Context, id = 'agent-1', { active }: { acti
;(agent as { ctx?: Context }).ctx = scoped
// Seeded plan state lands before the creation announcement, matching resume.
if (active !== undefined) session.append('plan/mode', { active })
// The loop announces creation after publication.
ctx.emit('agent/created', { agent })
// The loop publishes through the live registry when it is composed; narrow
// fold-only benches retain the direct lifecycle event used before it exists.
const agents = ctx.get('agents')
if (agents === undefined) {
ctx.emit('agent/created', { agent })
} else {
agents.enter(agent, owner)
agents.announce(agent)
}
return agent
}
@@ -653,6 +664,7 @@ describe('/plan', () => {
describe('exit_plan_mode', () => {
async function setupWithReview(answer?: { selected: string[]; custom?: string }) {
const ctx = await setup()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
if (answer !== undefined) {
@@ -730,6 +742,26 @@ describe('exit_plan_mode', () => {
expect(foldPlanMode(agent.session.events)).toBe(true)
})
it('rejects review from a runtime-owned agent with consumer-neutral guidance', async () => {
const ctx = await setup()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const ask = vi.fn(async () => ({ answers: [{ id: 'plan-review', selected: ['Approve'] }] }))
ctx.userInteraction.registerProvider({ ask })
const root = await agentWithSession(ctx, 'review-root')
const child = await agentWithSession(ctx, 'review-child', { active: true, owner: root })
const result = await callExit(ctx, child)
expect(result.isError).toBe(true)
expect(result.content).toEqual([{
type: 'text',
text: "Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result",
}])
expect(ask).not.toHaveBeenCalled()
expect(foldPlanMode(child.session.events)).toBe(true)
})
it('approve: records the boundary-applied switch and confirms (the fold flips at the flush)', async () => {
const { ctx, agent, asked } = await setupWithReview({ selected: ['Approve'] })
const result = await callExit(ctx, agent)
@@ -765,6 +797,7 @@ describe('exit_plan_mode', () => {
await ctx.plugin(ToolRegistry, { mode: 'code' })
await ctx.plugin(ExitRuntime)
await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const asked: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
@@ -939,6 +972,7 @@ describe('exit_plan_mode', () => {
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
const fiber = await ctx.plugin(PlanModeService, PLAN_CONFIG)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
let answer!: (value: { answers: { id: string; selected: string[] }[] }) => void
ctx.userInteraction.registerProvider({

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/tool-ask-user/README.md
README.md: cb1cc11bba4010c885f320fc5569509ff48fccc0
README.zh.md: 7558816a2874509717c50e22b93a548697f86ef7
README.md: 7af356263ea6582a081e7c6de22fd317ca8b96df
README.zh.md: 3f7b814b83c8c6957a4b2574ee69e87d45f65ae6

View File

@@ -54,5 +54,5 @@ Append-only; newly visible content follows the reusable request prefix and does
## Known Limitations and Deferred Work
- **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only.
- **Delegated subagents cannot ask the user** — `ask_user_question` rejects calls from a delegated subagent with `DELEGATED_CALLER`; a child that needs a decision must delegate the question to the top-level agent.
- **Runtime-owned subagents cannot ask the user** — `ask_user_question` rejects a live child owned by another agent with `DELEGATED_CALLER`; the child must include the unresolved question or decision in its final result. Durable lineage does not decide this boundary, so a lineage-bearing session resumed as a runtime root may ask normally.
- **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary.

View File

@@ -54,5 +54,5 @@
## 已知限制与暂缓事项
- **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`
- **委托的子代理不能向用户提问**`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝来自委托子代理的调用;需要决策的子代理必须把问题转交给顶层代理
- **运行时中归属于其他 agent 的 subagent 不能向用户提问**`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝由另一个 agent 所有的存活子级;该子级必须在最终结果中包含尚未解决的问题或决策。持久化会话谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问
- **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON而非更丰富的内容块词汇。

View File

@@ -1,8 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry from '@deepseek-ai/dsh-tools'
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
@@ -28,6 +27,7 @@ interface OptionSchemaShape {
async function setup() {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(UserInteractionService)
@@ -35,6 +35,14 @@ async function setup() {
return ctx
}
function stubAgent(id: string, delegationDepth = 0): Agent {
const agentId = id as Agent['id']
return {
id: agentId,
session: { id: agentId, header: { delegationDepth } },
} as unknown as Agent
}
describe('ask_user_question tool', () => {
it('registers a model-facing tool schema', async () => {
const ctx = await setup()
@@ -208,9 +216,8 @@ describe('ask_user_question tool', () => {
expect(seen[0]?.signal).toBe(controller.signal)
})
it('passes optional header and agent through to the user-interaction request', async () => {
it('passes optional header and a resumed runtime root through to the user-interaction request', async () => {
const ctx = await setup()
await ctx.plugin(SessionStore)
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
@@ -218,8 +225,8 @@ describe('ask_user_question tool', () => {
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } })
const agent = { session } as unknown as Agent
const agent = stubAgent('resumed-root', 1)
ctx.agents.enter(agent, undefined)
const result = await ctx.tools.execute({
signal: testToolSignal,
@@ -249,9 +256,8 @@ describe('ask_user_question tool', () => {
})
})
it('rejects a delegated subagent with a structured DELEGATED_CALLER error', async () => {
it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => {
const ctx = await setup()
await ctx.plugin(SessionStore)
const seen: AskUserQuestionRequest[] = []
ctx.userInteraction.registerProvider({
async ask(request) {
@@ -259,20 +265,26 @@ describe('ask_user_question tool', () => {
return { answers: [{ id: 'continue', selected: ['ok'] }] }
},
})
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } })
const agent = { session } as unknown as Agent
const root = stubAgent('root', 0)
const child = stubAgent('child', 0)
ctx.agents.enter(root, undefined)
ctx.agents.enter(child, root)
const result = await ctx.tools.execute({
signal: testToolSignal,
callId: CallId('ask-delegated'),
name: 'ask_user_question',
arguments: { questions: [{ id: 'continue', question: 'Continue?' }] },
agent,
agent: child,
})
expect(result).toMatchObject({
isError: true,
error: { info: { name: 'UserInteractionError', code: 'DELEGATED_CALLER' } },
content: [{
type: 'text',
text: "Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result",
}],
})
expect(seen).toHaveLength(0)
})

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/ui/user-interaction/README.md
README.md: d5b2353485b47b3077af74699a57728063f6ce22
README.zh.md: 460feed0e8b0634372d5e7712e4bfca19e0f5523
README.md: cba015e782623b3a5adf018303823577a0b96774
README.zh.md: a5f944850c5ac4e05da59e8478167eef91796610

View File

@@ -13,15 +13,17 @@ Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a mod
### Key Types
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label.
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }`; `detail` supplies supporting text that providers render with the question without turning it into an option label. When present, `agent` must be the registry's exact live runtime root.
- `AskUserQuestionOption``{ label, description? }`.
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`; the tagged presentation intent below.
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`.
- `UserInteractionProvider` — UI implementation with `ask(request)`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, and `DELEGATED_CALLER`.
- `UserInteractionError``HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`.
For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch.
When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. Agentless programmatic requests retain the existing provider path.
### Presentation intent
`intent` declares that a question IS a decision of a known shape, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent shapes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read one answer shape either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of.
@@ -32,7 +34,7 @@ This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-
## Model Experience
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent`, `Error: no user-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-interaction provider is registered`, or `Error: <message>`. Waiting for the human adds no tokens.
#### KV Cache effect

View File

@@ -13,15 +13,17 @@
### 关键类型
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。
- `AskUserQuestionRequest``{ questions: [{ id, question, detail?, header?, options?, multiSelect?, intent? }], agent?, signal? }``detail` 提供辅助文本,提供方会将其随问题一起渲染,而不会将其变成选项标签。如提供 `agent`,它必须与注册表中的存活运行时根 agent智能体是同一对象。
- `AskUserQuestionOption``{ label, description? }`
- `AskUserQuestionIntent``{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。
- `AskUserQuestionAnswer``{ answers: [{ id, selected, custom? }] }`
- `UserInteractionProvider`:包含 `ask(request)` 的 UI 实现。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED``DELEGATED_CALLER` 等代码。
- `UserInteractionError``HarnessError` 的子类,包含 `EMPTY_QUESTIONS``BAD_INTENT``NO_PROVIDER``DUPLICATE_PROVIDER``ASK_ABORTED``CALLER_NOT_LIVE``DELEGATED_CALLER` 等代码。
对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。
请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久化会话谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;由另一个 agent 所有的存活子级即使持久化深度为零也会被拒绝。不含 agent 的程序化请求继续沿用现有提供方路径。
### 呈现意图
`intent` 声明某个问题本身就是一种已知形态的决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只塑造呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的都是同一种回答形态。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言是任何类型都承载不了的,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。
@@ -32,7 +34,7 @@
## 模型体验
间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON或返回以下失败之一`Error: ask_user_question was aborted before the user answered``Error: ask_user_question requires at least one question``Error: ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent``Error: no user-interaction provider is registered``Error: <message>`。等待人类回答不会增加 token。
间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON或返回以下失败之一`Error: ask_user_question was aborted before the user answered``Error: ask_user_question requires at least one question``Error: human interaction requires the exact live calling agent when an agent is supplied``Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result``Error: no user-interaction provider is registered``Error: <message>`。等待人类回答不会增加 token。
#### KV Cache 影响

View File

@@ -28,7 +28,7 @@ export type {
export interface AskUserQuestionRequest {
/** Questions to display. */
questions: AskUserQuestionItem[]
/** Calling agent, when the request came from an agent tool call. */
/** Exact live calling agent, when the request came from an agent tool call. */
agent?: Agent
/** Abort signal for the owning tool/step. */
signal?: AbortSignal
@@ -77,15 +77,17 @@ export class UserInteractionService extends Service {
/**
* Ask the active UI provider and wait for the user's answer.
*
* Human-interaction requests are only valid from a top-level agent: a
* delegated subagent has no human answerer in its own context, so asking
* there would block forever. This mirrors the goal tools' top-level-only
* authority (`create_goal` rejects non-top-level agents).
* When a caller supplies an agent, human interaction is valid only for the
* exact live runtime root. Runtime ownership, not durable session lineage,
* decides this boundary: an owned child has no human answerer and would
* block forever, while a lineage-bearing session resumed as a new runtime
* root may ask normally.
*
* @param request Questions, owner agent, and abort signal.
* @returns The answer chosen or typed by the human.
* @throws {UserInteractionError} code `DELEGATED_CALLER` when the calling
* agent is a delegated subagent (`session.header.delegationDepth > 0`).
* @throws {UserInteractionError} code `CALLER_NOT_LIVE` when a supplied
* agent is not the registry's exact live instance, or `DELEGATED_CALLER`
* when that live agent is owned by another agent.
*/
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
if (request.signal?.aborted) {
@@ -94,10 +96,20 @@ export class UserInteractionService extends Service {
if (request.questions.length === 0) {
throw new UserInteractionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS')
}
if ((request.agent?.session.header.delegationDepth ?? 0) > 0) {
throw new UserInteractionError(
'ask_user_question is unavailable to delegated subagents; delegate the question to the top-level agent',
'DELEGATED_CALLER')
const agent = request.agent
if (agent !== undefined) {
const agents = this.ctx.get('agents')
if (agents === undefined || agents.get(agent.id) !== agent) {
throw new UserInteractionError(
'human interaction requires the exact live calling agent when an agent is supplied',
'CALLER_NOT_LIVE')
}
if (!agents.roots().includes(agent)) {
throw new UserInteractionError(
'human interaction is unavailable while the calling agent is owned by another live agent; '
+ "include the unresolved question or decision in the child agent's final result",
'DELEGATED_CALLER')
}
}
// A presentation intent asserts two things the types cannot: that the
// named approve label is one of this question's own options, and that a

View File

@@ -1,7 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Agent } from '@deepseek-ai/dsh-agent'
import SessionStore from '@deepseek-ai/dsh-session'
import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent'
import UserInteractionService, {
UserInteractionError,
type AskUserQuestionRequest,
@@ -19,6 +18,14 @@ function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUse
}
}
function stubAgent(id: string, delegationDepth = 0): Agent {
const agentId = id as Agent['id']
return {
id: agentId,
session: { id: agentId, header: { delegationDepth } },
} as unknown as Agent
}
describe('UserInteractionService', () => {
it('delegates ask requests to the registered provider', async () => {
const ctx = new Context()
@@ -86,30 +93,36 @@ describe('UserInteractionService', () => {
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a delegated subagent before reaching the provider', async () => {
it('rejects a live runtime-owned agent before reaching the provider', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 1 } })
const agent = { session } as unknown as Agent
const root = stubAgent('root', 0)
const child = stubAgent('child', 0)
ctx.agents.enter(root, undefined)
ctx.agents.enter(child, root)
await expect(ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent,
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'DELEGATED_CALLER' })
agent: child,
})).rejects.toMatchObject({
name: 'UserInteractionError',
code: 'DELEGATED_CALLER',
message: "human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result",
})
expect(p.ask).not.toHaveBeenCalled()
})
it('still reaches the provider for a top-level agent (delegationDepth 0)', async () => {
it('reaches the provider for a lineage-bearing session resumed as a runtime root', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const p = provider('yes')
ctx.userInteraction.registerProvider(p)
const session = ctx.sessions.create(undefined, { meta: { delegationDepth: 0 } })
const agent = { session } as unknown as Agent
const agent = stubAgent('resumed-root', 1)
ctx.agents.enter(agent, undefined)
const result = await ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
@@ -119,6 +132,35 @@ describe('UserInteractionService', () => {
expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] })
})
it('rejects a supplied agent when no live registry can attest it', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
await expect(ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: stubAgent('unattested'),
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects a stale agent object that reuses a live id', async () => {
const ctx = new Context()
await ctx.plugin(AgentRegistry)
await ctx.plugin(UserInteractionService)
const p = { ask: vi.fn(async () => ({ answers: [] })) }
ctx.userInteraction.registerProvider(p)
const live = stubAgent('same-id')
ctx.agents.enter(live, undefined)
await expect(ctx.userInteraction.ask({
questions: [{ id: 'confirm', question: 'Proceed?' }],
agent: stubAgent('same-id'),
})).rejects.toMatchObject({ name: 'UserInteractionError', code: 'CALLER_NOT_LIVE' })
expect(p.ask).not.toHaveBeenCalled()
})
it('rejects an intent whose approve label names none of its own options', async () => {
const ctx = new Context()
await ctx.plugin(UserInteractionService)