diff --git a/.changeset/background-question-inline-answer.md b/.changeset/background-question-inline-answer.md new file mode 100644 index 000000000..847c88db1 --- /dev/null +++ b/.changeset/background-question-inline-answer.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-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 000000000..5f52c90ba --- /dev/null +++ b/.changeset/background-question-survives-turn-end.md @@ -0,0 +1,5 @@ +--- +"@pymodel/pythinker-code": patch +--- + +Fix background questions being cancelled as soon as the agent finishes its turn. diff --git a/apps/pythinker-code/dist-web/.web-bundle-manifest.json b/apps/pythinker-code/dist-web/.web-bundle-manifest.json index 3531c520e..5fd624f63 100644 --- a/apps/pythinker-code/dist-web/.web-bundle-manifest.json +++ b/apps/pythinker-code/dist-web/.web-bundle-manifest.json @@ -1,4 +1,4 @@ { - "sourceHash": "0670132fd6ebf38fbe6620f3f406ab07bcd253ce8fe8c89e34558d03fd2a4d0f", + "sourceHash": "60e31323838a7481743f77ddfb9d3e4ed318a1f3b46e1ebf55cb27305899b535", "sourceFileCount": 493 } diff --git a/docs/reference/tools.md b/docs/reference/tools.md index ef149f0cc..499c644b1 100644 --- a/docs/reference/tools.md +++ b/docs/reference/tools.md @@ -95,13 +95,13 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill **`AgentDynamicWorkflow`** 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 dynamic_workflow, or omit it to use `coder`. Pass `model` (available when [secondary-model routing](../configuration/config-files.md#subagent-model-pool) is enabled and a 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; configure the limit with [`[dynamic_workflow] timeout_ms`](../configuration/config-files.md#dynamic-workflow) in `config.toml` (`0` means no timeout) or the `PYTHINKER_CODE_AGENT_DYNAMIC_WORKFLOW_TIMEOUT_MS` environment variable. Print mode (`pythinker -p`) defaults to no timeout. A timed-out subagent is aborted and marked as failed in the aggregated report. In the TUI, foreground dynamicWorkflows show a live `Agent dynamic_workflow` progress panel above the input box. If a model response calls `AgentDynamicWorkflow`, that call must be the only tool call in the response; to run multiple dynamicWorkflows, call one `AgentDynamicWorkflow`, wait for its result, then call the next, or combine the work into one dynamic_workflow when a single template can cover it. In `manual` permission mode, `AgentDynamicWorkflow` calls outside active dynamic_workflow mode request approval unless a permission rule allows them; while dynamic_workflow mode is active, `AgentDynamicWorkflow` itself is auto-approved. Permission rules match `AgentDynamicWorkflow` by tool name only — argument patterns such as `AgentDynamicWorkflow(dynamic_workflow)` 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 `[dynamic_workflow] max_concurrency` or `PYTHINKER_CODE_AGENT_DYNAMIC_WORKFLOW_MAX_CONCURRENCY` to a positive integer to cap how many subagents run at the same time across all execution phases. An invalid environment value makes the call fail 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/packages/agent-core-v2/src/agent/task/taskService.ts b/packages/agent-core-v2/src/agent/task/taskService.ts index 51e2c8bd1..385db169a 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, ISessionEventBus } from '#/app/event/eventBus'; import { Error2, ErrorCodes } from '#/errors'; import { z } from 'zod'; @@ -166,6 +166,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 = [ @@ -1286,10 +1287,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, @@ -1301,10 +1299,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', @@ -1317,6 +1312,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 { if (!this.lifecycleActive()) return; void this.dispatcher.dispatch( @@ -1417,8 +1421,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)]; } @@ -1426,6 +1435,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 [ ``, @@ -1532,8 +1585,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', @@ -1541,10 +1595,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 e53a0b04b..f8bfe8b68 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 f52745871..2b432300a 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 10cb83643..3f3f44978 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/session/question/question.ts b/packages/agent-core-v2/src/session/question/question.ts index 349b13e89..7bd08e163 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 771e00d7d..ce702c0d4 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 fea5a64f3..e57540c14 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 @@ -340,7 +340,7 @@ describe('AskUserQuestionTool', () => { }, ], }, - { signal, agentId: 'main' }, + { signal, agentId: 'main', detached: false }, ); expect(telemetryTrack).toHaveBeenCalledWith('question_answered', { answered: 1, @@ -376,7 +376,7 @@ describe('AskUserQuestionTool', () => { }), ], }), - { signal, agentId: 'main' }, + { signal, agentId: 'main', detached: false }, ); }); @@ -544,9 +544,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'); @@ -571,6 +575,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 21fbc3d8e..9888795eb 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'; @@ -487,6 +488,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 e9e681df6..4cf75309a 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.runtimeOf('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.runtimeOf('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 6de531831..3727da150 100644 --- a/packages/transcript/src/history/groupTurns.ts +++ b/packages/transcript/src/history/groupTurns.ts @@ -391,7 +391,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 fdcd646fd..3c64207c2 100644 --- a/packages/transcript/test/layers.test.ts +++ b/packages/transcript/test/layers.test.ts @@ -664,6 +664,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 = [ '',