Add ask_user_question interaction tool
This commit is contained in:
@@ -7,6 +7,8 @@ The packages every harness build is assembled from: the session log, the system-
|
||||
| `session/` | Event-sourced session log + in-memory store | `ctx.sessions` |
|
||||
| `system-prompt/` | Prompt-section + tool-schema assembly registry | `ctx.systemPrompt` |
|
||||
| `tools/` | Tool registry + `tools/execute` waterfall | `ctx.tools` |
|
||||
| `user-interaction/` | Human question/answer seam for tools and permission flows | `ctx.userInteraction` |
|
||||
| `tool-ask-user/` | Model-facing `ask_user_question` tool over `ctx.userInteraction` | (registers on `ctx.tools`) |
|
||||
| `agent/` | Agent interface, registry, `agent/*` event vocabulary | `ctx.agents` |
|
||||
| `agent-loop/` | The concrete loop plugin: `ReactLoopAgent` + the loop driver | `ctx.agentLoop` |
|
||||
| `agent-core/` | Bundle plugin: the providerless/executor-less/UI-less spine as code | (loads the spine) |
|
||||
|
||||
18
packages/core/tool-ask-user/README.md
Normal file
18
packages/core/tool-ask-user/README.md
Normal file
@@ -0,0 +1,18 @@
|
||||
# @deepseek-ai/dsh-tool-ask-user
|
||||
|
||||
Model-facing `ask_user_question` tool over `ctx.userInteraction`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing.
|
||||
|
||||
## Tool
|
||||
|
||||
`ask_user_question` accepts:
|
||||
|
||||
- `question` — required question text.
|
||||
- `header` — optional short heading.
|
||||
- `options` — optional choices with `label`, `value`, `description`, and `recommended`.
|
||||
- `allow_custom` — whether free-form answers are allowed; defaults to the provider's normal `true` behavior.
|
||||
|
||||
The tool calls `ctx.userInteraction.ask()` and returns the selected option value or custom answer as a text tool result.
|
||||
|
||||
## Role
|
||||
|
||||
This is the consumer package for the user-interaction seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop.
|
||||
38
packages/core/tool-ask-user/package.json
Normal file
38
packages/core/tool-ask-user/package.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-tool-ask-user",
|
||||
"description": "Model-facing ask_user_question tool over the ctx.userInteraction seam",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-tools": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-system-prompt": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
63
packages/core/tool-ask-user/src/index.ts
Normal file
63
packages/core/tool-ask-user/src/index.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Model-facing `ask_user_question` tool over the `ctx.userInteraction` seam.
|
||||
* The tool pauses until a UI provider returns a human answer, then feeds that
|
||||
* answer back into the agent loop as an ordinary tool result.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-tool-ask-user
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { defineTool } from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'tool-ask-user'
|
||||
export const inject = ['tools', 'userInteraction']
|
||||
|
||||
const description = 'Ask the user a concise question when you need confirmation, a choice, or missing information before proceeding. '
|
||||
+ 'Use options when possible; mark the recommended option when one is safest.'
|
||||
|
||||
export function apply(ctx: Context): void {
|
||||
ctx.tools.register(defineTool({
|
||||
name: 'ask_user_question',
|
||||
description,
|
||||
parameters: {
|
||||
header: {
|
||||
type: 'string',
|
||||
description: 'Optional short heading for the question, such as "Confirm" or "Choose Mode".',
|
||||
},
|
||||
question: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
description: 'The specific question to ask the user.',
|
||||
},
|
||||
options: {
|
||||
type: 'array',
|
||||
description: 'Optional mutually exclusive choices to show the user.',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
label: { type: 'string', required: true, description: 'Short user-facing option label.' },
|
||||
value: { type: 'string', description: 'Answer text returned to you if this option is selected. Defaults to label.' },
|
||||
description: { type: 'string', description: 'One sentence explaining the tradeoff or impact.' },
|
||||
recommended: { type: 'boolean', description: 'True for the recommended/default option.' },
|
||||
},
|
||||
},
|
||||
},
|
||||
allow_custom: {
|
||||
type: 'boolean',
|
||||
description: 'Whether the user may type a free-form answer instead of selecting an option. Defaults to true.',
|
||||
},
|
||||
},
|
||||
async execute(args, exec) {
|
||||
const result = await ctx.userInteraction.ask({
|
||||
question: args.question,
|
||||
...args.header !== undefined ? { header: args.header } : {},
|
||||
...args.options !== undefined ? { options: args.options } : {},
|
||||
...args.allow_custom !== undefined ? { allowCustom: args.allow_custom } : {},
|
||||
...exec.agent !== undefined ? { agent: exec.agent } : {},
|
||||
...exec.signal !== undefined ? { signal: exec.signal } : {},
|
||||
})
|
||||
return [{ type: 'text', text: result.answer }]
|
||||
},
|
||||
}))
|
||||
}
|
||||
185
packages/core/tool-ask-user/tests/tool-ask-user.spec.ts
Normal file
185
packages/core/tool-ask-user/tests/tool-ask-user.spec.ts
Normal file
@@ -0,0 +1,185 @@
|
||||
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 SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import UserInteractionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-interaction'
|
||||
import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user'
|
||||
|
||||
interface OptionSchemaShape {
|
||||
properties: {
|
||||
options: {
|
||||
items: {
|
||||
properties: Record<string, { type: string }>
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function setup() {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
await ctx.plugin(toolAskUser)
|
||||
return ctx
|
||||
}
|
||||
|
||||
describe('ask_user_question tool', () => {
|
||||
it('registers a model-facing tool schema', async () => {
|
||||
const ctx = await setup()
|
||||
const schema = ctx.tools.schemas().find(tool => tool.name === 'ask_user_question')
|
||||
|
||||
expect(schema).toMatchObject({
|
||||
name: 'ask_user_question',
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
question: { type: 'string' },
|
||||
options: { type: 'array' },
|
||||
allow_custom: { type: 'boolean' },
|
||||
},
|
||||
required: ['question'],
|
||||
},
|
||||
})
|
||||
const parameters = schema?.parameters as unknown as OptionSchemaShape
|
||||
expect(parameters.properties.options.items.properties).toMatchObject({
|
||||
description: { type: 'string' },
|
||||
recommended: { type: 'boolean' },
|
||||
})
|
||||
expect(parameters.properties.options.items.properties).not.toHaveProperty('desc')
|
||||
})
|
||||
|
||||
it('asks the registered user-interaction provider and returns the answer text', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
const option = request.options?.[0]
|
||||
return option === undefined ? { answer: 'Use pnpm' } : { answer: 'Use pnpm', option }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-1'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
question: 'Which package manager should I use?',
|
||||
options: [{ label: 'pnpm', value: 'Use pnpm', recommended: true }],
|
||||
allow_custom: false,
|
||||
},
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
isError: false,
|
||||
content: [{ type: 'text', text: 'Use pnpm' }],
|
||||
})
|
||||
expect(seen).toMatchObject([{
|
||||
question: 'Which package manager should I use?',
|
||||
options: [{ label: 'pnpm', value: 'Use pnpm', recommended: true }],
|
||||
allowCustom: false,
|
||||
}])
|
||||
})
|
||||
|
||||
it('passes the tool abort signal to the user-interaction request', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answer: 'ok' }
|
||||
},
|
||||
})
|
||||
const controller = new AbortController()
|
||||
|
||||
await ctx.tools.execute({
|
||||
callId: CallId('ask-2'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { question: 'Continue?' },
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
expect(seen[0]?.signal).toBe(controller.signal)
|
||||
})
|
||||
|
||||
it('passes optional header and agent through to the user-interaction request', async () => {
|
||||
const ctx = await setup()
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answer: 'ok' }
|
||||
},
|
||||
})
|
||||
const agent = { id: 'main' } as unknown as Agent
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-3'),
|
||||
name: 'ask_user_question',
|
||||
arguments: { header: 'Confirm', question: 'Continue?' },
|
||||
agent,
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'ok' }])
|
||||
expect(seen[0]).toMatchObject({ header: 'Confirm', agent })
|
||||
})
|
||||
|
||||
it('uses an option label when the selected option has no explicit value', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
const option = request.options?.[0]
|
||||
if (option === undefined) throw new Error('missing option')
|
||||
return { answer: option.label, option }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-4'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
question: 'Pick one',
|
||||
options: [{ label: 'Fallback label' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'Fallback label' }])
|
||||
})
|
||||
|
||||
it('returns the provider-computed answer even when option metadata is present', async () => {
|
||||
const ctx = await setup()
|
||||
ctx.userInteraction.registerProvider({
|
||||
async ask(request) {
|
||||
const option = request.options?.[0]
|
||||
if (option === undefined) throw new Error('missing option')
|
||||
return { answer: `selected ${option.value}`, option }
|
||||
},
|
||||
})
|
||||
|
||||
const result = await ctx.tools.execute({
|
||||
callId: CallId('ask-5'),
|
||||
name: 'ask_user_question',
|
||||
arguments: {
|
||||
question: 'Pick one',
|
||||
options: [{ label: 'A', value: 'a' }],
|
||||
},
|
||||
})
|
||||
|
||||
expect(result.content).toEqual([{ type: 'text', text: 'selected a' }])
|
||||
})
|
||||
|
||||
it('unregisters the tool when its plugin fiber is disposed', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const fiber = await ctx.plugin(toolAskUser)
|
||||
expect(ctx.tools.get('ask_user_question')).toBeDefined()
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
expect(ctx.tools.get('ask_user_question')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
36
packages/core/tool-ask-user/tsconfig.json
Normal file
36
packages/core/tool-ask-user/tsconfig.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/schemastery"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
{
|
||||
"path": "../agent"
|
||||
},
|
||||
{
|
||||
"path": "../system-prompt"
|
||||
},
|
||||
{
|
||||
"path": "../tools"
|
||||
},
|
||||
{
|
||||
"path": "../user-interaction"
|
||||
}
|
||||
]
|
||||
}
|
||||
22
packages/core/user-interaction/README.md
Normal file
22
packages/core/user-interaction/README.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# @deepseek-ai/dsh-user-interaction
|
||||
|
||||
Abstract user-interaction seam. It owns `ctx.userInteraction`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision.
|
||||
|
||||
## Service: `UserInteractionService` (ctx key: `userInteraction`)
|
||||
|
||||
### Public API
|
||||
|
||||
- `ctx.userInteraction.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it.
|
||||
- `ctx.userInteraction.ask(request): Promise<AskUserQuestionAnswer>` Ask the active provider and wait for the answer.
|
||||
|
||||
### Key Types
|
||||
|
||||
- `AskUserQuestionRequest` — `{ question, header?, options?, allowCustom?, agent?, signal? }`.
|
||||
- `AskUserQuestionOption` — `{ label, value?, description?, recommended? }`.
|
||||
- `AskUserQuestionAnswer` — `{ answer, option? }`.
|
||||
- `UserInteractionProvider` — UI implementation with `ask(request)`.
|
||||
- `UserInteractionError` — `HarnessError` subclass with codes such as `NO_PROVIDER`, `DUPLICATE_PROVIDER`, and `ASK_ABORTED`.
|
||||
|
||||
## Role
|
||||
|
||||
This is the interface package. Model-facing consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this seam; UI implementations such as `@deepseek-ai/dsh-ui-stdio` provide the provider. The loop stays unchanged: a tool call simply awaits a promise, and the tool result resumes the normal agent loop.
|
||||
32
packages/core/user-interaction/package.json
Normal file
32
packages/core/user-interaction/package.json
Normal file
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-user-interaction",
|
||||
"description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
105
packages/core/user-interaction/src/index.ts
Normal file
105
packages/core/user-interaction/src/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* User-interaction seam (`ctx.userInteraction`): a UI-backed service for
|
||||
* pausing an agent tool call until the human answers a question. The model-
|
||||
* facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide
|
||||
* the single active provider.
|
||||
*
|
||||
* @module @deepseek-ai/dsh-user-interaction
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
|
||||
declare module 'cordis' {
|
||||
interface Context {
|
||||
userInteraction: UserInteractionService
|
||||
}
|
||||
}
|
||||
|
||||
/** One selectable answer offered to the user. */
|
||||
export interface AskUserQuestionOption {
|
||||
/** User-facing label. */
|
||||
label: string
|
||||
/** Value returned to the model when selected. Defaults to `label`. */
|
||||
value?: string
|
||||
/** Optional extra context rendered by capable UIs. */
|
||||
description?: string
|
||||
/** Marks the recommended/default option. */
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
/** Request for a human answer. */
|
||||
export interface AskUserQuestionRequest {
|
||||
/** The question to display. */
|
||||
question: string
|
||||
/** Optional short heading/group label. */
|
||||
header?: string
|
||||
/** Optional choices the UI can render as a menu. */
|
||||
options?: AskUserQuestionOption[]
|
||||
/** Whether free-form answers are accepted. Defaults to `true`. */
|
||||
allowCustom?: boolean
|
||||
/** Calling agent, when the request came from an agent tool call. */
|
||||
agent?: Agent
|
||||
/** Abort signal for the owning tool/step. */
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
/** The human's answer. */
|
||||
export interface AskUserQuestionAnswer {
|
||||
/** Model-facing answer text. */
|
||||
answer: string
|
||||
/** The selected option, when the answer came from `options`. */
|
||||
option?: AskUserQuestionOption
|
||||
}
|
||||
|
||||
/** UI-side provider for user questions. */
|
||||
export interface UserInteractionProvider {
|
||||
ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer>
|
||||
}
|
||||
|
||||
/** Stable error taxonomy for user-interaction failures. */
|
||||
export class UserInteractionError extends Error {
|
||||
readonly code: string
|
||||
|
||||
constructor(message: string, code: string, options?: ErrorOptions) {
|
||||
super(message, options)
|
||||
this.code = code
|
||||
this.name = 'UserInteractionError'
|
||||
}
|
||||
}
|
||||
|
||||
/** `ctx.userInteraction`: one active UI provider plus an `ask()` surface. */
|
||||
export class UserInteractionService extends Service {
|
||||
private provider: UserInteractionProvider | undefined
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'userInteraction')
|
||||
}
|
||||
|
||||
/** Register the UI provider. Only one provider may be active in a context. */
|
||||
registerProvider(provider: UserInteractionProvider): () => void {
|
||||
const dispose = this.ctx.effect(function* (this: UserInteractionService) {
|
||||
if (this.provider !== undefined) {
|
||||
throw new UserInteractionError('a user-interaction provider is already registered', 'DUPLICATE_PROVIDER')
|
||||
}
|
||||
this.provider = provider
|
||||
yield () => {
|
||||
this.provider = undefined
|
||||
}
|
||||
}.bind(this), 'userInteraction.registerProvider()')
|
||||
return () => void dispose()
|
||||
}
|
||||
|
||||
/** Ask the active UI provider and wait for the user's answer. */
|
||||
async ask(request: AskUserQuestionRequest): Promise<AskUserQuestionAnswer> {
|
||||
if (request.signal?.aborted) {
|
||||
throw new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')
|
||||
}
|
||||
if (this.provider === undefined) {
|
||||
throw new UserInteractionError('no user-interaction provider is registered', 'NO_PROVIDER')
|
||||
}
|
||||
return this.provider.ask(request)
|
||||
}
|
||||
}
|
||||
|
||||
export default UserInteractionService
|
||||
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import UserInteractionService, {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionRequest,
|
||||
type UserInteractionProvider,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
function provider(answer = 'approved'): UserInteractionProvider & { seen: AskUserQuestionRequest[] } {
|
||||
const seen: AskUserQuestionRequest[] = []
|
||||
return {
|
||||
seen,
|
||||
async ask(request) {
|
||||
seen.push(request)
|
||||
return { answer }
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
describe('UserInteractionService', () => {
|
||||
it('delegates ask requests to the registered provider', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = provider('yes')
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
|
||||
const result = await ctx.userInteraction.ask({ question: 'Proceed?' })
|
||||
|
||||
expect(result).toEqual({ answer: 'yes' })
|
||||
expect(p.seen).toEqual([{ question: 'Proceed?' }])
|
||||
})
|
||||
|
||||
it('rejects ask requests when no provider is registered', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
|
||||
await expect(ctx.userInteraction.ask({ question: 'Proceed?' }))
|
||||
.rejects.toMatchObject({ name: 'UserInteractionError', code: 'NO_PROVIDER' })
|
||||
})
|
||||
|
||||
it('registers providers with HMR-safe disposal', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = provider()
|
||||
const dispose = ctx.userInteraction.registerProvider(p)
|
||||
|
||||
dispose()
|
||||
dispose()
|
||||
|
||||
await expect(ctx.userInteraction.ask({ question: 'Proceed?' }))
|
||||
.rejects.toMatchObject({ code: 'NO_PROVIDER' })
|
||||
})
|
||||
|
||||
it('rejects duplicate providers instead of replacing the active UI', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
ctx.userInteraction.registerProvider(provider('first'))
|
||||
|
||||
expect(() => ctx.userInteraction.registerProvider(provider('second')))
|
||||
.toThrow(UserInteractionError)
|
||||
})
|
||||
|
||||
it('fails before reaching the provider when the signal is already aborted', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const p = { ask: vi.fn(async () => ({ answer: 'too late' })) }
|
||||
ctx.userInteraction.registerProvider(p)
|
||||
const controller = new AbortController()
|
||||
controller.abort()
|
||||
|
||||
await expect(ctx.userInteraction.ask({ question: 'Proceed?', signal: controller.signal }))
|
||||
.rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
expect(p.ask).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
21
packages/core/user-interaction/tsconfig.json
Normal file
21
packages/core/user-interaction/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../../../vendor/cosmokit"
|
||||
},
|
||||
{
|
||||
"path": "../../../vendor/cordis"
|
||||
},
|
||||
{
|
||||
"path": "../agent"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user