Add ask_user_question interaction tool
This commit is contained in:
@@ -5,7 +5,7 @@ Packages that exist to serve development, testing, and the examples rather than
|
||||
| Package | Role | ctx key |
|
||||
|---|---|---|
|
||||
| `invariants/` | Dev-mode event-contract invariants + session-log freeze | (listens on `session/*`, `agent/*`) |
|
||||
| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent | (drives `ctx.agents`) |
|
||||
| `ui-stdio/` | Minimal stdio (readline) UI plugin: renders `agent/*` events, feeds stdin lines to the agent, and provides `ctx.userInteraction` answers | (drives `ctx.agents`, registers a user-interaction provider) |
|
||||
| `llm-replay/` | Record/replay adapter: short-circuits `llm/stream` from a recorded session JSONL (keyless snapshot tests) | (listens on `llm/stream`) |
|
||||
|
||||
`invariants` runs only in dev mode (contract checks, not runtime behavior). `ui-stdio` and `llm-replay` were extracted from the examples for reuse and to bring them under the per-file coverage gate; they back the demos and the snapshot test tier. A package graduates OUT of `support/` into a product group only when it gains documented product consumers.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# @deepseek-ai/dsh-ui-stdio
|
||||
|
||||
A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), and renders that agent's streamed output and tool activity to stdout. A UI is "just a plugin" here — it only consumes the `agent/*` event taxonomy plus the `agents` service (`inject: ['agents']`), so the same plugin drives any example or product surface.
|
||||
A minimal stdio (readline) UI, as a plugin. It reads lines from stdin and feeds them to an agent (`send` when idle, `steer` while a turn is running), renders that agent's streamed output and tool activity to stdout, and provides the `ctx.userInteraction` answer provider for `ask_user_question`. A UI is "just a plugin" here — it consumes the `agent/*` event taxonomy plus the `agents` and `userInteraction` services (`inject: ['agents', 'userInteraction']`), so the same plugin drives any example or product surface with the required seam loaded.
|
||||
|
||||
This package consolidates what were two near-identical copies under `examples/echo-agent` and `examples/coding-agent`. The coding copy was a superset; this package IS that superset — dimmed chain-of-thought rendering plus robust piped-stdin EOF handling — with the per-consumer differences moved into `Config`.
|
||||
|
||||
@@ -26,6 +26,10 @@ Rendering is **global** — every agent's events are written to stdout, not just
|
||||
- `agent/turn-start` / `agent/turn-end` — a `[<agent> turn N]` header and a trailing `> ` prompt.
|
||||
- `session/event` — `tool/call` renders `[tool call] name(args)`; `tool/result` renders the joined text blocks as `[tool result] …`.
|
||||
|
||||
## User Questions
|
||||
|
||||
When `ctx.userInteraction.ask()` is called, the UI writes the question, renders numbered options when provided, and treats the next stdin line as the answer instead of sending it to the agent. Recommended options render first, option details render from `description`, a numeric line selects the displayed option, an empty line selects the recommended option when one exists, and a non-empty free-form line is accepted when `allowCustom` is not `false`.
|
||||
|
||||
## The I/O seam
|
||||
|
||||
The production entry point `apply(ctx, config)` binds the real `process` streams. The testable core is `createStdioChat(ctx, config, runtime)`, where `runtime: StdioRuntime` supplies `input` / `output` / `exit`. This seam is deliberately **not** part of the serializable `Config` (streams and functions do not belong in YAML config); it exists so the render, EOF, and disposal branches can be exercised with fakes instead of hijacking globals.
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-llm": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -34,6 +34,7 @@
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"@deepseek-ai/dsh-user-interaction": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,9 +23,15 @@ import type { Readable, Writable } from 'node:stream'
|
||||
import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import { AgentId } from '@deepseek-ai/dsh-agent'
|
||||
import {
|
||||
UserInteractionError,
|
||||
type AskUserQuestionAnswer,
|
||||
type AskUserQuestionOption,
|
||||
type AskUserQuestionRequest,
|
||||
} from '@deepseek-ai/dsh-user-interaction'
|
||||
|
||||
export const name = 'ui-stdio'
|
||||
export const inject = ['agents']
|
||||
export const inject = ['agents', 'userInteraction']
|
||||
|
||||
/** Serializable plugin configuration (cordis-native, schemastery). */
|
||||
export interface Config {
|
||||
@@ -60,6 +66,27 @@ function isTTYPair(input: Readable, output: Writable): boolean {
|
||||
return Boolean((input as { isTTY?: boolean }).isTTY && (output as { isTTY?: boolean }).isTTY)
|
||||
}
|
||||
|
||||
function optionAnswer(option: AskUserQuestionOption): string {
|
||||
return option.value ?? option.label
|
||||
}
|
||||
|
||||
function displayOptions(options: AskUserQuestionOption[] = []): AskUserQuestionOption[] {
|
||||
return options
|
||||
.map((option, index) => ({ option, index }))
|
||||
.sort((left, right) => {
|
||||
if (left.option.recommended === right.option.recommended) return left.index - right.index
|
||||
return left.option.recommended ? -1 : 1
|
||||
})
|
||||
.map(({ option }) => option)
|
||||
}
|
||||
|
||||
interface PendingQuestion {
|
||||
request: AskUserQuestionRequest
|
||||
resolve(answer: AskUserQuestionAnswer): void
|
||||
reject(error: unknown): void
|
||||
onAbort: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* The plugin body, parameterized over its I/O runtime. `apply` is the thin
|
||||
* production wrapper that binds the real `process` streams; tests call this
|
||||
@@ -100,6 +127,12 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
output.write('\n> ')
|
||||
})
|
||||
|
||||
ctx.on('agent/error', (agent, turn, step, error) => {
|
||||
if (inReasoning) output.write('\x1B[0m')
|
||||
inReasoning = false
|
||||
output.write(`\n[${agent.id} turn ${turn} step ${step} error] ${error.message}\n> `)
|
||||
})
|
||||
|
||||
ctx.on('session/event', (_session, event) => {
|
||||
if (event.type === 'tool/call') {
|
||||
const { name: toolName, arguments: args } = event.data
|
||||
@@ -130,6 +163,8 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
let submittedWork = false
|
||||
let sawRunning = false
|
||||
let exitTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let activeQuestion: PendingQuestion | undefined
|
||||
const questionQueue: PendingQuestion[] = []
|
||||
|
||||
const maybeExit = (): void => {
|
||||
if (disposed || !stdinClosed) return
|
||||
@@ -156,7 +191,113 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
if (status === 'idle') maybeExit()
|
||||
})
|
||||
|
||||
const renderQuestion = (pending: PendingQuestion): void => {
|
||||
const { request } = pending
|
||||
output.write('\n')
|
||||
output.write(request.header ? `[${request.header}] ${request.question}\n` : `[question] ${request.question}\n`)
|
||||
displayOptions(request.options).forEach((option, index) => {
|
||||
output.write(` ${index + 1}. ${option.label}${option.recommended ? ' (recommended)' : ''}\n`)
|
||||
if (option.description) output.write(` ${option.description}\n`)
|
||||
})
|
||||
output.write('> ')
|
||||
}
|
||||
|
||||
const removeAbortListener = (pending: PendingQuestion): void => {
|
||||
pending.request.signal?.removeEventListener('abort', pending.onAbort)
|
||||
}
|
||||
|
||||
const startNextQuestion = (): void => {
|
||||
if (activeQuestion !== undefined) return
|
||||
const pending = questionQueue.shift()
|
||||
if (pending === undefined) return
|
||||
if (pending.request.signal?.aborted) {
|
||||
pending.reject(new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED'))
|
||||
startNextQuestion()
|
||||
return
|
||||
}
|
||||
activeQuestion = pending
|
||||
pending.request.signal?.addEventListener('abort', pending.onAbort, { once: true })
|
||||
renderQuestion(pending)
|
||||
}
|
||||
|
||||
const disposeQuestion = (pending: PendingQuestion): void => {
|
||||
removeAbortListener(pending)
|
||||
pending.reject(new UserInteractionError('ask_user_question was interrupted before the user answered', 'ASK_ABORTED'))
|
||||
}
|
||||
|
||||
const disposePendingQuestions = (): void => {
|
||||
if (activeQuestion !== undefined) {
|
||||
disposeQuestion(activeQuestion)
|
||||
activeQuestion = undefined
|
||||
}
|
||||
for (const pending of questionQueue.splice(0)) {
|
||||
disposeQuestion(pending)
|
||||
}
|
||||
}
|
||||
|
||||
const finishQuestion = (pending: PendingQuestion, answer: AskUserQuestionAnswer): void => {
|
||||
removeAbortListener(pending)
|
||||
activeQuestion = undefined
|
||||
pending.resolve(answer)
|
||||
output.write('\n')
|
||||
startNextQuestion()
|
||||
}
|
||||
|
||||
const answerQuestion = (line: string): void => {
|
||||
const pending = activeQuestion as PendingQuestion
|
||||
|
||||
const text = line.trim()
|
||||
const options = displayOptions(pending.request.options)
|
||||
const selectedIndex = /^\d+$/.test(text) ? Number(text) - 1 : -1
|
||||
const selected = selectedIndex >= 0 ? options[selectedIndex] : undefined
|
||||
if (selected !== undefined) {
|
||||
finishQuestion(pending, { answer: optionAnswer(selected), option: selected })
|
||||
return
|
||||
}
|
||||
|
||||
const recommended = options.find(option => option.recommended)
|
||||
if (text === '' && recommended !== undefined) {
|
||||
finishQuestion(pending, { answer: optionAnswer(recommended), option: recommended })
|
||||
return
|
||||
}
|
||||
|
||||
const allowCustom = pending.request.allowCustom ?? true
|
||||
if (allowCustom && text !== '') {
|
||||
finishQuestion(pending, { answer: text })
|
||||
return
|
||||
}
|
||||
|
||||
output.write(options.length > 0
|
||||
? 'Please enter one of the option numbers'
|
||||
+ (allowCustom ? ' or a custom answer' : '')
|
||||
+ '.\n> '
|
||||
: 'Please enter an answer.\n> ')
|
||||
}
|
||||
|
||||
const disposeUserInteractionProvider = ctx.userInteraction.registerProvider({
|
||||
ask(request) {
|
||||
return new Promise<AskUserQuestionAnswer>((resolve, reject) => {
|
||||
const pending: PendingQuestion = {
|
||||
request,
|
||||
resolve,
|
||||
reject,
|
||||
onAbort: () => {
|
||||
activeQuestion = undefined
|
||||
disposeQuestion(pending)
|
||||
startNextQuestion()
|
||||
},
|
||||
}
|
||||
questionQueue.push(pending)
|
||||
startNextQuestion()
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
reader.on('line', (line) => {
|
||||
if (activeQuestion !== undefined) {
|
||||
answerQuestion(line)
|
||||
return
|
||||
}
|
||||
const text = line.trim()
|
||||
if (!text) return
|
||||
const agent = ctx.agents.get(agentId)
|
||||
@@ -175,12 +316,15 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt
|
||||
// Fires for BOTH stdin EOF and plugin disposal (reader.close() below);
|
||||
// `disposed` guards teardown so HMR/dispose never exits the process.
|
||||
stdinClosed = true
|
||||
if (!disposed) disposePendingQuestions()
|
||||
maybeExit()
|
||||
})
|
||||
output.write(`${welcome}\n> `)
|
||||
return () => {
|
||||
disposed = true
|
||||
if (exitTimer !== undefined) clearTimeout(exitTimer)
|
||||
disposePendingQuestions()
|
||||
disposeUserInteractionProvider()
|
||||
disposeStatusListener()
|
||||
reader.close()
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ function fakeContext(): Context {
|
||||
return {
|
||||
on: vi.fn(() => vi.fn()),
|
||||
effect: vi.fn((callback: () => () => void) => callback()),
|
||||
userInteraction: { registerProvider: vi.fn(() => vi.fn()) },
|
||||
} as unknown as Context
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent'
|
||||
import AgentRegistry from '@deepseek-ai/dsh-agent'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import { createStdioChat, type Config, type StdioRuntime } from '../src/index.ts'
|
||||
|
||||
/**
|
||||
@@ -66,10 +67,11 @@ const CONFIG: Config = { welcome: 'hi there', agent: 'main' }
|
||||
async function setup(config: Config = CONFIG, runtimeOver: Partial<StdioRuntime> = {}) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(AgentRegistry)
|
||||
await ctx.plugin(UserInteractionService)
|
||||
const { runtime, input, out, exit } = makeRuntime(runtimeOver)
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
createStdioChat(inner, config, runtime)
|
||||
}, { inject: ['agents'] }))
|
||||
}, { inject: ['agents', 'userInteraction'] }))
|
||||
return { ctx, fiber, input, out, exit }
|
||||
}
|
||||
|
||||
@@ -171,9 +173,236 @@ describe('createStdioChat rendering', () => {
|
||||
} as SessionEvent)
|
||||
expect(out.text()).toBe(before)
|
||||
})
|
||||
|
||||
it('renders agent errors so failed model requests are visible in stdio', async () => {
|
||||
const { ctx, out } = await setup()
|
||||
const agent = makeAgent('main')
|
||||
|
||||
ctx.emit('agent/error', agent, 1, 1, new Error('fetch failed'))
|
||||
|
||||
expect(out.text()).toContain('\n[main turn 1 step 1 error] fetch failed\n> ')
|
||||
})
|
||||
|
||||
it('resets dim styling when an agent error interrupts reasoning', async () => {
|
||||
const { ctx, out } = await setup()
|
||||
const agent = makeAgent('main')
|
||||
|
||||
ctx.emit('agent/stream-chunk', agent, 1, 0, { type: 'reasoning-delta', index: 0, text: 'thinking' })
|
||||
ctx.emit('agent/error', agent, 1, 1, new Error('fetch failed'))
|
||||
|
||||
expect(out.text()).toContain('\x1B[2mthinking\x1B[0m\n[main turn 1 step 1 error] fetch failed')
|
||||
})
|
||||
})
|
||||
|
||||
describe('createStdioChat input', () => {
|
||||
it('answers a pending user question instead of sending the line to the agent', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const agent = makeAgent('main', 'idle')
|
||||
ctx.agents.register(agent)
|
||||
|
||||
const answer = ctx.userInteraction.ask({
|
||||
header: 'Confirm',
|
||||
question: 'Proceed with the edit?',
|
||||
options: [{ label: 'Yes', value: 'Proceed', description: 'Apply the edit now.', recommended: true }],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('Use a smaller change')
|
||||
|
||||
await expect(answer).resolves.toEqual({ answer: 'Use a smaller change' })
|
||||
expect(agent.sent).toEqual([])
|
||||
expect(out.text()).toContain('[Confirm] Proceed with the edit?')
|
||||
expect(out.text()).toContain('1. Yes (recommended)')
|
||||
expect(out.text()).toContain('Apply the edit now.')
|
||||
})
|
||||
|
||||
it('answers a pending user question by numeric option selection', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
question: 'Which mode?',
|
||||
options: [
|
||||
{ label: 'Safe', value: 'Use safe mode', recommended: true },
|
||||
{ label: 'Fast', value: 'Use fast mode' },
|
||||
],
|
||||
allowCustom: false,
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('2')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answer: 'Use fast mode',
|
||||
option: { label: 'Fast', value: 'Use fast mode' },
|
||||
})
|
||||
})
|
||||
|
||||
it('renders recommended options first and selects by displayed number', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
question: 'Which topic?',
|
||||
options: [
|
||||
{ label: 'Hobbies', value: 'hobbies' },
|
||||
{ label: 'Work', value: 'work', description: 'Questions about current projects.' },
|
||||
{ label: 'Casual', value: 'casual', recommended: true, description: 'Easy conversation.' },
|
||||
],
|
||||
allowCustom: false,
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
expect(out.text()).toContain([
|
||||
'[question] Which topic?',
|
||||
' 1. Casual (recommended)',
|
||||
' Easy conversation.',
|
||||
' 2. Hobbies',
|
||||
' 3. Work',
|
||||
' Questions about current projects.',
|
||||
].join('\n'))
|
||||
input.feed('1')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answer: 'casual',
|
||||
option: { label: 'Casual', value: 'casual', recommended: true, description: 'Easy conversation.' },
|
||||
})
|
||||
})
|
||||
|
||||
it('uses the recommended option when the user submits an empty answer', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
question: 'Continue?',
|
||||
options: [
|
||||
{ label: 'No' },
|
||||
{ label: 'Yes', value: 'Continue', recommended: true },
|
||||
],
|
||||
allowCustom: false,
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answer: 'Continue',
|
||||
option: { label: 'Yes', value: 'Continue', recommended: true },
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prompts when options are required and the input is invalid', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
question: 'Which mode?',
|
||||
options: [{ label: 'Safe' }],
|
||||
allowCustom: false,
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('custom')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter one of the option numbers.')
|
||||
input.feed('1')
|
||||
|
||||
await expect(answer).resolves.toEqual({
|
||||
answer: 'Safe',
|
||||
option: { label: 'Safe' },
|
||||
})
|
||||
})
|
||||
|
||||
it('re-prompts with custom-answer guidance when options also allow free-form input', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({
|
||||
question: 'Which mode?',
|
||||
options: [{ label: 'Safe' }],
|
||||
})
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter one of the option numbers or a custom answer.')
|
||||
input.feed('Use custom mode')
|
||||
|
||||
await expect(answer).resolves.toEqual({ answer: 'Use custom mode' })
|
||||
})
|
||||
|
||||
it('re-prompts when a free-form question receives an empty answer', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const answer = ctx.userInteraction.ask({ question: 'What should I use?' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
input.feed('')
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('Please enter an answer.')
|
||||
input.feed('Use defaults')
|
||||
|
||||
await expect(answer).resolves.toEqual({ answer: 'Use defaults' })
|
||||
})
|
||||
|
||||
it('rejects an active question when its signal aborts', async () => {
|
||||
const { ctx } = await setup()
|
||||
const controller = new AbortController()
|
||||
const answer = ctx.userInteraction.ask({ question: 'Continue?', signal: controller.signal })
|
||||
const rejected = expect(answer).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
controller.abort()
|
||||
|
||||
await rejected
|
||||
})
|
||||
|
||||
it('continues to the next queued question when the active question aborts', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const controller = new AbortController()
|
||||
const first = ctx.userInteraction.ask({ question: 'First?', signal: controller.signal })
|
||||
const firstRejected = expect(first).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const second = ctx.userInteraction.ask({ question: 'Second?' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
controller.abort()
|
||||
await firstRejected
|
||||
await new Promise(r => setImmediate(r))
|
||||
expect(out.text()).toContain('[question] Second?')
|
||||
input.feed('second answer')
|
||||
|
||||
await expect(second).resolves.toEqual({ answer: 'second answer' })
|
||||
})
|
||||
|
||||
it('skips a queued question whose signal aborted before it became active', async () => {
|
||||
const { ctx, input, out } = await setup()
|
||||
const controller = new AbortController()
|
||||
const first = ctx.userInteraction.ask({ question: 'First?' })
|
||||
const second = ctx.userInteraction.ask({ question: 'Second?', signal: controller.signal })
|
||||
const secondRejected = expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
controller.abort()
|
||||
input.feed('first answer')
|
||||
|
||||
await expect(first).resolves.toEqual({ answer: 'first answer' })
|
||||
await secondRejected
|
||||
expect(out.text()).not.toContain('[question] Second?')
|
||||
})
|
||||
|
||||
it('rejects active and queued questions when the UI is disposed', async () => {
|
||||
const { ctx, fiber } = await setup()
|
||||
const active = ctx.userInteraction.ask({ question: 'Active?' })
|
||||
const queued = ctx.userInteraction.ask({ question: 'Queued?' })
|
||||
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
await fiber.dispose()
|
||||
|
||||
await activeRejected
|
||||
await queuedRejected
|
||||
})
|
||||
|
||||
it('rejects active and queued questions when stdin closes before the user answers', async () => {
|
||||
const { ctx, input, exit } = await setup()
|
||||
const active = ctx.userInteraction.ask({ question: 'Active?' })
|
||||
const queued = ctx.userInteraction.ask({ question: 'Queued?' })
|
||||
const activeRejected = expect(active).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
const queuedRejected = expect(queued).rejects.toMatchObject({ code: 'ASK_ABORTED' })
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
input.finish()
|
||||
await new Promise(r => setImmediate(r))
|
||||
|
||||
await activeRejected
|
||||
await queuedRejected
|
||||
expect(exit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('sends a typed line to an idle agent', async () => {
|
||||
const { ctx, input } = await setup()
|
||||
const agent = makeAgent('main', 'idle')
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../../core/agent"
|
||||
},
|
||||
{
|
||||
"path": "../../core/user-interaction"
|
||||
},
|
||||
{
|
||||
"path": "../../llm/llm"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user