feat: background subagents with headless and SDK support (#3076)

* feat(core): add run_in_background support for Agent tool

Enable sub-agents to run asynchronously via `run_in_background: true`
parameter. Background agents execute independently from the parent,
which receives an immediate launch confirmation and continues working.
A notification is injected into the parent conversation when the
background agent completes.

Key changes:
- BackgroundTaskRegistry tracks lifecycle of background agents
- Agent tool gains async execution path with fire-and-forget semantics
- Background agents use YOLO approval mode to prevent deadlock
- Independent AbortControllers survive parent ESC cancellation
- CLI bridges notifications via useMessageQueue for between-turn delivery
- State race guards prevent complete/fail after cancellation
- Session cleanup aborts all running background agents

* feat(background): improve notification formatting and UI handling

- Add prefix/separator protocol to distinguish background notifications from user input
- Show concise summary in UI while sending full details to LLM
- Add 'notification' history item type with specialized display
- Add 'background' agent status for background-running agents
- Prevent notifications from polluting prompt history (up-arrow)
- Truncate long descriptions in display text

This improves the UX for background agents by showing cleaner, more concise
notifications while preserving full context for the LLM.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(background): reject run_in_background in non-interactive mode

Headless mode skips AppContainer, so the notification callback is never
registered and background agent results would be silently dropped. Return
an error prompting the model to retry without run_in_background.

* refactor(background): replace prefix/separator protocol with typed notification queue

Replace the stringly-typed \x00__BG_NOTIFY__\x00 prefix/separator
encoding with a typed notification path using SendMessageType.Notification.

- Add SendMessageType.Notification to the enum
- Change BackgroundNotificationCallback to emit (displayText, modelText)
- Move notification queue from AppContainer into useGeminiStream (mirrors
  the cron queue pattern): register on registry, queue structured items,
  drain on idle via submitQuery
- prepareQueryForGemini short-circuits for Notification type (skips slash
  commands, shell mode, @-commands, prompt history logging)
- Remove BACKGROUND_NOTIFICATION_PREFIX/SEPARATOR constants

* refactor(background): move abortAll to Config.shutdown

Background agent cleanup belongs in Config.shutdown() alongside other
resource teardown (skillManager, toolRegistry, arenaRuntime), not in
AppContainer's registerCleanup. This also ensures headless mode gets
cleanup for free.

* fix(background): persist notification items for session resume

Background agent notifications were missing after session resume because
they were never recorded in the chat history. The model text was absent
from the API history and the display item was lost.

- Add recordNotification() to ChatRecordingService — stores as user-role
  message with subtype 'notification' and displayText payload
- Thread notificationDisplayText through submitQuery → sendMessageStream
- Restore as HistoryItemNotification in resumeHistoryUtils

* fix(background): replace YOLO with deny-by-default for background agents

Background agents were using YOLO approval mode which auto-approves all
tool calls — too permissive. Replace with shouldAvoidPermissionPrompts
which auto-denies tool calls that need interactive approval, matching
claw-code's approach.

The permission flow for background agents is now:
1. L3/L4 permission rules (allow/deny) — same as foreground
2. Approval mode overrides (AUTO_EDIT for edits) — same as foreground
3. PermissionRequest hooks — can override the denial
4. Auto-deny — if no hook decided, deny because prompts are unavailable

* fix(background): add missing getBackgroundTaskRegistry mock in useGeminiStream tests

* refactor(core): move fork subagent params from execute() to construction time

Identity-shaping fork inputs (parent history, generationConfig, tool decls,
env-skip flag) were threaded through `AgentHeadless.execute()`'s options bag
and re-passed by the SubagentStop hook retry loop. They belong on the agent's
construction-time configs, not its per-invocation options.

- PromptConfig gains `renderedSystemPrompt` (verbatim, bypasses templating
  and userMemory injection) and drops the `systemPrompt`/`initialMessages`
  XOR so fork can carry both. createChat skips env bootstrap when
  `initialMessages` is non-empty.
- AgentHeadless.execute() shrinks to (context, signal?). Fork dispatch in
  agent.ts builds synthetic PromptConfig/ModelConfig/ToolConfig from the
  parent's cache-safe params and calls AgentHeadless.create directly
  (bypassing SubagentManager). Parent's tool decls flow through verbatim
  including the `agent` tool itself for cache parity.
- Recursive-fork prevention switches from fork-side tool stripping to a
  runtime guard. The previous `isInForkChild(history)` helper was dead
  code (it scanned the main GeminiClient's history, not the fork child's
  chat). Replaced with `isInForkExecution()` backed by AsyncLocalStorage:
  the fork's background execution runs inside `runInForkContext`, and the
  ALS frame propagates through the standard async chain into nested
  AgentTool.execute() calls where the guard fires.

* refactor(core): move agent tool files into dedicated tools/agent/ directory

Move agent.ts, agent.test.ts, and fork-subagent.ts under
tools/agent/ and update all import paths accordingly.

* refactor(core): remove dead temp and top_p fields from ModelConfig

These fields were never populated from subagent frontmatter and served
no purpose in the fork path either. The ModelConfig interface retains
only the actively-used model field.

* refactor(core): read parent generation config directly instead of getCacheSafeParams

Fork subagent now reads system instruction and tool declarations from
the live GeminiChat via getGenerationConfig() instead of the global
getCacheSafeParams() snapshot. This removes the cross-module coupling
between the agent tool and the followup infrastructure.

* fix(core): prevent duplicate tool declarations when toolConfig has only inline decls

prepareTools() treated asStrings.length === 0 as "add all registry tools",
which is correct when no tools are specified at all, but wrong when the
caller provides only inline FunctionDeclaration[] (no string names). The
fork path passes parent tool declarations as inline decls for cache parity,
so prepareTools was adding the full registry set on top — duplicating every
non-excluded tool.

Add onlyInlineDecls.length === 0 to the condition so that pure-inline
toolConfigs bypass the registry entirely.

* feat(core): support agent-level `background: true` in frontmatter

Subagent definitions can now declare `background: true` in their YAML
frontmatter to always run as background tasks. This is OR'd with the
`run_in_background` tool parameter — useful for monitors, watchers, and
proactive agents so the LLM doesn't need to remember to set the flag.

* fix(core): address background subagent lifecycle gaps

- Inherit bgConfig from agentConfig so the resolved approval mode is
  preserved for background agents (foreground would run AUTO_EDIT but
  background fell back to DEFAULT, which combined with shouldAvoid-
  PermissionPrompts would auto-deny every permission request).
- Honor SubagentStop blocking decisions in background runs by looping
  on hook output up to 5 iterations, matching runSubagentWithHooks.
- Check terminate mode before reporting completion; non-GOAL modes
  (ERROR, MAX_TURNS, TIMEOUT) are now reported as failures instead of
  emitting a success notification for an incomplete run.
- Exclude SendMessageType.Notification from the UserPromptSubmit hook
  guard so background completion messages are not rewritten or blocked
  as if they were user input.

* feat(cli): headless support and SDK task events for background agents (#3379)

* feat(cli): unify notification queue for cron and background agents

Migrate cron from its own queue (cronQueueRef / cronQueue) to the shared
notification queue used by background agents. Both producers now push the
same item shape { displayText, modelText, sendMessageType } and a single
drain effect / helper processes them in FIFO order.

Cron fires render as HistoryItemNotification (● prefix) instead of
HistoryItemUser (> prefix), with a "Cron: <prompt>" display label.
Records use subtype 'cron' for clean resume and analytics separation.

Lift the non-interactive rejection for background agents. Register a
notification callback in nonInteractiveCli.ts with a terminal hold-back
phase (100ms poll) that keeps the process alive until all background
agents complete and their notifications are processed.

* feat(cli): emit SDK task events for background subagents

Emit `task_started` when a background agent registers and
`task_notification` when it completes, fails, or is cancelled, so
headless/SDK consumers can track lifecycle without parsing display
text. Model-facing text is now structured XML with status, summary,
truncated result, and usage stats. Completion stats (tokens, tool
uses, duration) are captured from the subagent and included in both
the SDK payload and the model XML.

* fix: address codex review issues for background subagents

- Background subagents now inherit the resolved approval mode from
  agentConfig instead of the raw session config, so a subagent with
  `approvalMode: auto-edit` (or execution in a trusted folder) keeps
  that override when it runs asynchronously.
- Non-interactive cron drains are single-flight: concurrent cron fires
  now await the same in-flight drain, and the cron-done check gates
  on it, preventing the final result from being emitted while a cron
  turn is still streaming.
- Background forks go through createForkSubagent so they retain the
  parent's rendered system prompt and inherited history instead of
  degrading to a plain FORK_AGENT.

* fix(cli): restore cancellation, approval, and error paths in queued drain

- Hold-back loop now reacts to SIGINT/SIGTERM: when the main abort
  signal fires it calls registry.abortAll() so background agents with
  their own AbortControllers stop promptly instead of pinning the
  process open.
- Queued-turn tool execution forwards the stream-json approval update
  callback (onToolCallsUpdate) so permission-gated tools inside a
  background-notification follow-up emit can_use_tool requests.
- Queued-turn stream loop mirrors the main loop's text-mode handling
  of GeminiEventType.Error, writing to stderr and throwing so provider
  errors produce a non-zero exit code instead of silently succeeding.
- Interactive cron prompts go through the normal slash/@-command/shell
  preprocessing again; only Notification messages skip that path.

* fix(cli): skip duplicate user-message item for cron prompts

Cron prompts already render as a `● Cron: …` notification via the queue
drain, so adding them again as a `USER` history item produced a
duplicate `> …` line.

* fix(cli): honor SIGINT/SIGTERM during cron scheduler wait

The non-interactive cron phase awaits a Promise that resolves only when
scheduler.size reaches 0 and no drain is in flight. Recurring cron jobs
never drop the scheduler size to 0 on their own, so the previous abort
handling (added to the hold-back loop) was unreachable — the process
hung indefinitely after SIGINT/SIGTERM. Attach an abort listener inside
the promise so abort stops the scheduler and resolves immediately,
allowing the hold-back loop to run and the process to exit cleanly.

* feat(core): propagate tool-use id through background agent notifications

Plumb the scheduler's callId into AgentToolInvocation via an optional
setCallId hook on the invocation, detected structurally in
buildInvocation. The agent tool forwards it as toolUseId on the
BackgroundTaskRegistry entry so completion notifications can carry a
<tool-use-id> tag and SDK task_started / task_notification events can
emit tool_use_id — letting consumers correlate background completions
back to the original Agent tool-use that spawned them.

* fix(cli): drain single-flight race kept task_notification from emitting

drainLocalQueue wrapped its body in an async IIFE and cleared the
promise reference via finally. When the queue is empty the IIFE has
no awaits, so its finally runs synchronously as part of the RHS of
the assignment `drainPromise = (async () => {...})()` — clearing
drainPromise BEFORE the outer assignment overwrites it with the
resolved promise. The reference then stayed stuck on that fulfilled
promise forever, so later calls short-circuited through
`if (drainPromise) return drainPromise` and never processed
queued notifications.

Symptom: in headless `--output-format json` (and `stream-json`),
task_started emitted but task_notification never did, even after
the background agent completed. The process sat in the hold-back
loop until SIGTERM.

Fix: move the null-clearing out of the async body into an outer
`.finally()` on the returned promise. `.finally()` runs as a
microtask after the current synchronous block, so it clears the
latest drainPromise reference instead of the pre-assignment null.

* fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line

Headless text mode wrote `resultMessage.result` without a trailing newline.
In a TTY, zsh themes that use PROMPT_SP (powerlevel10k, agnoster, …) detect
the missing `\n` and emit `\r\033[K` before drawing the next prompt, which
wipes the final line off the screen. Pipe-captured output was unaffected,
so the bug only surfaced for interactive shell users — most visibly in the
background-agent flow where the drain-loop's final assistant message is
the *only* stdout write in text mode.

Append `\n` to both the success (stdout) and error (stderr) writes.

* docs(skill): tighten worked-example blurb in structured-debugging

Mirror the simplified blurb from .claude/skills/structured-debugging/SKILL.md
(knowledge repo). Drops the round-by-round narrative; keeps the contradiction
+ two lessons.

* docs(skill): mirror SKILL.md improvements (reframing failure mode, generalized path, value-logging guidance)

Mirror of knowledge repo commit 38eb28d into the qwen-code .qwen/skills
copy.

* docs(skill): mirror worked example into .qwen/skills/structured-debugging/

Mirrors knowledge/.claude/skills/structured-debugging/examples/
headless-bg-agent-empty-stdout.md so the .qwen copy of the skill links
resolve.

* docs(skill): mirror generalized side-note path guidance

* fix(cli): harden headless cron and background-agent failure paths

Three regressions surfaced by Codex review of feat/background-subagent:

- Cron drain rejections were dropped by a bare `void`, so a failing
  queued turn left the outer Promise unresolved and hung the run. Route
  drain failures through the Promise's reject so they propagate to the
  outer catch.
- The background-agent registry entry was inserted before
  `createForkSubagent()` / `createAgentHeadless()` was awaited. Failed
  init returned an error from the tool call but left a phantom `running`
  entry, and the headless hold-back loop (`registry.getRunning()`) waited
  forever. Register only after init succeeds.
- SIGINT/SIGTERM during the hold-back phase aborted background tasks,
  then fell through to `emitResult({ isError: false })`, so a cancelled
  `qwen -p ...` exited 0 with the prior assistant text. Route through
  `handleCancellationError()` so cancellation exits non-zero, matching
  the main turn loop.

* test(cli): update stdout/stderr assertions for trailing newline

`feadf052f` appended `\n` to text-mode `emitResult` output, but the
nonInteractiveCli tests still asserted the pre-change strings. Update
the 11 affected assertions to expect the trailing newline.

* fix: address review comments on background-agent notifications

Four additional issues from the PR review that the prior regression-fix
commit didn't cover:

- Escape XML metacharacters when interpolating `description`, `result`,
  `error`, `agentId`, `toolUseId`, and `status` into the task-notification
  envelope. Subagent output (which itself may carry untrusted tool output,
  fetched HTML, or another agent's notification) could contain
  `</result>` or `</task-notification>` and forge sibling tags the parent
  model would treat as trusted metadata. Truncate result text *before*
  escaping so the truncation never slices through an entity like `&amp;`.
- Emit the terminal notification from `cancel()` and `abortAll()`. The
  fire-and-forget `complete()`/`fail()` from the subagent task is guarded
  by `status !== 'running'` and was no-op'd after cancellation, so SDK
  consumers saw `task_started` with no matching `task_notification`,
  breaking the contract this PR establishes. Updated two race-guard
  tests that asserted the old behavior.
- Call `adapter.finalizeAssistantMessage()` before the abort-triggered
  early return inside `drainOneItem`'s stream loop. Without it,
  `startAssistantMessage()` had already been called, so stream-json mode
  left `message_start` unpaired.
- Enforce `config.getMaxSessionTurns()` in `drainOneItem` for symmetry
  with the main turn loop. Cron fires and notification replies otherwise
  bypass the budget cap in headless runs.

* fix: address codex review comments for background subagents

- Wrap background fork execute() in runInForkContext so the
  recursive-fork guard (AsyncLocalStorage-based) fires when a
  background fork's child model calls `agent` again. Previously only
  the foreground fork path was wrapped, so background forks could
  spawn nested implicit forks.
- Emit queued terminal task_notifications on SIGINT/SIGTERM before
  handleCancellationError exits. abortAll() enqueues cancellation
  notifications via the registry callback, but the process was
  exiting before the drain loop had a chance to flush them — leaving
  stream-json consumers that already saw task_started without a
  matching terminal task_notification. Extracted the SDK-emit block
  into a shared emitNotificationToSdk helper reused by the normal
  drain and the cancellation flush.
- Skip notification/cron subtypes in ACP HistoryReplayer. These
  records are persisted as type: 'user' so the model's chat history
  keeps them for continuity, but they were never user input —
  replaying them leaked raw <task-notification> XML (and cron
  prompts) back into the ACP session as if the user typed them.

* test(cli): sync JsonOutputAdapter text-mode assertions with trailing newline

Commit 0da1182b7 appended a newline to text-mode emitResult output
(zsh PROMPT_SP fix) and updated the nonInteractiveCli tests, but
four assertions in JsonOutputAdapter.test.ts were missed. Update
them to expect the trailing newline so CI passes.

* refactor: simplify background subagent plumbing

- Extract the SubagentStop hook blocking-decision loop into a
  runSubagentStopHookLoop helper so the foreground and background
  paths no longer duplicate the iteration/abort/log scaffolding.
- Unify BackgroundTaskRegistry.abortAll to delegate to cancel,
  removing copy-pasted abort/notification bookkeeping.
- Drop the unused findByName and BackgroundAgentEntry.name field.
- In nonInteractiveCli drain, hoist inputFormat and
  toolCallUpdateCallback out of the inner tool loop, and drop the
  unreachable try/catch around the readonly registry.
- Trim boilerplate doc/narration comments while keeping load-bearing
  WHY comments.

* fix: address codex review comments for background subagents

- Use tool callId (or short random suffix) instead of Date.now() for
  background agentIds; avoids registry collisions when parallel
  same-type agents launch in the same millisecond.
- Reset loopDetector and lastPromptId for Notification turns so a
  prior turn's loop count doesn't trip LoopDetected on the
  notification response.
- Replay notification/cron displayText in ACP HistoryReplayer so
  the assistant reply has an antecedent in resumed transcripts.

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
tanzhenxin 2026-04-17 18:23:06 +08:00 committed by GitHub
parent 2080686a62
commit 7e83c08062
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1708 additions and 244 deletions

View file

@ -1,6 +1,6 @@
---
name: structured-debugging
description:
description: >
Hypothesis-driven debugging methodology for hard bugs. Use this skill whenever
you're investigating non-trivial bugs, unexpected behavior, flaky tests, or
tracing issues through complex systems. Activate proactively when debugging
@ -32,11 +32,9 @@ Good: "The leader hangs because `hasActiveTeammates()` returns true after all ag
have reported completed, likely because terminal status isn't being set on the agent
object after the backend process exits."
Create a side note file for the investigation:
```
~/.qwen/investigations/<project>-<issue>.md
```
For bugs you expect to take more than one round, create a side note file
for the investigation in whichever location the project uses for such
notes.
Write your hypothesis there. This file persists across conversation turns and even
across sessions — it's your investigation journal.
@ -49,6 +47,12 @@ confirm or reject your hypothesis. Think about what data you need to see.
Don't scatter `console.log` everywhere. Identify the 2-3 places where your
hypothesis makes a testable prediction, and instrument those.
Prefer logging _values_ (return codes, payload contents, stream types,
message bodies, env state) over _presence checks_ ("was this function
called?", "was this branch taken?"). Code-path traces tell you what ran;
data traces tell you what it ran on. Most non-trivial bugs are correct
code processing wrong data.
Ask yourself: "If my hypothesis is correct, what will I see at point X?
If it's wrong, what will I see instead?"
@ -129,6 +133,23 @@ is still broken if the inbox contains stale messages from a previous run.
Always inspect the _content_ flowing through the code, not just whether the code
runs. Check payloads, message contents, file data, and database state.
### Reframing the user's report instead of investigating it
When the user reports a symptom your own run doesn't reproduce, the
contradiction _is_ the evidence — the two environments differ in some way
you haven't identified yet. The wrong move is to reframe their report
("they must be on a stale SHA", "they must be confused about what they
saw", "must be a flake") so that your run becomes the ground truth. Once
you do that, every later piece of evidence gets bent to defend the
reframing, and the actual bug stays hidden.
The right move: catalogue what differs between their environment and
yours (TTY vs pipe, terminal emulator, shell, locale, env vars, prior
state, build artifacts) before forming any hypothesis. For ambiguous
symptoms ("no output", "it's slow", "it's wrong") ask one disambiguating
question first — e.g., "does it hang or exit cleanly?" — that prunes the
hypothesis space cheaply before any test run.
### Losing context across attempts
After several debugging rounds, you start forgetting what you already tried and
@ -164,3 +185,10 @@ Fix: [what you're changing and why it addresses the root cause]
```
Then apply the fix, remove instrumentation, and verify with a clean run.
## Worked examples
- [`examples/headless-bg-agent-empty-stdout.md`](examples/headless-bg-agent-empty-stdout.md)
— pipe-captured runs all passed; the user's TTY printed nothing. The
contradiction _was_ the bug. Illustrates _reproduction contradiction is
data_ and _instrument data, not code paths_.

View file

@ -0,0 +1,59 @@
# Worked example: headless run prints empty stdout in zsh TTY
A short qwen-code case to illustrate two failure modes from `SKILL.md`:
_reproduction contradiction is data_, and _instrument the data flow, not
just the code path_.
## The bug
User: `npm run dev -- -p "..."` in zsh prints nothing. Process exits clean,
`~/.qwen/logs` shows the model returned proper text. Stdout was empty.
Cause: `JsonOutputAdapter.emitResult` wrote `resultMessage.result` without
a trailing `\n`. zsh's `PROMPT_SP` (powerlevel10k, agnoster, …) detects
the missing newline and emits `\r\033[K` before drawing the next prompt,
erasing the line. Pipe-captured stdout has no `PROMPT_SP`, so the bug is
invisible there.
Fix: append `\n` to the write.
## What made the case instructive
Every reproduction attempt from a debugging environment that captures
stdout (Cursor's Shell tool, `out=$(...)`, `tee`, file redirect) **passed**.
14/14 success against the user's 0/N. Same SHA, same machine, same
command. The only variable was: pipe stdout vs TTY stdout.
That contradiction was the entire investigation. Once it was named, the
fix was one line.
## Lessons mapped to SKILL.md
- **Reproduction contradiction is data, not user error.** When your run
succeeds and the user's fails on identical state, the _difference
between the two environments_ is where the bug lives. Catalogue what
differs (TTY vs pipe, terminal emulator, shell, locale, env vars,
prior state) before forming any hypothesis. Reframing the user's
report ("they must be on stale code") burns rounds and credibility.
- **Ask the one disambiguating question first.** "Does it hang or exit
cleanly?" would have falsified the most tempting wrong hypothesis here
(the recently-fixed drain-loop hang) on turn one. For any "no output"
report, that question is free and prunes half the hypothesis space.
- **Instrument the data flow, not just the code path.** Tracing whether
`write` was called showed the happy path firing every time and resolved
nothing. The breakthrough was logging the _return value_ of
`process.stdout.write` together with `process.stdout.isTTY`. Code-path
traces tell you what ran; data traces tell you what it ran on.
- **Pipe ≠ TTY.** A passing pipe-captured run does not prove a TTY user
sees the same output. Shell prompts can post-process trailing-newline-
less writes; terminals can swallow control sequences; pipes do
neither. When debugging interactive-shell symptoms, get evidence from
the user's actual terminal at least once.
## Reference
Fix commit: qwen-code `feadf052f`
`fix(cli): append newline to text-mode emitResult so zsh PROMPT_SP doesn't erase the line`

View file

@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { ChatRecord, AgentResultDisplay } from '@qwen-code/qwen-code-core';
import type {
ChatRecord,
AgentResultDisplay,
NotificationRecordPayload,
} from '@qwen-code/qwen-code-core';
import type {
Content,
GenerateContentResponseUsageMetadata,
@ -49,6 +53,21 @@ export class HistoryReplayer {
this.setActiveRecordId(record.uuid, record.timestamp);
switch (record.type) {
case 'user':
// Notification/cron records hold raw XML/prompt the user never
// typed; replay the friendly displayText so the assistant's reply
// has an antecedent in the ACP transcript.
if (record.subtype === 'notification' || record.subtype === 'cron') {
const displayText = (
record.systemPayload as NotificationRecordPayload | undefined
)?.displayText;
if (displayText) {
await this.messageEmitter.emitUserMessage(
displayText,
record.timestamp,
);
}
break;
}
if (record.message) {
await this.replayContent(record.message, 'user', record.timestamp);
}

View file

@ -428,7 +428,7 @@ describe('JsonOutputAdapter', () => {
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Response text');
expect(output).toBe('Response text\n');
});
it('should emit error result to stderr in text mode', () => {
@ -447,7 +447,7 @@ describe('JsonOutputAdapter', () => {
expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Test error message');
expect(output).toBe('Test error message\n');
stderrWriteSpy.mockRestore();
});
@ -465,7 +465,7 @@ describe('JsonOutputAdapter', () => {
expect(stdoutWriteSpy).toHaveBeenCalled();
const output = stdoutWriteSpy.mock.calls[0][0] as string;
expect(output).toBe('Custom summary text');
expect(output).toBe('Custom summary text\n');
});
it('should handle empty error message in text mode', () => {
@ -484,7 +484,7 @@ describe('JsonOutputAdapter', () => {
expect(stderrWriteSpy).toHaveBeenCalled();
const output = stderrWriteSpy.mock.calls[0][0] as string;
// When no errorMessage is provided, the default 'Unknown error' is used
expect(output).toBe('Unknown error');
expect(output).toBe('Unknown error\n');
stderrWriteSpy.mockRestore();
});

View file

@ -67,9 +67,9 @@ export class JsonOutputAdapter
if (this.config.getOutputFormat() === 'text') {
if (resultMessage.is_error) {
process.stderr.write(`${resultMessage.error?.message || ''}`);
process.stderr.write(`${resultMessage.error?.message || ''}\n`);
} else {
process.stdout.write(`${resultMessage.result}`);
process.stdout.write(`${resultMessage.result}\n`);
}
} else {
// Emit the entire messages array as JSON (includes all main agent + subagent messages)

View file

@ -146,6 +146,11 @@ describe('runNonInteractive', () => {
isInteractive: vi.fn().mockReturnValue(false),
isCronEnabled: vi.fn().mockReturnValue(false),
getCronScheduler: vi.fn().mockReturnValue(null),
getBackgroundTaskRegistry: vi.fn().mockReturnValue({
setNotificationCallback: vi.fn(),
setRegisterCallback: vi.fn(),
getRunning: vi.fn().mockReturnValue([]),
}),
} as unknown as Config;
mockSettings = {
@ -255,7 +260,7 @@ describe('runNonInteractive', () => {
'prompt-id-1',
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World');
expect(processStdoutSpy).toHaveBeenCalledWith('Hello World\n');
expect(mockShutdownTelemetry).toHaveBeenCalled();
});
@ -319,7 +324,7 @@ describe('runNonInteractive', () => {
'prompt-id-2',
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer');
expect(processStdoutSpy).toHaveBeenCalledWith('Final answer\n');
});
it('should handle error during tool execution and should send error back to the model', async () => {
@ -388,7 +393,7 @@ describe('runNonInteractive', () => {
'prompt-id-3',
{ type: SendMessageType.ToolResult },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.');
expect(processStdoutSpy).toHaveBeenCalledWith('Sorry, let me try again.\n');
});
it('should exit with error if sendMessageStream throws initially', async () => {
@ -450,7 +455,7 @@ describe('runNonInteractive', () => {
expect(mockCoreExecuteToolCall).toHaveBeenCalled();
expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2);
expect(processStdoutSpy).toHaveBeenCalledWith(
"Sorry, I can't find that tool.",
"Sorry, I can't find that tool.\n",
);
});
@ -514,7 +519,7 @@ describe('runNonInteractive', () => {
);
// 6. Assert the final output is correct
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.');
expect(processStdoutSpy).toHaveBeenCalledWith('Summary complete.\n');
});
it('should process input and write JSON output with stats', async () => {
@ -887,7 +892,7 @@ describe('runNonInteractive', () => {
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command');
expect(processStdoutSpy).toHaveBeenCalledWith('Response from command\n');
});
it('should handle command that requires confirmation by returning early', async () => {
@ -912,7 +917,7 @@ describe('runNonInteractive', () => {
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.',
'Shell command confirmation is not supported in non-interactive mode. Use YOLO mode or pre-approve commands.\n',
);
});
@ -947,7 +952,7 @@ describe('runNonInteractive', () => {
{ type: SendMessageType.UserQuery },
);
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown');
expect(processStdoutSpy).toHaveBeenCalledWith('Response to unknown\n');
});
it('should handle known but unsupported slash commands like /help by returning early', async () => {
@ -970,7 +975,7 @@ describe('runNonInteractive', () => {
// Should write error message through adapter to stdout (TEXT mode goes through JsonOutputAdapter)
expect(processStderrSpy).toHaveBeenCalledWith(
'The command "/help" is not supported in non-interactive mode.',
'The command "/help" is not supported in non-interactive mode.\n',
);
});
@ -995,7 +1000,7 @@ describe('runNonInteractive', () => {
// Should write error message to stderr
expect(processStderrSpy).toHaveBeenCalledWith(
'Unknown command result type: unhandled',
'Unknown command result type: unhandled\n',
);
});
@ -1033,7 +1038,7 @@ describe('runNonInteractive', () => {
expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2');
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged');
expect(processStdoutSpy).toHaveBeenCalledWith('Acknowledged\n');
});
it('should emit stream-json envelopes when output format is stream-json', async () => {

View file

@ -4,7 +4,11 @@
* SPDX-License-Identifier: Apache-2.0
*/
import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core';
import type {
BackgroundAgentStatus,
Config,
ToolCallRequestInfo,
} from '@qwen-code/qwen-code-core';
import { isSlashCommand } from './ui/utils/commandUtils.js';
import type { LoadedSettings } from './config/settings.js';
import {
@ -250,6 +254,54 @@ export async function runNonInteractive(
const initialParts = normalizePartList(initialPartList);
let currentMessages: Content[] = [{ role: 'user', parts: initialParts }];
// Register the callback early so background agents launched during the main
// tool-call chain can push completions onto the queue.
interface LocalQueueItem {
displayText: string;
modelText: string;
sendMessageType: SendMessageType;
sdkNotification?: {
task_id: string;
tool_use_id?: string;
status: BackgroundAgentStatus;
usage?: {
total_tokens: number;
tool_uses: number;
duration_ms: number;
};
};
}
const localQueue: LocalQueueItem[] = [];
const registry = config.getBackgroundTaskRegistry();
registry.setNotificationCallback((displayText, modelText, meta) => {
localQueue.push({
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
sdkNotification: {
task_id: meta.agentId,
tool_use_id: meta.toolUseId,
status: meta.status,
usage: meta.stats
? {
total_tokens: meta.stats.totalTokens,
tool_uses: meta.stats.toolUses,
duration_ms: meta.stats.durationMs,
}
: undefined,
},
});
});
registry.setRegisterCallback((entry) => {
adapter.emitSystemMessage('task_started', {
task_id: entry.agentId,
tool_use_id: entry.toolUseId,
description: entry.description,
subagent_type: entry.subagentType,
});
});
let isFirstTurn = true;
let modelOverride: string | undefined;
while (true) {
@ -346,9 +398,6 @@ export async function runNonInteractive(
},
);
// Note: In JSON mode, subagent messages are automatically added to the main
// adapter's messages array and will be output together on emitResult()
if (toolResponse.error) {
// In JSON/STREAM_JSON mode, tool errors are tolerated and formatted
// as tool_result blocks. handleToolError will detect JSON/STREAM_JSON mode
@ -380,144 +429,251 @@ export async function runNonInteractive(
}
currentMessages = [{ role: 'user', parts: toolResponseParts }];
} else {
// No more tool calls — check if cron jobs are keeping us alive
// Shared between the normal drain and the cancellation flush so stream-json
// consumers always see a terminal task_notification paired with task_started.
const emitNotificationToSdk = (item: LocalQueueItem) => {
if (item.sendMessageType !== SendMessageType.Notification) return;
adapter.emitUserMessage([{ text: item.displayText }]);
if (item.sdkNotification) {
adapter.emitSystemMessage(
'task_notification',
item.sdkNotification,
);
}
};
// Drain-turns count toward getMaxSessionTurns() for symmetry with the main
// loop — otherwise a looping cron or a model that keeps replying to
// notifications could exceed the cap silently in headless runs.
const drainOneItem = async () => {
if (localQueue.length === 0) return;
const item = localQueue.shift()!;
emitNotificationToSdk(item);
turnCount++;
if (
config.getMaxSessionTurns() >= 0 &&
turnCount > config.getMaxSessionTurns()
) {
handleMaxTurnsExceededError(config);
}
const inputFormat =
typeof config.getInputFormat === 'function'
? config.getInputFormat()
: InputFormat.TEXT;
const toolCallUpdateCallback =
inputFormat === InputFormat.STREAM_JSON && options.controlService
? options.controlService.permission.getToolCallUpdateCallback()
: undefined;
let itemMessages: Content[] = [
{ role: 'user', parts: [{ text: item.modelText }] },
];
let itemIsFirstTurn = true;
let itemModelOverride: string | undefined;
while (true) {
const itemToolCallRequests: ToolCallRequestInfo[] = [];
const itemApiStartTime = Date.now();
const itemStream = geminiClient.sendMessageStream(
itemMessages[0]?.parts || [],
abortController.signal,
prompt_id,
{
type: itemIsFirstTurn
? item.sendMessageType
: SendMessageType.ToolResult,
modelOverride: itemModelOverride,
...(itemIsFirstTurn && {
notificationDisplayText: item.displayText,
}),
},
);
itemIsFirstTurn = false;
adapter.startAssistantMessage();
for await (const event of itemStream) {
if (abortController.signal.aborted) {
// Pair the startAssistantMessage() above so stream-json mode doesn't
// leave an unterminated message_start.
adapter.finalizeAssistantMessage();
return;
}
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
itemToolCallRequests.push(event.value);
}
if (
outputFormat === OutputFormat.TEXT &&
event.type === GeminiEventType.Error
) {
const errorText = parseAndFormatApiError(
event.value.error,
config.getContentGeneratorConfig()?.authType,
);
process.stderr.write(`${errorText}\n`);
throw new Error(errorText);
}
}
adapter.finalizeAssistantMessage();
totalApiDurationMs += Date.now() - itemApiStartTime;
if (itemToolCallRequests.length > 0) {
const itemToolResponseParts: Part[] = [];
for (const requestInfo of itemToolCallRequests) {
const isAgentTool = requestInfo.name === 'agent';
const { handler: outputUpdateHandler } = isAgentTool
? createAgentToolProgressHandler(
config,
requestInfo.callId,
adapter,
)
: createToolProgressHandler(requestInfo, adapter);
const toolResponse = await executeToolCall(
config,
requestInfo,
abortController.signal,
{
outputUpdateHandler,
...(toolCallUpdateCallback && {
onToolCallsUpdate: toolCallUpdateCallback,
}),
},
);
if (toolResponse.error) {
handleToolError(
requestInfo.name,
toolResponse.error,
config,
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: undefined,
);
}
adapter.emitToolResult(requestInfo, toolResponse);
if (toolResponse.responseParts) {
itemToolResponseParts.push(...toolResponse.responseParts);
}
if ('modelOverride' in toolResponse) {
itemModelOverride = toolResponse.modelOverride;
}
}
itemMessages = [{ role: 'user', parts: itemToolResponseParts }];
} else {
break;
}
}
};
// Single-flight drain: concurrent callers wait for the running drain so
// cron jobs firing mid-stream don't produce overlapping turns.
//
// Clear via outer `.finally()` rather than inside the async body: when the
// queue is empty the body runs synchronously, so an inner finally would
// null the slot BEFORE the outer `drainPromise = p` assignment and leave
// it stuck forever.
let drainPromise: Promise<void> | null = null;
const drainLocalQueue = (): Promise<void> => {
if (drainPromise) return drainPromise;
const p = (async () => {
while (localQueue.length > 0) {
await drainOneItem();
}
})();
drainPromise = p;
void p.finally(() => {
if (drainPromise === p) drainPromise = null;
});
return p;
};
// Start cron scheduler — fires enqueue onto the shared queue.
const scheduler = !config.isCronEnabled()
? null
: config.getCronScheduler();
if (scheduler && scheduler.size > 0) {
// Start the scheduler and wait for all jobs to complete or be deleted.
// Each fired prompt is processed as a new turn through the same loop.
await new Promise<void>((resolve) => {
const cronQueue: string[] = [];
let processing = false;
const checkDone = () => {
if (scheduler.size === 0 && !processing) {
if (scheduler && scheduler.size > 0) {
await new Promise<void>((resolve, reject) => {
// Resolve on SIGINT/SIGTERM too — recurring cron jobs never
// drop scheduler.size to 0 on their own, so without this the
// hold-back loop below is unreachable after an abort.
const onAbort = () => {
scheduler.stop();
resolve();
};
if (abortController.signal.aborted) {
onAbort();
return;
}
abortController.signal.addEventListener('abort', onAbort, {
once: true,
});
const checkCronDone = () => {
if (scheduler.size === 0 && !drainPromise) {
abortController.signal.removeEventListener('abort', onAbort);
scheduler.stop();
resolve();
}
};
const drainQueue = async () => {
if (processing) return;
processing = true;
try {
while (cronQueue.length > 0) {
const cronPrompt = cronQueue.shift()!;
turnCount++;
let cronMessages: Content[] = [
{ role: 'user', parts: [{ text: cronPrompt }] },
];
let cronIsFirstTurn = true;
let cronModelOverride: string | undefined;
while (true) {
const cronToolCallRequests: ToolCallRequestInfo[] = [];
const cronApiStartTime = Date.now();
const cronStream = geminiClient.sendMessageStream(
cronMessages[0]?.parts || [],
abortController.signal,
prompt_id,
{
type: cronIsFirstTurn
? SendMessageType.Cron
: SendMessageType.ToolResult,
modelOverride: cronModelOverride,
},
);
cronIsFirstTurn = false;
adapter.startAssistantMessage();
for await (const event of cronStream) {
if (abortController.signal.aborted) {
const summary = scheduler.getExitSummary();
scheduler.stop();
if (summary) {
process.stderr.write(summary + '\n');
}
resolve();
return;
}
adapter.processEvent(event);
if (event.type === GeminiEventType.ToolCallRequest) {
cronToolCallRequests.push(event.value);
}
}
adapter.finalizeAssistantMessage();
totalApiDurationMs += Date.now() - cronApiStartTime;
if (cronToolCallRequests.length > 0) {
const cronToolResponseParts: Part[] = [];
for (const requestInfo of cronToolCallRequests) {
const isAgentTool = requestInfo.name === 'agent';
const { handler: outputUpdateHandler } = isAgentTool
? createAgentToolProgressHandler(
config,
requestInfo.callId,
adapter,
)
: createToolProgressHandler(requestInfo, adapter);
const toolResponse = await executeToolCall(
config,
requestInfo,
abortController.signal,
{ outputUpdateHandler },
);
if (toolResponse.error) {
handleToolError(
requestInfo.name,
toolResponse.error,
config,
toolResponse.errorType || 'TOOL_EXECUTION_ERROR',
typeof toolResponse.resultDisplay === 'string'
? toolResponse.resultDisplay
: undefined,
);
}
adapter.emitToolResult(requestInfo, toolResponse);
if (toolResponse.responseParts) {
cronToolResponseParts.push(
...toolResponse.responseParts,
);
}
if ('modelOverride' in toolResponse) {
cronModelOverride = toolResponse.modelOverride;
}
}
cronMessages = [
{ role: 'user', parts: cronToolResponseParts },
];
} else {
break;
}
}
}
} catch (error) {
debugLogger.error('Error processing cron prompt:', error);
} finally {
processing = false;
checkDone();
}
// Propagate drain failures. Without this, a rejected
// drainLocalQueue() (e.g. a text-mode API error surfacing
// out of drainOneItem) would be swallowed by `void` and
// checkCronDone would never fire — hanging the run.
const onDrainError = (err: unknown) => {
abortController.signal.removeEventListener('abort', onAbort);
scheduler.stop();
reject(err);
};
scheduler.start((job: { prompt: string }) => {
cronQueue.push(job.prompt);
void drainQueue();
const label = job.prompt.slice(0, 40);
localQueue.push({
displayText: `Cron: ${label}`,
modelText: job.prompt,
sendMessageType: SendMessageType.Cron,
});
drainLocalQueue().then(checkCronDone, onDrainError);
});
// Also check immediately in case jobs were already deleted
checkDone();
// Check immediately in case jobs were already deleted
checkCronDone();
});
}
// Wait for running background agents to complete before emitting the final
// result. On SIGINT/SIGTERM, abort them and route through
// handleCancellationError — otherwise the success emitResult below would
// silently convert a cancellation into a completion.
while (true) {
if (abortController.signal.aborted) {
registry.abortAll();
// Flush queued terminal notifications before handleCancellationError
// exits so stream-json consumers always see a task_notification paired
// with every task_started.
while (localQueue.length > 0) {
emitNotificationToSdk(localQueue.shift()!);
}
handleCancellationError(config);
}
await drainLocalQueue();
const running = registry.getRunning();
if (running.length === 0 && localQueue.length === 0) break;
await new Promise((r) => setTimeout(r, 100));
}
const metrics = uiTelemetryService.getMetrics();
const usage = computeUsageFromMetrics(metrics);
// Get stats for JSON format output
@ -567,6 +723,10 @@ export async function runNonInteractive(
});
handleError(error, config);
} finally {
const reg = config.getBackgroundTaskRegistry();
reg.setNotificationCallback(undefined);
reg.setRegisterCallback(undefined);
process.stdout.removeListener('error', stdoutErrorHandler);
// Cleanup signal handlers
process.removeListener('SIGINT', shutdownHandler);

View file

@ -98,6 +98,9 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'user' && (
<UserMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'notification' && (
<InfoMessage text={itemForDisplay.text} />
)}
{itemForDisplay.type === 'user_shell' && (
<UserShellMessage text={itemForDisplay.text} />
)}

View file

@ -45,6 +45,8 @@ const getStatusColor = (
case 'completed':
case 'success':
return theme.status.success;
case 'background':
return theme.text.secondary;
case 'cancelled':
return theme.status.warning;
case 'failed':
@ -60,6 +62,8 @@ const getStatusText = (status: AgentResultDisplay['status']) => {
return 'Running';
case 'completed':
return 'Completed';
case 'background':
return 'Running in background';
case 'cancelled':
return 'User Cancelled';
case 'failed':

View file

@ -207,6 +207,9 @@ describe('useGeminiStream', () => {
getArenaAgentClient: vi.fn(() => null),
isCronEnabled: vi.fn(() => false),
getCronScheduler: vi.fn(() => null),
getBackgroundTaskRegistry: vi.fn(() => ({
setNotificationCallback: vi.fn(),
})),
} as unknown as Config;
mockOnDebugMessage = vi.fn();
mockHandleSlashCommand = vi.fn().mockResolvedValue(false);

View file

@ -527,6 +527,7 @@ export const useGeminiStream = (
userMessageTimestamp: number,
abortSignal: AbortSignal,
prompt_id: string,
submitType: SendMessageType,
): Promise<{
queryToSend: PartListUnion | null;
shouldProceed: boolean;
@ -542,6 +543,19 @@ export const useGeminiStream = (
if (typeof query === 'string') {
const trimmedQuery = query.trim();
// Notification messages (e.g. background agent completions) are
// pre-processed by the notification drain loop which already
// added the display item to history. Just pass the model text
// through to the API. Cron prompts still go through the normal
// slash/@-command/shell preprocessing path below.
if (submitType === SendMessageType.Notification) {
onDebugMessage(
`Received notification (${trimmedQuery.length} chars)`,
);
return { queryToSend: trimmedQuery, shouldProceed: true };
}
onDebugMessage(`Received user query (${trimmedQuery.length} chars)`);
await logger?.logMessage(MessageSenderType.USER, trimmedQuery);
@ -592,10 +606,15 @@ export const useGeminiStream = (
localQueryToSendToGemini = trimmedQuery;
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
// Cron prompts are already rendered as a `● Cron: …` notification by
// the queue drain, so skip the user-message history item to avoid
// a duplicate `> …` line. Preprocessing (@/slash/shell) still runs.
if (submitType !== SendMessageType.Cron) {
addItem(
{ type: MessageType.USER, text: trimmedQuery },
userMessageTimestamp,
);
}
// Handle @-commands (which might involve tool calls)
if (isAtCommand(trimmedQuery)) {
@ -1225,6 +1244,7 @@ export const useGeminiStream = (
query: PartListUnion,
submitType: SendMessageType = SendMessageType.UserQuery,
prompt_id?: string,
metadata?: { notificationDisplayText?: string },
) => {
const allowConcurrentBtwDuringResponse =
submitType === SendMessageType.UserQuery &&
@ -1301,6 +1321,7 @@ export const useGeminiStream = (
userMessageTimestamp,
abortSignal,
prompt_id!,
submitType,
);
if (!shouldProceed || queryToSend === null) {
@ -1365,7 +1386,11 @@ export const useGeminiStream = (
finalQueryToSend,
abortSignal,
prompt_id!,
{ type: submitType, modelOverride: modelOverrideRef.current },
{
type: submitType,
notificationDisplayText: metadata?.notificationDisplayText,
modelOverride: modelOverrideRef.current,
},
);
const processingStatus = await processGeminiStreamEvents(
@ -1843,17 +1868,29 @@ export const useGeminiStream = (
storage,
]);
// ─── Cron scheduler integration ─────────────────────────
const cronQueueRef = useRef<string[]>([]);
const [cronTrigger, setCronTrigger] = useState(0);
// ─── Unified notification queue (cron + background agents) ──────
const notificationQueueRef = useRef<
Array<{
displayText: string;
modelText: string;
sendMessageType: SendMessageType;
}>
>([]);
const [notificationTrigger, setNotificationTrigger] = useState(0);
// Start the scheduler on mount, stop on unmount
// Start the cron scheduler on mount, stop on unmount.
// Cron fires enqueue onto the shared notification queue.
useEffect(() => {
if (!config.isCronEnabled()) return;
const scheduler = config.getCronScheduler();
scheduler.start((job: { prompt: string }) => {
cronQueueRef.current.push(job.prompt);
setCronTrigger((n) => n + 1);
const label = job.prompt.slice(0, 40);
notificationQueueRef.current.push({
displayText: `Cron: ${label}`,
modelText: job.prompt,
sendMessageType: SendMessageType.Cron,
});
setNotificationTrigger((n) => n + 1);
});
return () => {
const summary = scheduler.getExitSummary();
@ -1864,16 +1901,38 @@ export const useGeminiStream = (
};
}, [config]);
// When idle, drain the cron queue one prompt at a time
// Register background agent notification callback onto the shared queue.
useEffect(() => {
const registry = config.getBackgroundTaskRegistry();
registry.setNotificationCallback((displayText, modelText) => {
notificationQueueRef.current.push({
displayText,
modelText,
sendMessageType: SendMessageType.Notification,
});
setNotificationTrigger((n) => n + 1);
});
return () => {
registry.setNotificationCallback(undefined);
};
}, [config]);
// When idle, drain the unified queue one item at a time.
useEffect(() => {
if (
streamingState === StreamingState.Idle &&
cronQueueRef.current.length > 0
notificationQueueRef.current.length > 0
) {
const prompt = cronQueueRef.current.shift()!;
submitQuery(prompt, SendMessageType.Cron);
const item = notificationQueueRef.current.shift()!;
addItem(
{ type: 'notification' as const, text: item.displayText },
Date.now(),
);
submitQuery(item.modelText, item.sendMessageType, undefined, {
notificationDisplayText: item.displayText,
});
}
}, [streamingState, submitQuery, cronTrigger]);
}, [streamingState, submitQuery, notificationTrigger, addItem]);
return {
streamingState,

View file

@ -208,6 +208,11 @@ export type HistoryItemToolGroup = HistoryItemBase & {
isUserInitiated?: boolean;
};
export type HistoryItemNotification = HistoryItemBase & {
type: 'notification';
text: string;
};
export type HistoryItemUserShell = HistoryItemBase & {
type: 'user_shell';
text: string;
@ -418,6 +423,7 @@ export type HistoryItemStopHookSystemMessage = HistoryItemBase & {
// Individually exported types extending HistoryItemBase
export type HistoryItemWithoutId =
| HistoryItemUser
| HistoryItemNotification
| HistoryItemUserShell
| HistoryItemGemini
| HistoryItemGeminiContent

View file

@ -256,6 +256,22 @@ function convertToHistoryItems(
}
switch (record.type) {
case 'user': {
// Restore notification items (background agent completions and cron fires)
if (record.subtype === 'notification' || record.subtype === 'cron') {
const payload = record.systemPayload as
| { displayText?: string }
| undefined;
const fallback =
record.subtype === 'cron'
? 'Cron job fired'
: 'Background agent completed';
const text =
payload?.displayText ||
extractTextFromParts(record.message?.parts as Part[]) ||
fallback;
items.push({ type: 'notification', text });
break;
}
if (pendingAtCommands.length > 0) {
// Flush any pending tool group before user message
if (currentToolGroup.length > 0) {

View file

@ -0,0 +1,287 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BackgroundTaskRegistry } from './background-tasks.js';
describe('BackgroundTaskRegistry', () => {
let registry: BackgroundTaskRegistry;
beforeEach(() => {
registry = new BackgroundTaskRegistry();
});
it('registers and retrieves a background agent', () => {
const entry = {
agentId: 'test-1',
description: 'test agent',
status: 'running' as const,
startTime: Date.now(),
abortController: new AbortController(),
};
registry.register(entry);
expect(registry.get('test-1')).toBe(entry);
});
it('completes a background agent and sends notification', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.complete('test-1', 'The result text');
const entry = registry.get('test-1')!;
expect(entry.status).toBe('completed');
expect(entry.result).toBe('The result text');
expect(entry.endTime).toBeDefined();
expect(callback).toHaveBeenCalledOnce();
const [displayText, modelText] = callback.mock.calls[0] as [string, string];
// Display text: short summary without the full result
expect(displayText).toContain('completed');
expect(displayText).toContain('test agent');
expect(displayText).not.toContain('The result text');
// Model text: full details including result for the LLM
expect(modelText).toContain('The result text');
});
it('fails a background agent and sends notification', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.fail('test-1', 'Something went wrong');
const entry = registry.get('test-1')!;
expect(entry.status).toBe('failed');
expect(entry.error).toBe('Something went wrong');
expect(callback).toHaveBeenCalledOnce();
const [displayText] = callback.mock.calls[0] as [string, string];
expect(displayText).toContain('failed');
});
it('cancels a running background agent', () => {
const abortController = new AbortController();
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController,
});
registry.cancel('test-1');
expect(registry.get('test-1')!.status).toBe('cancelled');
expect(abortController.signal.aborted).toBe(true);
});
it('does not cancel a non-running agent', () => {
const abortController = new AbortController();
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController,
});
registry.complete('test-1', 'done');
registry.cancel('test-1'); // should be a no-op
expect(registry.get('test-1')!.status).toBe('completed');
expect(abortController.signal.aborted).toBe(false);
});
it('lists running agents', () => {
registry.register({
agentId: 'a',
description: 'agent a',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.register({
agentId: 'b',
description: 'agent b',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.complete('a', 'done');
const running = registry.getRunning();
expect(running).toHaveLength(1);
expect(running[0].agentId).toBe('b');
});
it('aborts all running agents', () => {
const ac1 = new AbortController();
const ac2 = new AbortController();
registry.register({
agentId: 'a',
description: 'agent a',
status: 'running',
startTime: Date.now(),
abortController: ac1,
});
registry.register({
agentId: 'b',
description: 'agent b',
status: 'running',
startTime: Date.now(),
abortController: ac2,
});
registry.abortAll();
expect(ac1.signal.aborted).toBe(true);
expect(ac2.signal.aborted).toBe(true);
expect(registry.get('a')!.status).toBe('cancelled');
expect(registry.get('b')!.status).toBe('cancelled');
});
it('complete is a no-op after cancellation (state race guard)', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.cancel('test-1');
registry.complete('test-1', 'late result');
// Status should remain 'cancelled', not flip to 'completed'
expect(registry.get('test-1')!.status).toBe('cancelled');
// Exactly one notification, emitted by cancel() itself — the late
// complete() must be no-op'd by the running-status guard.
expect(callback).toHaveBeenCalledTimes(1);
const [, modelText] = callback.mock.calls[0];
expect(modelText).toContain('<status>cancelled</status>');
});
it('fail is a no-op after cancellation (state race guard)', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.cancel('test-1');
registry.fail('test-1', 'late error');
expect(registry.get('test-1')!.status).toBe('cancelled');
expect(callback).toHaveBeenCalledTimes(1);
const [, modelText] = callback.mock.calls[0];
expect(modelText).toContain('<status>cancelled</status>');
});
it('does not send notification without callback', () => {
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
// Should not throw
registry.complete('test-1', 'done');
expect(registry.get('test-1')!.status).toBe('completed');
});
it('propagates toolUseId through XML and notification meta', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
toolUseId: 'call-abc-123',
});
registry.complete('test-1', 'done');
expect(callback).toHaveBeenCalledOnce();
const [, modelText, meta] = callback.mock.calls[0];
expect(modelText).toContain('<tool-use-id>call-abc-123</tool-use-id>');
expect(meta.toolUseId).toBe('call-abc-123');
});
it('omits tool-use-id XML tag when toolUseId is absent', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
registry.register({
agentId: 'test-1',
description: 'test agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.complete('test-1', 'done');
const [, modelText, meta] = callback.mock.calls[0];
expect(modelText).not.toContain('<tool-use-id>');
expect(meta.toolUseId).toBeUndefined();
});
it('escapes XML metacharacters in interpolated fields', () => {
const callback = vi.fn();
registry.setNotificationCallback(callback);
registry.register({
agentId: 'test-1',
description: 'summarize </result> & </task-notification>',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
});
registry.complete('test-1', 'here is <b>bold</b> & </task-notification>');
const [, modelText] = callback.mock.calls[0];
// No injected closing tags — subagent text is escaped so the
// parent envelope stays a single task-notification element.
expect(modelText.match(/<\/task-notification>/g)!.length).toBe(1);
expect(modelText).toContain('&lt;/result&gt;');
expect(modelText).toContain('&lt;/task-notification&gt;');
expect(modelText).toContain('&lt;b&gt;bold&lt;/b&gt;');
expect(modelText).toContain('&amp;');
});
});

View file

@ -0,0 +1,248 @@
/**
* @license
* Copyright 2025 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @fileoverview BackgroundTaskRegistry tracks background (async) sub-agents.
*
* When the Agent tool is called with `run_in_background: true`, the sub-agent
* runs asynchronously. This registry tracks the lifecycle of each background
* agent so the parent can be notified on completion.
*/
import { createDebugLogger } from '../utils/debugLogger.js';
const debugLogger = createDebugLogger('BACKGROUND_TASKS');
const MAX_DESCRIPTION_LENGTH = 40;
const MAX_RESULT_LENGTH = 2000;
// Escape text so it is safe to interpolate into an XML element body.
// Subagent-produced strings (description, result, error) can contain `<`,
// `>`, or literal `</task-notification>` — without escaping, a subagent
// summarizing HTML or another agent's notification could close the
// envelope early and forge sibling tags (e.g. a faked <status>) that the
// parent model would treat as trusted metadata.
function escapeXml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export type BackgroundAgentStatus =
| 'running'
| 'completed'
| 'failed'
| 'cancelled';
export interface AgentCompletionStats {
totalTokens: number;
toolUses: number;
durationMs: number;
}
export interface BackgroundAgentEntry {
agentId: string;
description: string;
subagentType?: string;
status: BackgroundAgentStatus;
startTime: number;
endTime?: number;
result?: string;
error?: string;
abortController: AbortController;
stats?: AgentCompletionStats;
toolUseId?: string;
}
export interface NotificationMeta {
agentId: string;
status: BackgroundAgentStatus;
stats?: AgentCompletionStats;
toolUseId?: string;
}
export type BackgroundNotificationCallback = (
displayText: string,
modelText: string,
meta: NotificationMeta,
) => void;
export type BackgroundRegisterCallback = (entry: BackgroundAgentEntry) => void;
export class BackgroundTaskRegistry {
private readonly agents = new Map<string, BackgroundAgentEntry>();
private notificationCallback?: BackgroundNotificationCallback;
private registerCallback?: BackgroundRegisterCallback;
register(entry: BackgroundAgentEntry): void {
this.agents.set(entry.agentId, entry);
debugLogger.info(`Registered background agent: ${entry.agentId}`);
if (this.registerCallback) {
try {
this.registerCallback(entry);
} catch (error) {
debugLogger.error('Failed to emit register callback:', error);
}
}
}
// No-op if not 'running' — guards against race with concurrent cancellation.
complete(
agentId: string,
result: string,
stats?: AgentCompletionStats,
): void {
const entry = this.agents.get(agentId);
if (!entry || entry.status !== 'running') return;
entry.status = 'completed';
entry.endTime = Date.now();
entry.result = result;
entry.stats = stats;
debugLogger.info(`Background agent completed: ${agentId}`);
this.emitNotification(entry);
}
// No-op if not 'running' — guards against race with concurrent cancellation.
fail(agentId: string, error: string, stats?: AgentCompletionStats): void {
const entry = this.agents.get(agentId);
if (!entry || entry.status !== 'running') return;
entry.status = 'failed';
entry.endTime = Date.now();
entry.error = error;
entry.stats = stats;
debugLogger.info(`Background agent failed: ${agentId}`);
this.emitNotification(entry);
}
// Emit the terminal notification here — the fire-and-forget complete()/fail()
// path is guarded by `status !== 'running'` and will no-op, so without this the
// SDK contract breaks: consumers saw task_started but never receive a matching
// task_notification.
cancel(agentId: string): void {
const entry = this.agents.get(agentId);
if (!entry || entry.status !== 'running') return;
entry.abortController.abort();
entry.status = 'cancelled';
entry.endTime = Date.now();
debugLogger.info(`Background agent cancelled: ${agentId}`);
this.emitNotification(entry);
}
get(agentId: string): BackgroundAgentEntry | undefined {
return this.agents.get(agentId);
}
getRunning(): BackgroundAgentEntry[] {
return Array.from(this.agents.values()).filter(
(e) => e.status === 'running',
);
}
setNotificationCallback(
cb: BackgroundNotificationCallback | undefined,
): void {
this.notificationCallback = cb;
}
setRegisterCallback(cb: BackgroundRegisterCallback | undefined): void {
this.registerCallback = cb;
}
abortAll(): void {
for (const entry of Array.from(this.agents.values())) {
this.cancel(entry.agentId);
}
debugLogger.info('Aborted all background agents');
}
private buildDisplayLabel(entry: BackgroundAgentEntry): string {
// Strip the subagent type prefix if the description already starts with it
// to avoid duplication like "Explore: Explore: list ts files".
let rawDesc = entry.description;
if (
entry.subagentType &&
rawDesc.toLowerCase().startsWith(entry.subagentType.toLowerCase() + ':')
) {
rawDesc = rawDesc.slice(entry.subagentType.length + 1).trimStart();
}
const desc =
rawDesc.length > MAX_DESCRIPTION_LENGTH
? rawDesc.slice(0, MAX_DESCRIPTION_LENGTH) + '...'
: rawDesc;
return entry.subagentType ? `${entry.subagentType}: ${desc}` : desc;
}
private emitNotification(entry: BackgroundAgentEntry): void {
if (!this.notificationCallback) return;
const statusText =
entry.status === 'completed'
? 'completed'
: entry.status === 'failed'
? 'failed'
: 'was cancelled';
const label = this.buildDisplayLabel(entry);
const displayLine = `Background agent "${label}" ${statusText}.`;
// Truncate before escaping so we don't slice through an escape
// sequence (e.g. mid-`&amp;`) and emit malformed XML.
const rawResult = entry.result
? entry.result.length > MAX_RESULT_LENGTH
? entry.result.slice(0, MAX_RESULT_LENGTH) + '\n[truncated]'
: entry.result
: undefined;
const xmlParts: string[] = [
'<task-notification>',
`<task-id>${escapeXml(entry.agentId)}</task-id>`,
];
if (entry.toolUseId) {
xmlParts.push(`<tool-use-id>${escapeXml(entry.toolUseId)}</tool-use-id>`);
}
xmlParts.push(
`<status>${escapeXml(entry.status)}</status>`,
`<summary>Agent "${escapeXml(entry.description)}" ${statusText}.</summary>`,
);
if (rawResult) {
xmlParts.push(`<result>${escapeXml(rawResult)}</result>`);
}
if (entry.error) {
xmlParts.push(`<result>Error: ${escapeXml(entry.error)}</result>`);
}
if (entry.stats) {
xmlParts.push(
'<usage>',
`<total_tokens>${entry.stats.totalTokens}</total_tokens>`,
`<tool_uses>${entry.stats.toolUses}</tool_uses>`,
`<duration_ms>${entry.stats.durationMs}</duration_ms>`,
'</usage>',
);
}
xmlParts.push('</task-notification>');
const meta: NotificationMeta = {
agentId: entry.agentId,
status: entry.status,
stats: entry.stats,
toolUseId: entry.toolUseId,
};
try {
this.notificationCallback(displayLine, xmlParts.join('\n'), meta);
} catch (error) {
debugLogger.error('Failed to emit background notification:', error);
}
}
}

View file

@ -16,3 +16,4 @@
export * from './backends/index.js';
export * from './arena/index.js';
export * from './runtime/index.js';
export * from './background-tasks.js';

View file

@ -78,6 +78,7 @@ import { SkillManager } from '../skills/skill-manager.js';
import { PermissionManager } from '../permissions/permission-manager.js';
import { SubagentManager } from '../subagents/subagent-manager.js';
import type { SubagentConfig } from '../subagents/types.js';
import { BackgroundTaskRegistry } from '../agents/background-tasks.js';
import {
DEFAULT_OTLP_ENDPOINT,
DEFAULT_TELEMETRY_TARGET,
@ -533,6 +534,7 @@ export class Config {
private toolRegistry!: ToolRegistry;
private promptRegistry!: PromptRegistry;
private subagentManager!: SubagentManager;
private readonly backgroundTaskRegistry = new BackgroundTaskRegistry();
private extensionManager!: ExtensionManager;
private skillManager: SkillManager | null = null;
private permissionManager: PermissionManager | null = null;
@ -1508,6 +1510,8 @@ export class Config {
await this.toolRegistry.stop();
}
this.backgroundTaskRegistry.abortAll();
await this.cleanupArenaRuntime();
} catch (error) {
// Log but don't throw - cleanup should be best-effort
@ -2315,6 +2319,19 @@ export class Config {
return this.subagentManager;
}
getBackgroundTaskRegistry(): BackgroundTaskRegistry {
return this.backgroundTaskRegistry;
}
/**
* Whether interactive permission prompts should be auto-denied.
* True for background agents that have no UI to show prompts.
* PermissionRequest hooks still run and can override the denial.
*/
getShouldAvoidPermissionPrompts(): boolean {
return false;
}
getSkillManager(): SkillManager | null {
return this.skillManager;
}

View file

@ -102,6 +102,8 @@ export enum SendMessageType {
Hook = 'hook',
/** Cron-fired prompt. Behaves like UserQuery but skips UserPromptSubmit hook. */
Cron = 'cron',
/** Background agent notification. Display item is added by the drain loop. */
Notification = 'notification',
}
export interface SendMessageOptions {
@ -111,6 +113,8 @@ export interface SendMessageOptions {
iterationCount: number;
reasons: string[];
};
/** Display text for notification messages (persisted for session resume). */
notificationDisplayText?: string;
/** Model override from skill execution. When present, overrides the session model for this turn. */
modelOverride?: string;
}
@ -598,6 +602,7 @@ export class GeminiClient {
if (
messageType !== SendMessageType.Retry &&
messageType !== SendMessageType.Cron &&
messageType !== SendMessageType.Notification &&
hooksEnabled &&
messageBus &&
this.config.hasHooksForEvent('UserPromptSubmit')
@ -642,13 +647,28 @@ export class GeminiClient {
}
}
if (messageType === SendMessageType.Notification) {
this.config
.getChatRecordingService()
?.recordNotification(request, options?.notificationDisplayText);
}
// Notifications start a fresh Turn with a new prompt_id, so the loop
// detector must reset — otherwise a prior turn's count can trip
// LoopDetected early on the notification turn.
if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron ||
messageType === SendMessageType.Notification
) {
this.loopDetector.reset(prompt_id);
this.lastPromptId = prompt_id;
}
if (
messageType === SendMessageType.UserQuery ||
messageType === SendMessageType.Cron
) {
this.loopDetector.reset(prompt_id);
this.lastPromptId = prompt_id;
if (this.config.getManagedAutoMemoryEnabled()) {
relevantAutoMemoryPromise = this.config
.getMemoryManager()
@ -665,8 +685,14 @@ export class GeminiClient {
});
}
// record user message for session management
this.config.getChatRecordingService()?.recordUserMessage(request);
// record user/cron message for session management
if (messageType === SendMessageType.Cron) {
this.config
.getChatRecordingService()
?.recordCronPrompt(request, options?.notificationDisplayText);
} else {
this.config.getChatRecordingService()?.recordUserMessage(request);
}
// Idle cleanup: clear stale thinking blocks after idle period.
// Latch: once triggered, never revert — prevents oscillation.

View file

@ -610,6 +610,7 @@ export class CoreToolScheduler {
const invocationOrError = this.buildInvocation(
call.tool,
args as Record<string, unknown>,
targetCallId,
);
if (invocationOrError instanceof Error) {
const response = createErrorResponse(
@ -646,9 +647,17 @@ export class CoreToolScheduler {
private buildInvocation(
tool: AnyDeclarativeTool,
args: object,
callId?: string,
): AnyToolInvocation | Error {
try {
return tool.build(structuredClone(args));
const invocation = tool.build(structuredClone(args));
if (callId) {
const maybeAware = invocation as { setCallId?: (id: string) => void };
if (typeof maybeAware.setCallId === 'function') {
maybeAware.setCallId(callId);
}
}
return invocation;
} catch (e) {
if (e instanceof Error) {
return e;
@ -827,6 +836,7 @@ export class CoreToolScheduler {
const invocationOrError = this.buildInvocation(
toolInstance,
reqInfo.args,
reqInfo.callId,
);
if (invocationOrError instanceof Error) {
const error = reqInfo.wasOutputTruncated
@ -1011,12 +1021,12 @@ export class CoreToolScheduler {
/**
* In non-interactive mode, automatically deny.
*/
const shouldAutoDeny =
const isNonInteractiveDeny =
!this.config.isInteractive() &&
!this.config.getExperimentalZedIntegration() &&
this.config.getInputFormat() !== InputFormat.STREAM_JSON;
if (shouldAutoDeny) {
if (isNonInteractiveDeny) {
const errorMessage = `Qwen Code requires permission to use "${reqInfo.name}", but that permission was declined (non-interactive mode cannot prompt for confirmation).`;
this.setStatusInternal(
reqInfo.callId,
@ -1031,6 +1041,8 @@ export class CoreToolScheduler {
}
// Fire PermissionRequest hook before showing the permission dialog.
// Hooks run before the background-agent auto-deny so they can
// override the denial with policy-based decisions.
const messageBus = this.config.getMessageBus() as
| MessageBus
| undefined;
@ -1095,6 +1107,22 @@ export class CoreToolScheduler {
}
}
// Background agents can't show interactive prompts.
// Auto-deny after hooks have had a chance to decide.
if (this.config.getShouldAvoidPermissionPrompts?.()) {
const errorMessage = `Tool "${reqInfo.name}" requires permission, but background agents cannot prompt for confirmation. The tool call was denied.`;
this.setStatusInternal(
reqInfo.callId,
'error',
createErrorResponse(
reqInfo,
new Error(errorMessage),
ToolErrorType.EXECUTION_DENIED,
),
);
continue;
}
// Allow IDE to resolve confirmation
this.openIdeDiffIfEnabled(
confirmationDetails,

View file

@ -57,7 +57,9 @@ export interface ChatRecord {
| 'chat_compression'
| 'slash_command'
| 'ui_telemetry'
| 'at_command';
| 'at_command'
| 'notification'
| 'cron';
/** Working directory at time of message */
cwd: string;
/** CLI version for compatibility tracking */
@ -97,7 +99,12 @@ export interface ChatRecord {
| ChatCompressionRecordPayload
| SlashCommandRecordPayload
| UiTelemetryRecordPayload
| AtCommandRecordPayload;
| AtCommandRecordPayload
| NotificationRecordPayload;
}
export interface NotificationRecordPayload {
displayText: string;
}
/**
@ -294,6 +301,44 @@ export class ChatRecordingService {
}
}
/**
* Records a cron-fired prompt.
* Stored as a user-role message with subtype 'cron' so the UI
* restores it as a notification item instead of a user turn.
*/
recordCronPrompt(message: PartListUnion, displayText?: string): void {
this.recordNotificationLike(message, 'cron', displayText);
}
/**
* Records a background agent notification.
* Stored as a user-role message with subtype 'notification' so the
* UI restores it as an info item, not a user turn.
*/
recordNotification(message: PartListUnion, displayText?: string): void {
this.recordNotificationLike(message, 'notification', displayText);
}
private recordNotificationLike(
message: PartListUnion,
subtype: 'notification' | 'cron',
displayText?: string,
): void {
try {
const record: ChatRecord = {
...this.createBaseRecord('user'),
subtype,
message: createUserContent(message),
systemPayload: displayText
? ({ displayText } as NotificationRecordPayload)
: undefined,
};
this.appendRecord(record);
} catch (error) {
debugLogger.error(`Error saving ${subtype} record:`, error);
}
}
/**
* Records an assistant turn with all available data.
* Writes immediately to disk.

View file

@ -132,6 +132,16 @@ describe('SubagentManager', () => {
runConfig: { max_time_minutes: 5, max_turns: 10 },
};
}
if (yamlString.includes('background:')) {
const bgMatch = yamlString.match(/background:\s*"?(true|false)"?/);
const bgValue = bgMatch?.[1] === 'true' ? true : false;
return {
name: yamlString.match(/name:\s*(\S+)/)?.[1] ?? 'test-agent',
description:
yamlString.match(/description:\s*(.+)/)?.[1] ?? 'A test subagent',
background: bgValue,
};
}
if (yamlString.includes('name: agent1')) {
return { name: 'agent1', description: 'First agent' };
}
@ -486,6 +496,73 @@ You are a helpful assistant.
consoleSpy.mockRestore();
});
it('should parse background: true from frontmatter', () => {
const markdownWithBackground = `---
name: monitor
description: A background monitor
background: true
---
You are a monitor.
`;
const config = manager.parseSubagentContent(
markdownWithBackground,
validConfig.filePath!,
'project',
);
expect(config.background).toBe(true);
});
it('should parse background: "true" string from frontmatter', () => {
const markdownWithBgString = `---
name: monitor
description: A background monitor
background: "true"
---
You are a monitor.
`;
const config = manager.parseSubagentContent(
markdownWithBgString,
validConfig.filePath!,
'project',
);
expect(config.background).toBe(true);
});
it('should not set background when background: false', () => {
const markdownWithBgFalse = `---
name: monitor
description: A foreground agent
background: false
---
You are an agent.
`;
const config = manager.parseSubagentContent(
markdownWithBgFalse,
validConfig.filePath!,
'project',
);
expect(config.background).toBeUndefined();
});
it('should not set background when omitted', () => {
const config = manager.parseSubagentContent(
validMarkdown,
validConfig.filePath!,
'project',
);
expect(config.background).toBeUndefined();
});
});
describe('serializeSubagent', () => {
@ -564,6 +641,37 @@ You are a helpful assistant.
expect(parsed.disallowedTools).toEqual(['write_file', 'mcp__slack']);
});
it('should serialize background: true', () => {
const configWithBackground: SubagentConfig = {
...validConfig,
background: true,
};
const serialized = manager.serializeSubagent(configWithBackground);
expect(serialized).toContain('background: true');
});
it('should not serialize background when undefined', () => {
const serialized = manager.serializeSubagent(validConfig);
expect(serialized).not.toContain('background');
});
it('should roundtrip background through serialize and parse', () => {
const configWithBackground: SubagentConfig = {
...validConfig,
background: true,
};
const serialized = manager.serializeSubagent(configWithBackground);
const parsed = manager.parseSubagentContent(
serialized,
validConfig.filePath!,
'project',
);
expect(parsed.background).toBe(true);
});
});
describe('createSubagent', () => {

View file

@ -606,6 +606,10 @@ export class SubagentManager {
frontmatter['approvalMode'] = config.approvalMode;
}
if (config.background) {
frontmatter['background'] = true;
}
// Serialize to YAML
const yamlContent = stringifyYaml(frontmatter, {
lineWidth: 0, // Disable line wrapping
@ -1087,6 +1091,21 @@ function parseSubagentContent(
? legacyModelConfig['model']
: undefined;
const backgroundRaw = frontmatter['background'];
if (
backgroundRaw !== undefined &&
backgroundRaw !== 'true' &&
backgroundRaw !== 'false' &&
backgroundRaw !== true &&
backgroundRaw !== false
) {
debugLogger.warn(
`Agent file ${filePath} has invalid background value '${backgroundRaw}'. Must be 'true', 'false', or omitted.`,
);
}
const background =
backgroundRaw === 'true' || backgroundRaw === true ? true : undefined;
const config: SubagentConfig = {
name,
description,
@ -1099,6 +1118,7 @@ function parseSubagentContent(
runConfig: runConfig as Partial<RunConfig>,
color,
level,
...(background ? { background } : {}),
};
// Validate the parsed configuration

View file

@ -100,6 +100,13 @@ export interface SubagentConfig {
*/
color?: string;
/**
* When true, this agent always runs as a background task when spawned.
* OR'd with the `run_in_background` tool parameter if either is true,
* the agent runs in the background.
*/
background?: boolean;
/**
* Indicates whether this is a built-in agent.
* Built-in agents cannot be modified or deleted.

View file

@ -1108,7 +1108,7 @@ describe('AgentTool', () => {
.calls[0]?.[0] as string;
expect(startAgentId).toBe(stopAgentId);
expect(startAgentId).toMatch(/^file-search-\d+$/);
expect(startAgentId).toMatch(/^file-search-[0-9a-f]{8}$/);
});
});
@ -1402,6 +1402,171 @@ describe('AgentTool', () => {
expect(snapshots.some((s) => !s.hasPendingConfirmation)).toBe(true);
});
});
describe('Agent-level background: true', () => {
let mockAgent: AgentHeadless;
let mockContextState: ContextState;
let mockRegistry: {
register: ReturnType<typeof vi.fn>;
complete: ReturnType<typeof vi.fn>;
fail: ReturnType<typeof vi.fn>;
};
const bgSubagent: SubagentConfig = {
name: 'monitor',
description: 'Background monitor agent',
systemPrompt: 'You are a monitor.',
level: 'project',
filePath: '/project/.qwen/agents/monitor.md',
background: true,
};
beforeEach(() => {
mockAgent = {
execute: vi.fn().mockResolvedValue(undefined),
getFinalText: vi.fn().mockReturnValue('Monitor done'),
getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL),
getExecutionSummary: vi.fn().mockReturnValue({}),
} as unknown as AgentHeadless;
mockContextState = { set: vi.fn() } as unknown as ContextState;
MockedContextState.mockImplementation(() => mockContextState);
mockRegistry = {
register: vi.fn(),
complete: vi.fn(),
fail: vi.fn(),
};
vi.mocked(config.getApprovalMode).mockReturnValue(ApprovalMode.DEFAULT);
(config as unknown as Record<string, unknown>)['isInteractive'] = vi
.fn()
.mockReturnValue(true);
(config as unknown as Record<string, unknown>)[
'getBackgroundTaskRegistry'
] = vi.fn().mockReturnValue(mockRegistry);
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(bgSubagent);
vi.mocked(mockSubagentManager.createAgentHeadless).mockResolvedValue(
mockAgent,
);
});
it('should run in background when agent definition has background: true', async () => {
const params: AgentParams = {
description: 'Start monitor',
prompt: 'Watch for changes',
subagent_type: 'monitor',
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
expect(llmText).toContain('Background agent launched');
expect(mockRegistry.register).toHaveBeenCalledWith(
expect.objectContaining({
description: 'Start monitor',
subagentType: 'monitor',
status: 'running',
}),
);
const display = result.returnDisplay as AgentResultDisplay;
expect(display.status).toBe('background');
});
it('should run in background when run_in_background is true even without background config', async () => {
const fgSubagent: SubagentConfig = {
...bgSubagent,
name: 'file-search',
background: undefined,
};
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(fgSubagent);
const params: AgentParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
run_in_background: true,
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
expect(llmText).toContain('Background agent launched');
expect(mockRegistry.register).toHaveBeenCalled();
});
it('should run in foreground when neither flag is set', async () => {
const fgSubagent: SubagentConfig = {
...bgSubagent,
name: 'file-search',
background: undefined,
};
vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue(fgSubagent);
const params: AgentParams = {
description: 'Search files',
prompt: 'Find all TypeScript files',
subagent_type: 'file-search',
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
expect(llmText).not.toContain('Background agent launched');
expect(mockRegistry.register).not.toHaveBeenCalled();
});
it('should allow background in non-interactive mode (headless support)', async () => {
vi.mocked(
config.isInteractive as ReturnType<typeof vi.fn>,
).mockReturnValue(false);
const params: AgentParams = {
description: 'Start monitor',
prompt: 'Watch for changes',
subagent_type: 'monitor',
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
const result = await invocation.execute();
const llmText = partToString(result.llmContent);
expect(llmText).toContain('Background agent launched');
expect(mockRegistry.register).toHaveBeenCalled();
});
it('forwards the scheduler-provided callId as toolUseId on the registry entry', async () => {
const params: AgentParams = {
description: 'Start monitor',
prompt: 'Watch for changes',
subagent_type: 'monitor',
};
const invocation = (
agentTool as AgentToolWithProtectedMethods
).createInvocation(params);
(invocation as unknown as { setCallId: (id: string) => void }).setCallId(
'call-xyz-789',
);
await invocation.execute();
expect(mockRegistry.register).toHaveBeenCalledWith(
expect.objectContaining({ toolUseId: 'call-xyz-789' }),
);
});
});
});
describe('resolveSubagentApprovalMode', () => {

View file

@ -4,6 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { BaseDeclarativeTool, BaseToolInvocation, Kind } from '../tools.js';
import { ToolNames, ToolDisplayNames } from '../tool-names.js';
import type {
@ -59,6 +60,7 @@ export interface AgentParams {
description: string;
prompt: string;
subagent_type?: string;
run_in_background?: boolean;
}
const debugLogger = createDebugLogger('AGENT');
@ -185,6 +187,11 @@ export class AgentTool extends BaseDeclarativeTool<AgentParams, ToolResult> {
type: 'string',
description: 'The type of specialized agent to use for this task',
},
run_in_background: {
type: 'boolean',
description:
'Set to true to run this agent in the background. You will be notified when it completes.',
},
},
required: ['description', 'prompt'],
additionalProperties: false,
@ -273,6 +280,7 @@ Usage notes:
- Clearly tell the agent whether you expect it to write code or just to do research (search, file reads, web fetches, etc.), since it is not aware of the user's intent
- If the agent description mentions that it should be used proactively, then you should try your best to use it without the user having to ask for it first. Use your judgement.
- If the user specifies that they want you to run agents "in parallel", you MUST send a single message with multiple Agent tool use content blocks. For example, if you need to launch both a build-validator agent and a test-runner agent in parallel, send a single message with both tool calls.
- You can optionally set \`run_in_background: true\` to run the agent in the background. You will be notified when it completes. Use this when you have genuinely independent work to do in parallel and don't need the agent's results before you can proceed.
Example usage:
@ -384,6 +392,7 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
readonly eventEmitter: AgentEventEmitter = new AgentEventEmitter();
private currentDisplay: AgentResultDisplay | null = null;
private currentToolCalls: AgentResultDisplay['toolCalls'] = [];
private callId?: string;
constructor(
private readonly config: Config,
@ -393,6 +402,11 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
super(params);
}
// Background agents carry the tool-use id through to completion notifications.
setCallId(callId: string): void {
this.callId = callId;
}
/**
* Updates the current display state and calls updateOutput if provided
*/
@ -653,10 +667,12 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
const generationConfig = geminiClient?.getChat().getGenerationConfig();
if (generationConfig?.systemInstruction) {
// Inline FunctionDeclaration[] from the parent — passed verbatim
// including `agent` itself so the fork's tool-name set matches the
// parent's. prepareTools bypasses the exclusion filter for inline
// decls; `isInForkExecution()` (ALS-based) is the sole
// recursive-fork block at runtime.
// (including `agent` and cron tools) so the fork's system prompt,
// tools, and history exactly match the parent's and share its
// DashScope cache prefix. A fork is a context-sharing extension of
// the parent, not an isolated subagent, so the general subagent
// exclusion list does not apply. Recursive forks are blocked by the
// ALS-based `isInForkExecution()` guard.
const parentToolDecls: FunctionDeclaration[] =
(
generationConfig.tools as Array<{
@ -695,6 +711,69 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
return { subagent, taskPrompt };
}
// Runs the SubagentStop hook after execution. On a blocking decision, feeds the
// reason back and re-executes — up to 5 iterations to defend against a
// misconfigured hook looping forever.
private async runSubagentStopHookLoop(
subagent: AgentHeadless,
opts: {
agentId: string;
agentType: string;
resolvedMode: PermissionMode;
signal?: AbortSignal;
},
): Promise<void> {
const { agentId, agentType, resolvedMode, signal } = opts;
const hookSystem = this.config.getHookSystem();
if (!hookSystem) return;
const transcriptPath = this.config.getTranscriptPath();
let stopHookActive = false;
const maxIterations = 5;
for (let i = 0; i < maxIterations; i++) {
try {
const stopHookOutput = await hookSystem.fireSubagentStopEvent(
agentId,
agentType,
transcriptPath,
subagent.getFinalText(),
stopHookActive,
resolvedMode,
signal,
);
const typedStopOutput = stopHookOutput as StopHookOutput | undefined;
if (
!typedStopOutput?.isBlockingDecision() &&
!typedStopOutput?.shouldStopExecution()
) {
return;
}
stopHookActive = true;
const continueContext = new ContextState();
continueContext.set(
'task_prompt',
typedStopOutput.getEffectiveReason(),
);
await subagent.execute(continueContext, signal);
if (signal?.aborted) return;
} catch (hookError) {
debugLogger.warn(
`[Agent] SubagentStop hook failed, allowing stop: ${hookError}`,
);
return;
}
}
debugLogger.warn(
`[Agent] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop`,
);
}
/**
* Runs a subagent with start/stop hook lifecycle, updating the display
* as execution progresses.
@ -738,69 +817,13 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
// Execute the subagent (blocking)
await subagent.execute(contextState, signal);
// Fire SubagentStop hook after execution and handle block decisions
if (hookSystem && !signal?.aborted) {
const transcriptPath = this.config.getTranscriptPath();
let stopHookActive = false;
// Loop to handle "block" decisions (prevent subagent from stopping)
let continueExecution = true;
let iterationCount = 0;
const maxIterations = 5; // Prevent infinite loops from hook misconfigurations
while (continueExecution) {
iterationCount++;
// Safety check to prevent infinite loops
if (iterationCount >= maxIterations) {
debugLogger.warn(
`[TaskTool] SubagentStop hook reached maximum iterations (${maxIterations}), forcing stop to prevent infinite loop`,
);
continueExecution = false;
break;
}
try {
const stopHookOutput = await hookSystem.fireSubagentStopEvent(
agentId,
agentType,
transcriptPath,
subagent.getFinalText(),
stopHookActive,
resolvedMode,
signal,
);
const typedStopOutput = stopHookOutput as
| StopHookOutput
| undefined;
if (
typedStopOutput?.isBlockingDecision() ||
typedStopOutput?.shouldStopExecution()
) {
// Feed the reason back to the subagent and continue execution
const continueReason = typedStopOutput.getEffectiveReason();
stopHookActive = true;
const continueContext = new ContextState();
continueContext.set('task_prompt', continueReason);
await subagent.execute(continueContext, signal);
if (signal?.aborted) {
continueExecution = false;
}
// Loop continues to re-check SubagentStop hook
} else {
continueExecution = false;
}
} catch (hookError) {
debugLogger.warn(
`[TaskTool] SubagentStop hook failed, allowing stop: ${hookError}`,
);
continueExecution = false;
}
}
await this.runSubagentStopHookLoop(subagent, {
agentId,
agentType,
resolvedMode,
signal,
});
}
// Get the results
@ -941,14 +964,141 @@ class AgentToolInvocation extends BaseToolInvocation<AgentParams, ToolResult> {
const contextState = new ContextState();
contextState.set('task_prompt', taskPrompt);
// Date.now() alone collides when two parallel background agents of the
// same type land in the same ms; the registry is keyed by agentId.
const agentIdSuffix = this.callId ?? randomUUID().slice(0, 8);
const hookOpts = {
agentId: `${subagentConfig.name}-${Date.now()}`,
agentId: `${subagentConfig.name}-${agentIdSuffix}`,
agentType: this.params.subagent_type || subagentConfig.name,
resolvedMode,
signal,
updateOutput,
};
// ── Background (async) execution path ──────────────────────
// OR the tool parameter with the agent definition's background flag.
const shouldRunInBackground =
this.params.run_in_background === true ||
subagentConfig.background === true;
if (shouldRunInBackground) {
// Fire SubagentStart hook before background launch
const hookSystem = this.config.getHookSystem();
if (hookSystem) {
try {
const startHookOutput = await hookSystem.fireSubagentStartEvent(
hookOpts.agentId,
hookOpts.agentType,
resolvedMode,
signal,
);
const additionalContext = startHookOutput?.getAdditionalContext();
if (additionalContext) {
contextState.set('hook_context', additionalContext);
}
} catch (hookError) {
debugLogger.warn(
`[Agent] SubagentStart hook failed, continuing execution: ${hookError}`,
);
}
}
// Create an independent AbortController — background agents
// survive ESC cancellation of the parent's current turn.
const bgAbortController = new AbortController();
// Background agents have no UI, so interactive permission prompts must be
// auto-denied rather than auto-approved (YOLO). PermissionRequest hooks
// still run and can override. Use Object.create so the resolved approval
// mode override (e.g. subagent-level `approvalMode: auto-edit`) is preserved.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bgConfig = Object.create(agentConfig) as any;
bgConfig.getShouldAvoidPermissionPrompts = () => true;
// Register in the background task registry only AFTER init succeeds — if
// construction throws, a pre-registered phantom 'running' entry would hang
// the non-interactive hold-back loop forever.
let bgSubagent: AgentHeadless;
if (isFork) {
const fork = await this.createForkSubagent(bgConfig as Config);
bgSubagent = fork.subagent;
} else {
bgSubagent = await this.subagentManager.createAgentHeadless(
subagentConfig,
bgConfig as Config,
);
}
const registry = this.config.getBackgroundTaskRegistry();
registry.register({
agentId: hookOpts.agentId,
description: this.params.description,
subagentType: subagentConfig.name,
status: 'running',
startTime: Date.now(),
abortController: bgAbortController,
toolUseId: this.callId,
});
const getCompletionStats = () => {
const summary = bgSubagent.getExecutionSummary();
return {
totalTokens: summary.totalTokens,
toolUses: summary.totalToolCalls,
durationMs: summary.totalDurationMs,
};
};
// Fire-and-forget: start the subagent without blocking the parent.
// For forks, wrap the body in runInForkContext so the recursive-fork
// guard in execute() fires if the fork child's model calls `agent`
// again — otherwise background forks bypass the ALS marker and can
// spawn nested implicit forks.
const bgBody = async () => {
try {
await bgSubagent.execute(contextState, bgAbortController.signal);
if (hookSystem && !bgAbortController.signal.aborted) {
await this.runSubagentStopHookLoop(bgSubagent, {
agentId: hookOpts.agentId,
agentType: hookOpts.agentType,
resolvedMode,
signal: bgAbortController.signal,
});
}
// Report terminate mode: only GOAL counts as success. ERROR,
// MAX_TURNS, and TIMEOUT are surfaced as failures so the parent
// model (and the UI) don't treat incomplete runs as completed.
const terminateMode = bgSubagent.getTerminateMode();
const finalText = bgSubagent.getFinalText();
const completionStats = getCompletionStats();
if (terminateMode === AgentTerminateMode.GOAL) {
registry.complete(hookOpts.agentId, finalText, completionStats);
} else {
registry.fail(
hookOpts.agentId,
finalText || `Agent terminated with mode: ${terminateMode}`,
completionStats,
);
}
} catch (error) {
const errorMsg =
error instanceof Error ? error.message : String(error);
debugLogger.error(`[Agent] Background agent failed: ${errorMsg}`);
registry.fail(hookOpts.agentId, errorMsg, getCompletionStats());
}
};
void (isFork ? runInForkContext(bgBody) : bgBody());
this.updateDisplay({ status: 'background' as const }, updateOutput);
return {
llmContent: `Background agent launched: "${this.params.description}" (ID: ${hookOpts.agentId}). You will be notified when it completes.`,
returnDisplay: this.currentDisplay!,
};
}
if (isFork) {
// Background fork execution. Run under an AsyncLocalStorage frame so
// nested `agent` tool calls by the fork's model can be detected.

View file

@ -495,7 +495,7 @@ export interface AgentResultDisplay {
subagentColor?: string;
taskDescription: string;
taskPrompt: string;
status: 'running' | 'completed' | 'failed' | 'cancelled';
status: 'running' | 'completed' | 'failed' | 'cancelled' | 'background';
terminateReason?: string;
result?: string;
executionSummary?: AgentStatsSummary;