diff --git a/.changeset/background-question-inline-answer.md b/.changeset/background-question-inline-answer.md new file mode 100644 index 00000000000..88aee15a09a --- /dev/null +++ b/.changeset/background-question-inline-answer.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Deliver background question answers to the agent directly instead of via a saved output file. diff --git a/.changeset/background-question-survives-turn-end.md b/.changeset/background-question-survives-turn-end.md new file mode 100644 index 00000000000..18022db46a1 --- /dev/null +++ b/.changeset/background-question-survives-turn-end.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Fix background questions being cancelled as soon as the agent finishes its turn. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 8a176e2bf42..2c6ef388e38 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -93,13 +93,13 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill **`AgentSwarm`** launches subagents from a shared `prompt_template` and an `items` array, resumes existing subagents through `resume_agent_ids`, or combines both in one call. The template must contain the `{{item}}` placeholder; each item replaces that placeholder and launches one new subagent. Pass `subagent_type` to choose the profile used by every spawned subagent in the swarm, or omit it to use `coder`. Pass `model` (available when a [subagent model pool](../configuration/config-files.md#subagent-model-pool) is configured — a `[secondary_model.models]` table or a lone `default_model`) to run item-spawned subagents on a pool alias or on the caller's own model (`"primary"`). Without it, item-spawned subagents bind the pool's `default_model`; without a configured pool, they inherit the caller's model. Resumed subagents keep their own model. Without `resume_agent_ids`, the tool requires at least 2 items; with `resume_agent_ids`, it can resume one or more existing subagents. The tool supports up to 128 total subagents, waits for all subagents to finish, and returns an aggregated report. Each subagent times out after 2 hours by default; the limit is configurable via [`[swarm] timeout_ms`](../configuration/config-files.md#swarm) in `config.toml` (`0` = no timeout, or the `KIMI_CODE_SWARM_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). A timed-out subagent is aborted and marked as failed in the aggregated report. In the TUI, foreground swarms show a live `Agent swarm` progress panel above the input box. If a model response calls `AgentSwarm`, that call must be the only tool call in the response; to run multiple swarms, call one `AgentSwarm`, wait for its result, then call the next, or combine the work into one swarm when a single template can cover it. In `manual` permission mode, `AgentSwarm` calls outside active swarm mode request approval unless a permission rule allows them; while swarm mode is active, `AgentSwarm` itself is auto-approved. Permission rules match `AgentSwarm` by tool name only — argument patterns such as `AgentSwarm(swarm)` are not supported. By default the tool ramps up concurrency without an upper limit (5 subagents start immediately, then 1 more every 700 ms); set `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time during that ramp, or leave it unset for no cap. If it is set to a value that is not a positive integer, the AgentSwarm call fails fast. -**`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. +**`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately; the question stays open after the turn ends, and the answer is delivered to the Agent as a notification once the user responds. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. **`Skill`** allows the Agent to actively invoke a registered inline-type Skill. Accepts `skill` (the Skill name) and optional `args` (additional argument text). Only `type = "inline"` Skills can be called via this tool; Skills with `disableModelInvocation: true` are rejected. Maximum nesting depth is 3 levels. See [Agent Skills](../customization/skills.md) for details. ## Background Tasks -Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path are automatically delivered back to the Agent; use `TaskOutput` to check progress early, or `WaitFor` to wait for a result inside the current turn. +Background task tools manage tasks started via `Bash`, `Agent`, or `AskUserQuestion`. When a task reaches a terminal state, its status and saved output path (or, for questions, the answer itself) are automatically delivered back to the Agent; use `TaskOutput` to check progress early, or `WaitFor` to wait for a result inside the current turn. | Tool | Default Approval | Description | | --- | --- | --- | diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index ca034761830..aa524ae0181 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -93,13 +93,13 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`AgentSwarm`** 可以从共享的 `prompt_template` 和 `items` 数组启动 subagent,也可以通过 `resume_agent_ids` 恢复已有 subagent,或在一次调用中同时使用两者。模板必须包含 `{{item}}` 占位符;每个 item 会替换该占位符,并启动一个新的 subagent。传入 `subagent_type` 可以指定整个 swarm 中所有新启动的 subagent 使用的 profile;省略时默认使用 `coder`。传入 `model`(在配置 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 后可用——`[secondary_model.models]` 表或仅一行 `default_model`)可以让新启动的 subagent 运行在池中别名指定的模型或调用方自己的模型(`"primary"`)上。未传入时新启动的 subagent 绑定池的 `default_model`;未配置模型池时则继承调用方模型。恢复的 subagent 保持其原有模型。不传 `resume_agent_ids` 时,本工具要求至少 2 个 item;传入 `resume_agent_ids` 时,可以恢复 1 个或多个已有 subagent。本工具最多支持 128 个 subagent,会等待全部 subagent 完成,并返回聚合报告。每个 subagent 默认 2 小时超时,可通过 `config.toml` 的 [`[swarm] timeout_ms`](../configuration/config-files.md#swarm)(`0` = 无超时,或 `KIMI_CODE_SWARM_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时;超时的 subagent 会被中止,并在聚合报告中标记为失败。在 TUI 中,前台 swarm 会在输入框上方显示实时 `Agent swarm` 进度面板。若一次模型响应调用 `AgentSwarm`,该调用必须是该响应中的唯一工具调用;如需运行多个 swarm,应先调用一个 `AgentSwarm` 并等待结果,再调用下一个,若单个模板可以覆盖这些工作,也可以合并为一个 swarm。在 `manual` 权限模式下,未处于 swarm mode 时调用 `AgentSwarm` 会触发审批,除非已有权限规则允许;swarm mode 已开启时,`AgentSwarm` 本身会自动放行。权限规则只能按工具名 `AgentSwarm` 匹配,不支持 `AgentSwarm(swarm)` 这类参数模式。默认情况下,本工具会逐步提升并发且不设上限(立即启动 5 个 subagent,之后每 700 毫秒再启动 1 个);将 `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` 设为正整数可限制该阶段同时运行的 subagent 数量,不设置则表示不限制。若设置为非正整数的值,本次 AgentSwarm 调用会立即失败。 -**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 +**`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID;问题在本轮结束后仍保持待答,用户作答后答案会以通知形式直接送回 Agent。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 **`Skill`** 允许 Agent 主动调用已注册的 inline 类型 Skill。接受 `skill`(Skill 名称)和可选的 `args`(附加参数文本)。只有 `type = "inline"` 的 Skill 能通过此工具调用;`disableModelInvocation: true` 的 Skill 会被拒绝。嵌套调用深度上限 3 层。Skill 体系细节见 [Agent Skills](../customization/skills.md)。 ## 后台任务 -后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径送回 Agent;如需提前检查进度,使用 `TaskOutput`;如果下一步必须等待某个任务的结果,使用 `WaitFor` 在当前轮次内等待。 +后台任务工具用于管理通过 `Bash`、`Agent` 或 `AskUserQuestion` 启动的后台任务。任务进入终止状态时会自动把状态和已保存的输出路径(问题任务则直接送回答案)送回 Agent;如需提前检查进度,使用 `TaskOutput`;如果下一步必须等待某个任务的结果,使用 `WaitFor` 在当前轮次内等待。 | 工具 | 默认审批 | 说明 | | --- | --- | --- | diff --git a/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index bdd89e1ca39..b823e457f8f 100644 --- a/packages/agent-core-v2/src/agent/task/taskService.ts +++ b/packages/agent-core-v2/src/agent/task/taskService.ts @@ -13,7 +13,7 @@ import { userCancellationReason, } from '#/_base/utils/abort'; import { setClampedTimeout } from '#/_base/utils/timer'; -import { escapeXml, escapeXmlAttr } from '#/_base/utils/xml-escape'; +import { escapeXml, escapeXmlAttr, escapeXmlTags } from '#/_base/utils/xml-escape'; import { IEventBus } from '#/app/event/eventBus'; import { Error2, ErrorCodes } from '#/errors'; import { z } from 'zod'; @@ -163,6 +163,7 @@ const SIGTERM_GRACE_MS = 5_000; const TASK_ID_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz'; const SESSION_CLOSED_REASON = 'Session closed'; const NOTIFICATION_FALLBACK_PREVIEW_BYTES = 3_000; +const QUESTION_ANSWER_INLINE_BYTES = 16_000; const ACTIVE_BACKGROUND_TASK_INJECTION_VARIANT = 'background_task_status'; const TASK_RESUME_TERMINATION_VARIANT = 'task_resume_termination'; const ACTIVE_BACKGROUND_TASK_GUIDANCE = [ @@ -1260,10 +1261,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { try { let output = emptyOutputSnapshot(); try { - output = await this.getOutputSnapshot(info.taskId, 0); - if (!output.fullOutputAvailable) { - output = await this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); - } + output = await this.notificationOutputSnapshot(info); } catch (error) { this.log.error('task notification output read failed; delivering without output', { taskId: info.taskId, @@ -1275,10 +1273,7 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { if (this.deliveredNotificationKeys.has(key)) return undefined; if (this.hasDeliveredNotification(key)) return undefined; this.scheduledNotificationKeys.add(key); - const notification = buildAgentTaskNotification( - info, - agentTaskNotificationChildren(output), - ); + const notification = buildAgentTaskNotification(info, output); const content = [ { type: 'text', @@ -1291,6 +1286,15 @@ export class AgentTaskService extends Disposable implements IAgentTaskService { } } + private async notificationOutputSnapshot(info: AgentTaskInfo): Promise { + if (info.kind === 'question') { + return this.getOutputSnapshot(info.taskId, QUESTION_ANSWER_INLINE_BYTES); + } + const persisted = await this.getOutputSnapshot(info.taskId, 0); + if (persisted.fullOutputAvailable) return persisted; + return this.getOutputSnapshot(info.taskId, NOTIFICATION_FALLBACK_PREVIEW_BYTES); + } + private fireNotificationHook(notification: AgentTaskNotification): void { void this.dispatcher.dispatch( new TaskNotified({ @@ -1389,8 +1393,13 @@ function emptyOutputSnapshot(): AgentTaskOutputSnapshot { } function agentTaskNotificationChildren( - output: AgentTaskOutputSnapshot, + info: AgentTaskInfo, + output: AgentTaskOutputSnapshot | undefined, ): readonly string[] | undefined { + if (output === undefined) return undefined; + if (inlinesQuestionAnswer(info, output)) { + return output.preview.length === 0 ? undefined : [renderAnswerBlock(output.preview)]; + } if (output.fullOutputAvailable && output.outputPath !== undefined) { return [renderOutputFileBlock(output.outputPath, output.outputSizeBytes)]; } @@ -1398,6 +1407,50 @@ function agentTaskNotificationChildren( return [renderOutputPreviewBlock(output)]; } +function inlinesQuestionAnswer(info: AgentTaskInfo, output: AgentTaskOutputSnapshot): boolean { + return info.kind === 'question' && !output.truncated; +} + +function renderAnswerBlock(answer: string): string { + return ['', escapeXmlTags(answer), ''].join('\n'); +} + +function questionNotificationText( + info: AgentTaskInfo, + output: AgentTaskOutputSnapshot | undefined, +): { readonly title: string; readonly body: string } | undefined { + if (info.status !== 'completed' || output === undefined || !inlinesQuestionAnswer(info, output)) { + return undefined; + } + const outcome = questionOutcome(output.preview); + if (outcome === 'answered') { + return { + title: 'Background question answered', + body: `The user answered "${info.description}".`, + }; + } + if (outcome === 'dismissed') { + return { + title: 'Background question dismissed', + body: `The user dismissed "${info.description}" without answering.`, + }; + } + return undefined; +} + +function questionOutcome(output: string): 'answered' | 'dismissed' | undefined { + let parsed: unknown; + try { + parsed = JSON.parse(output); + } catch { + return undefined; + } + if (typeof parsed !== 'object' || parsed === null) return undefined; + const answers = (parsed as { readonly answers?: unknown }).answers; + if (typeof answers !== 'object' || answers === null || Array.isArray(answers)) return undefined; + return Object.keys(answers).length > 0 ? 'answered' : 'dismissed'; +} + function renderOutputFileBlock(outputPath: string, outputSizeBytes: number): string { return [ ``, @@ -1504,8 +1557,9 @@ function buildAgentTaskNotificationBody(info: AgentTaskInfo): string { function buildAgentTaskNotification( info: AgentTaskInfo, - children?: readonly string[], + output?: AgentTaskOutputSnapshot, ): AgentTaskNotification { + const question = questionNotificationText(info, output); return { id: taskNotificationId(info.taskId, info.status), category: 'task', @@ -1513,10 +1567,10 @@ function buildAgentTaskNotification( source_kind: 'background_task', source_id: info.taskId, agent_id: info.kind === 'agent' ? info.agentId : undefined, - title: `Background ${info.kind} ${info.status}`, + title: question?.title ?? `Background ${info.kind} ${info.status}`, severity: info.status === 'completed' ? 'info' : 'warning', - body: buildAgentTaskNotificationBody(info), - children, + body: question?.body ?? buildAgentTaskNotificationBody(info), + children: agentTaskNotificationChildren(info, output), }; } diff --git a/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts b/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts index e53a0b04b72..f8bfe8b68ff 100644 --- a/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts +++ b/packages/agent-core-v2/src/agent/tools/ask-user-question/askUserQuestionTool.ts @@ -140,13 +140,8 @@ export class AskUserQuestionTool implements IAskUserQuestionTool { isError: false, output: `task_id: ${taskId}\n` + - `description: ${description}\n` + `status: ${status}\n` + - `automatic_notification: true\n` + - 'next_step: Continue your current work; the answer will arrive automatically when the user responds.\n' + - 'next_step: Use TaskOutput with this task_id for a non-blocking status/answer snapshot.\n' + - 'next_step: Use TaskStop only if the question should be cancelled.\n' + - 'human_shell_hint: The pending question is also visible in the client UI.', + 'next_step: Continue your work; the answer arrives automatically in a later message. Use TaskStop only to cancel the question.', }; } @@ -174,7 +169,7 @@ export class AskUserQuestionTool implements IAskUserQuestionTool { multiSelect: q.multi_select, })), }, - { signal, agentId: this.scopeContext.agentId }, + { signal, agentId: this.scopeContext.agentId, detached: args.background === true }, ); const normalized = normalizeQuestionResult(result); diff --git a/packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts b/packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts index f5274587110..2b432300a9e 100644 --- a/packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts +++ b/packages/agent-core-v2/src/agent/tools/ask-user-question/question-background-task.ts @@ -42,6 +42,10 @@ export class QuestionBackgroundTask implements AgentTask { const result = await this.run(sink.signal); const output = typeof result.output === 'string' ? result.output : JSON.stringify(result.output); + if (result.isError === true) { + await sink.settle({ status: 'failed', stopReason: output }); + return; + } sink.appendOutput(output); await sink.settle({ status: 'completed' }); } catch (error: unknown) { diff --git a/packages/agent-core-v2/src/features/interaction/interaction.ts b/packages/agent-core-v2/src/features/interaction/interaction.ts index 02ca59dee2d..94f74f31f1b 100644 --- a/packages/agent-core-v2/src/features/interaction/interaction.ts +++ b/packages/agent-core-v2/src/features/interaction/interaction.ts @@ -20,6 +20,19 @@ export interface Interaction { readonly createdAt: number; } +export type InteractionCancellationReason = 'turn_ended' | 'agent_closed'; + +export interface InteractionCancellation { + readonly cancelled: true; + readonly reason: InteractionCancellationReason; +} + +export function isInteractionCancellation(response: unknown): response is InteractionCancellation { + if (typeof response !== 'object' || response === null) return false; + const value = response as { readonly cancelled?: unknown; readonly reason?: unknown }; + return value.cancelled === true && (value.reason === 'turn_ended' || value.reason === 'agent_closed'); +} + export interface InteractionResolution { readonly id: string; readonly response: unknown; diff --git a/packages/agent-core-v2/src/features/interaction/interactionService.ts b/packages/agent-core-v2/src/features/interaction/interactionService.ts index f5c2bb323f0..7472f19a8fd 100644 --- a/packages/agent-core-v2/src/features/interaction/interactionService.ts +++ b/packages/agent-core-v2/src/features/interaction/interactionService.ts @@ -14,6 +14,7 @@ import { IEventDispatcher } from '#/state/eventDispatcher'; import { type Interaction, + type InteractionCancellation, type InteractionKind, type InteractionPendingChangedEvent, type InteractionRequest, @@ -90,7 +91,7 @@ function cancelTurnPending( if (entry.interaction.origin?.turnId !== turnId) continue; effects.pending.delete(id); rememberResolved(effects, id); - const response = { cancelled: true, reason: 'turn_ended' }; + const response: InteractionCancellation = { cancelled: true, reason: 'turn_ended' }; entry.resolve(response); recordResolved(runtime, id, response); effects.resolveEmitter.fire({ id, response }); @@ -119,7 +120,7 @@ const interactionEffects = fromCallback(({ return () => { subscription.dispose(); for (const entry of input.effects.pending.values()) { - entry.resolve({ cancelled: true, reason: 'agent_closed' }); + entry.resolve({ cancelled: true, reason: 'agent_closed' } satisfies InteractionCancellation); } input.effects.pending.clear(); input.effects.changeEmitter.dispose(); diff --git a/packages/agent-core-v2/src/session/question/question.ts b/packages/agent-core-v2/src/session/question/question.ts index 349b13e89c5..7bd08e163a4 100644 --- a/packages/agent-core-v2/src/session/question/question.ts +++ b/packages/agent-core-v2/src/session/question/question.ts @@ -38,7 +38,7 @@ export interface ISessionQuestionService { request( req: QuestionRequest, - options?: { signal?: AbortSignal; agentId?: string }, + options?: { signal?: AbortSignal; agentId?: string; detached?: boolean }, ): Promise; enqueue(req: QuestionRequest): QuestionRequest & { readonly id: string }; answer(id: string, result: QuestionResult): void; diff --git a/packages/agent-core-v2/src/session/question/questionService.ts b/packages/agent-core-v2/src/session/question/questionService.ts index 771e00d7d7f..ce702c0d4db 100644 --- a/packages/agent-core-v2/src/session/question/questionService.ts +++ b/packages/agent-core-v2/src/session/question/questionService.ts @@ -4,6 +4,7 @@ import { LifecycleScope } from '#/app/scopes'; import { ScopeActivation, registerScopedService } from '#/_base/di/scope'; import { IAgentLifecycleService } from '#/session/agentLifecycle/agentLifecycle'; +import { isInteractionCancellation } from '#/features/interaction/interaction'; import { enqueueSessionInteraction, listSessionPendingInteractions, @@ -22,14 +23,20 @@ export class SessionQuestionService implements ISessionQuestionService { constructor(@IAgentLifecycleService private readonly agents: IAgentLifecycleService) {} - request(req: QuestionRequest, options?: { signal?: AbortSignal; agentId?: string }): Promise { + request( + req: QuestionRequest, + options?: { signal?: AbortSignal; agentId?: string; detached?: boolean }, + ): Promise { const id = requestId(req); - const pending = requestSessionInteraction(this.agents, { + const pending = requestSessionInteraction(this.agents, { id, kind: 'question', payload: req, - origin: { turnId: req.turnId, agentId: options?.agentId }, - }); + origin: { + turnId: options?.detached === true ? undefined : req.turnId, + agentId: options?.agentId, + }, + }).then((response) => (isInteractionCancellation(response) ? null : (response as QuestionResult))); const signal = options?.signal; if (signal !== undefined) { diff --git a/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts b/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts index e1ada295f04..051af7c3a34 100644 --- a/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts +++ b/packages/agent-core-v2/test/agent/questionTools/tools/ask-user.test.ts @@ -345,7 +345,7 @@ describe('AskUserQuestionTool', () => { }, ], }, - { signal, agentId: 'main' }, + { signal, agentId: 'main', detached: false }, ); expect(telemetryTrack).toHaveBeenCalledWith('question_answered', { answered: 1, @@ -381,7 +381,7 @@ describe('AskUserQuestionTool', () => { }), ], }), - { signal, agentId: 'main' }, + { signal, agentId: 'main', detached: false }, ); }); @@ -549,9 +549,13 @@ describe('AskUserQuestionTool', () => { }); expect(result.isError).toBe(false); - expect(result.output).toContain('task_id: q_test_task_id'); - expect(result.output).toContain('automatic_notification: true'); - expect(result.output).toContain('human_shell_hint: The pending question is also visible in the client UI.'); + expect(result.output).toBe( + [ + 'task_id: q_test_task_id', + 'status: running', + 'next_step: Continue your work; the answer arrives automatically in a later message. Use TaskStop only to cancel the question.', + ].join('\n'), + ); expect(registerTask).toHaveBeenCalledOnce(); expect(registerTask.mock.calls[0]![1]).toMatchObject({ detached: true }); expect(getTask).toHaveBeenCalledWith('q_test_task_id'); @@ -576,6 +580,54 @@ describe('AskUserQuestionTool', () => { expect(settlements).toEqual([{ status: 'completed' }]); }); + it('detaches the background question from the asking turn', async () => { + const { tool, request, lastRegisteredTask } = makeTool(); + await executeTool(tool, { + turnId: 4, + toolCallId: 'call_bg_detached', + args: { ...input(), background: true }, + signal, + }); + + const { sink } = makeSink(); + await lastRegisteredTask()!.start(sink); + + expect(request).toHaveBeenCalledOnce(); + expect(request.mock.calls[0]![0]).toMatchObject({ turnId: 4, toolCallId: 'call_bg_detached' }); + expect(request.mock.calls[0]![1]).toMatchObject({ detached: true }); + + await executeTool(tool, { turnId: 4, toolCallId: 'call_fg', args: input(), signal }); + + expect(request).toHaveBeenCalledTimes(2); + expect(request.mock.calls[1]![1]).not.toMatchObject({ detached: true }); + }); + + it('settles failed with the tool error when the question cannot be asked', async () => { + const { tool, lastRegisteredTask } = makeTool({ + request: async () => { + throw new Error2(CoreErrors.codes.NOT_IMPLEMENTED, 'Client does not support questions'); + }, + }); + await executeTool(tool, { + turnId: 0, + toolCallId: 'call_bg_unsupported', + args: { ...input(), background: true }, + signal, + }); + + const { sink, outputs, settlements } = makeSink(); + await lastRegisteredTask()!.start(sink); + + expect(outputs).toEqual([]); + expect(settlements).toEqual([ + { + status: 'failed', + stopReason: + 'The connected client does not support interactive questions. Do NOT call this tool again. Ask the user directly in your text response instead.', + }, + ]); + }); + it('settles killed when the background task is aborted', async () => { const controller = new AbortController(); const { tool, lastRegisteredTask } = makeTool({ diff --git a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts index e277c84b74a..3eabbf0588e 100644 --- a/packages/agent-core-v2/test/agent/task/rpc-events.test.ts +++ b/packages/agent-core-v2/test/agent/task/rpc-events.test.ts @@ -17,6 +17,7 @@ import { type SubagentHandle, } from '#/agent/tools/agent/subagent-task'; import { ProcessTask } from '#/agent/tools/os/bash/process-task'; +import { QuestionBackgroundTask } from '#/agent/tools/ask-user-question/question-background-task'; import { IAgentContextMemoryService } from '#/agent/contextMemory/contextMemory'; import { IEventBus } from '#/app/event/eventBus'; import type { IExternalHooksRunnerService } from '#/features/externalHooks/app/externalHooksRunner'; @@ -503,6 +504,135 @@ describe('AgentTaskService — notification delivery', () => { expect(text).not.toContain('final subagent summary'); }); + it('inlines the answer in completed question task notifications', async () => { + const { agent, ctx, manager } = createAgentTaskService(); + ctx.mockNextResponse({ type: 'text', text: 'notification ack' }); + const turnEnd = ctx.untilTurnEnd(); + const answer = JSON.stringify({ answers: { 'Which database?': 'Postgres' } }); + const taskId = manager.registerTask( + new QuestionBackgroundTask( + async () => ({ isError: false, output: answer }), + 'Which database?', + { questionCount: 1, toolCallId: 'call_q' }, + ), + { detached: true }, + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(notifiedCount(ctx)).toBe(1); + }); + await turnEnd; + + const message = notificationMessageFor(agent, taskId); + expect(message.origin).toEqual({ + kind: 'task', + taskId, + status: 'completed', + notificationId: `task:${taskId}:completed`, + }); + const text = message.content[0]!.text; + expect(text).toContain('Title: Background question answered'); + expect(text).toContain('The user answered "Which database?".'); + expect(text).toContain(`\n${answer}\n`); + expect(text).not.toContain(' { + const { agent, ctx, manager } = createAgentTaskService(); + ctx.mockNextResponse({ type: 'text', text: 'notification ack' }); + const turnEnd = ctx.untilTurnEnd(); + const dismissed = JSON.stringify({ + answers: {}, + note: 'User dismissed the question without answering.', + }); + const taskId = manager.registerTask( + new QuestionBackgroundTask( + async () => ({ isError: false, output: dismissed }), + 'Which database?', + { questionCount: 1, toolCallId: 'call_q' }, + ), + { detached: true }, + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(notifiedCount(ctx)).toBe(1); + }); + await turnEnd; + + const text = notificationMessageFor(agent, taskId).content[0]!.text; + expect(text).toContain('Title: Background question dismissed'); + expect(text).toContain('The user dismissed "Which database?" without answering.'); + expect(text).toContain(`\n${dismissed}\n`); + expect(text).not.toContain(' { + const { agent, ctx, manager } = createAgentTaskService(); + ctx.mockNextResponse({ type: 'text', text: 'notification ack' }); + const turnEnd = ctx.untilTurnEnd(); + const taskId = manager.registerTask( + new QuestionBackgroundTask( + async () => ({ isError: false, output: 'not an answer payload' }), + 'Which database?', + { questionCount: 1, toolCallId: 'call_q' }, + ), + { detached: true }, + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(notifiedCount(ctx)).toBe(1); + }); + await turnEnd; + + const text = notificationMessageFor(agent, taskId).content[0]!.text; + expect(text).toContain('Title: Background question completed'); + expect(text).toContain('Which database? completed.'); + expect(text).not.toContain('dismissed'); + expect(text).toContain('\nnot an answer payload\n'); + expect(text).not.toContain(' { + const { agent, ctx, manager } = createAgentTaskService(); + ctx.mockNextResponse({ type: 'text', text: 'notification ack' }); + const turnEnd = ctx.untilTurnEnd(); + const taskId = manager.registerTask( + new QuestionBackgroundTask( + async () => ({ + isError: true, + output: 'The connected client does not support interactive questions.', + }), + 'Which database?', + { questionCount: 1, toolCallId: 'call_q' }, + ), + { detached: true }, + ); + + await manager.wait(taskId); + + await vi.waitFor(() => { + expect(notifiedCount(ctx)).toBe(1); + }); + await turnEnd; + + const message = notificationMessageFor(agent, taskId); + expect(message.origin).toMatchObject({ kind: 'task', taskId, status: 'failed' }); + const text = message.content[0]!.text; + expect(text).toContain('Title: Background question failed'); + expect(text).toContain( + 'Which database? failed. Reason: The connected client does not support interactive questions.', + ); + expect(text).not.toContain(''); + expect(text).not.toContain('dismissed'); + }); + it('enqueues completed process task notifications into the turn flow', async () => { const { agent, ctx, manager } = createAgentTaskService(); const taskId = registerProcess(manager, immediateProcess(0), 'echo ok', 'shell task'); diff --git a/packages/agent-core-v2/test/session/question/question.test.ts b/packages/agent-core-v2/test/session/question/question.test.ts index 4e8c0fa1011..0afae9b46a6 100644 --- a/packages/agent-core-v2/test/session/question/question.test.ts +++ b/packages/agent-core-v2/test/session/question/question.test.ts @@ -136,6 +136,39 @@ describe('ISessionQuestionService (Session scope facade over the interaction ker await expect(main).resolves.toBeNull(); }); + it('a detached request is not bound to the asking turn', async () => { + const interaction = interactions.serviceOf('main'); + const questions = session.accessor.get(ISessionQuestionService); + + const foreground = questions.request({ ...makeRequest('q-fg'), turnId: 3 }); + const detached = questions.request({ ...makeRequest('q-bg'), turnId: 3 }, { detached: true }); + expect(interaction.listPending().find((i) => i.id === 'q-fg')?.origin.turnId).toBe(3); + expect(interaction.listPending().find((i) => i.id === 'q-bg')?.origin.turnId).toBeUndefined(); + + interaction.cancelPendingForTurn(3); + + await expect(foreground).resolves.toBeNull(); + expect(questions.listPending().map((r) => r.id)).toEqual(['q-bg']); + expect(questions.listPending()[0]?.turnId).toBe(3); + + questions.answer('q-bg', { answers: { q_0: 'Yes' } }); + await expect(detached).resolves.toEqual({ answers: { q_0: 'Yes' } }); + }); + + it('resolves a request cancelled by its turn ending as a dismissal', async () => { + const interaction = interactions.serviceOf('main'); + const questions = session.accessor.get(ISessionQuestionService); + const resolved: { id: string; response: unknown }[] = []; + disposables.add(interaction.onDidResolve((r) => resolved.push(r))); + + const pending = questions.request({ ...makeRequest('q1'), turnId: 2 }); + interaction.cancelPendingForTurn(2); + + await expect(pending).resolves.toBeNull(); + expect(resolved).toEqual([{ id: 'q1', response: { cancelled: true, reason: 'turn_ended' } }]); + expect(questions.listPending()).toEqual([]); + }); + it('request with a pre-aborted signal resolves null and parks nothing', async () => { const questions = session.accessor.get(ISessionQuestionService); const controller = new AbortController(); diff --git a/packages/transcript/src/history/groupTurns.ts b/packages/transcript/src/history/groupTurns.ts index 57d7a34fa7a..6552dbbe1c6 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -401,7 +401,11 @@ function notificationFrameText(text: string): string { const bodyLines = lines.slice(bodyStart); const childStart = bodyLines.findIndex((line) => { const trimmed = line.trimStart(); - return trimmed.startsWith(' 0 && body.length > 0) return `${title}\n${body}`; diff --git a/packages/transcript/test/layers.test.ts b/packages/transcript/test/layers.test.ts index 79aa9bd7910..ad8411b2b17 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -634,6 +634,32 @@ describe('groupMessagesIntoSnapshot (cold path)', () => { expect(frame).toMatchObject({ text: 'Background agent completed\ninspect done.' }); }); + it('stops folded notification text before an inline answer block', () => { + const xml = [ + '', + 'Title: Background question answered', + 'Severity: info', + 'The user answered "Which database?".', + '', + '{"answers":{"Which database?":"Postgres"}}', + '', + '', + ].join('\n'); + const snapshot = groupMessagesIntoSnapshot( + [ + { role: 'user', content: [{ type: 'text', text: 'run' }], toolCalls: [], origin: { kind: 'user' } }, + { role: 'assistant', content: [{ type: 'text', text: 'go' }], toolCalls: [] }, + { role: 'user', content: [{ type: 'text', text: xml }], toolCalls: [], origin: { kind: 'task', taskId: 'question-1' } as { kind: string } }, + { role: 'assistant', content: [{ type: 'text', text: 'done' }], toolCalls: [] }, + ], + { taskOriginTurnTaskIds: new Set() }, + ); + const turn = snapshot.items[0]; + if (turn?.kind !== 'turn') throw new Error('expected turn'); + const frame = turn.steps.flatMap((step) => step.frames).find((f) => f.kind === 'text' && f.role === 'user'); + expect(frame).toMatchObject({ text: 'Background question answered\nThe user answered "Which database?".' }); + }); + it('buffers a folded notification that arrives before the first step into that step', () => { const xml = [ '',