Skip to content
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ca.js
Original file line number Diff line number Diff line change
Expand Up @@ -1593,6 +1593,7 @@ export default {
'rebutjat — editeu la configuració per tornar a aprovar',
'Background agent needs approval': "L'agent en segon pla necessita aprovació",
'Approve or deny the request above': 'Aprova o denega la sol·licitud de dalt',
'from nested agent': "de l'agent imbricat",
Running: 'En execució',
Pausing: 'Pausant',
Paused: 'En pausa',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/de.js
Original file line number Diff line number Diff line change
Expand Up @@ -1530,6 +1530,7 @@ export default {
'rejected — edit config to re-approve':
'abgelehnt — Konfiguration bearbeiten, um erneut zu genehmigen',
'Background agent needs approval': 'Hintergrund-Agent wartet auf Genehmigung',
'from nested agent': 'von verschachteltem Agent',
'Approve or deny the request above':
'Genehmigen oder lehnen Sie die obige Anfrage ab',
Running: 'Läuft',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/en.js
Original file line number Diff line number Diff line change
Expand Up @@ -2049,6 +2049,7 @@ export default {
'rejected — edit config to re-approve',
'Background agent needs approval': 'Background agent needs approval',
'Approve or deny the request above': 'Approve or deny the request above',
'from nested agent': 'from nested agent',
Running: 'Running',
Pausing: 'Pausing',
Paused: 'Paused',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/fr.js
Original file line number Diff line number Diff line change
Expand Up @@ -1601,6 +1601,7 @@ export default {
'rejeté — modifiez la configuration pour réapprouver',
'Background agent needs approval':
"L'agent en arrière-plan nécessite une approbation",
'from nested agent': "de l'agent imbriqué",
'Approve or deny the request above':
'Approuvez ou refusez la demande ci-dessus',
Running: 'En cours',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ja.js
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,7 @@ export default {
'rejected — edit config to re-approve': '拒否済み — 設定を編集して再承認',
'Background agent needs approval':
'バックグラウンドエージェントが承認待ちです',
'from nested agent': 'ネストされた agent から',
'Approve or deny the request above':
'上のリクエストを承認または拒否してください',
Running: '実行中',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/pt.js
Original file line number Diff line number Diff line change
Expand Up @@ -1563,6 +1563,7 @@ export default {
'rejeitado — edite a configuração para reaprovar',
'Background agent needs approval':
'Agente em segundo plano precisa de aprovação',
'from nested agent': 'do agent aninhado',
'Approve or deny the request above': 'Aprove ou negue a solicitação acima',
Running: 'Em execução',
Pausing: 'Pausando',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/ru.js
Original file line number Diff line number Diff line change
Expand Up @@ -1480,6 +1480,7 @@ export default {
'отклонено — измените конфигурацию для повторного подтверждения',
'Background agent needs approval': 'Фоновый агент требует подтверждения',
'Approve or deny the request above': 'Подтвердите или отклоните запрос выше',
'from nested agent': 'от вложенного агента',
Running: 'Выполняется',
Pausing: 'Приостанавливается',
Paused: 'Приостановлено',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh-TW.js
Original file line number Diff line number Diff line change
Expand Up @@ -2128,6 +2128,7 @@ export default {
'rejected — edit config to re-approve': '已拒絕 — 編輯設定以重新審批',
'Background agent needs approval': '背景 agent 等待審批',
'Approve or deny the request above': '請核准或拒絕上方的請求',
'from nested agent': '來自嵌套 agent',
Running: '執行中',
Pausing: '暫停中',
Paused: '已暫停',
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/i18n/locales/zh.js
Original file line number Diff line number Diff line change
Expand Up @@ -2324,6 +2324,7 @@ export default {
'rejected — edit config to re-approve': '已拒绝 — 编辑配置以重新审批',
'Background agent needs approval': '后台 agent 等待审批',
'Approve or deny the request above': '请批准或拒绝上方的请求',
'from nested agent': '来自嵌套 agent',
Running: '运行中',
Pausing: '暂停中',
Paused: '已暂停',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -871,6 +871,75 @@ describe('BackgroundTasksDialog', () => {
expect(h.probe.current!.state.dialogOpen).toBe(true);
});

it('names the nested agent a bridged approval came from', () => {
// Approvals bridged from a NESTED agent onto a backgrounded ancestor's
// entry are stamped with the nested runtime id (subagentId); the detail
// view must name it so the user can tell which descendant is blocked.
const bridgedApproval: NonNullable<
AgentDialogEntry['pendingApprovals']
>[number] = {
callId: 'nested-1',
name: 'Shell',
description: 'run nested-1',
confirmationDetails: {
type: 'exec',
title: 'Allow execution',
command: 'echo nested',
rootCommand: 'echo',
} as NonNullable<
AgentDialogEntry['pendingApprovals']
>[number]['confirmationDetails'],
respond: vi.fn(),
at: Date.now(),
subagentId: 'review-agent-abc123',
};
const bg = entry({
agentId: 'bg-bridged',
pendingApprovals: [bridgedApproval],
});
const h = setup([bg]);

h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());

expect(h.probe.current!.state.dialogMode).toBe('detail');
expect(h.lastFrame()).toContain('from nested agent: review-agent-abc123');
});

it('omits the nested-agent line for an unstamped approval', () => {
// The entry's OWN parked approvals carry no subagentId — the line must
// not render for them.
const ownApproval: NonNullable<
AgentDialogEntry['pendingApprovals']
>[number] = {
callId: 'own-1',
name: 'Shell',
description: 'run own-1',
confirmationDetails: {
type: 'exec',
title: 'Allow execution',
command: 'echo own',
rootCommand: 'echo',
} as NonNullable<
AgentDialogEntry['pendingApprovals']
>[number]['confirmationDetails'],
respond: vi.fn(),
at: Date.now(),
};
const bg = entry({
agentId: 'bg-own',
pendingApprovals: [ownApproval],
});
const h = setup([bg]);

h.call(() => h.probe.current!.actions.openDialog());
h.call(() => h.probe.current!.actions.enterDetail());

expect(h.probe.current!.state.dialogMode).toBe('detail');
expect(h.lastFrame()).toContain('Background agent needs approval');
expect(h.lastFrame()).not.toContain('from nested agent');
});

it('Esc backs out of an armed foreground cancel without closing the dialog', () => {
const fg = entry({
agentId: 'fg-1',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1947,6 +1947,17 @@ export const BackgroundTasksDialog: React.FC<BackgroundTasksDialogProps> = ({
? `[workflow] ${t('needs approval')}`
: t('Background agent needs approval')}
</Text>
{/* subagentId is set only on approvals bridged from a NESTED
agent onto this entry (see AgentTool's nested approval
bridge). Name the actual waiter so the user knows which of
the descendants is blocked. */}
{selectedApproval?.kind === 'agent' &&
selectedApproval.approval.subagentId !== undefined && (
<Text color={theme.text.secondary}>
{t('from nested agent')}:{' '}
{selectedApproval.approval.subagentId}
</Text>
)}
<ToolConfirmationMessage
confirmationDetails={approvalConfirmationDetails}
config={config}
Expand Down
183 changes: 183 additions & 0 deletions packages/core/src/agents/background-agent-resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ describe('BackgroundAgentResumeService', () => {
name: 'researcher',
color: 'cyan',
model: undefined as string | undefined,
approvalMode: undefined as string | undefined,
}
: null,
),
Expand Down Expand Up @@ -693,6 +694,187 @@ describe('BackgroundAgentResumeService', () => {
expect(subagentManager.loadSubagent).toHaveBeenCalledWith('researcher');
});

it('stamps the resumed prompt-avoidance policy where nested launches inherit it', async () => {
// The policy must sit on the config the rebuilt tool registry binds to
// (the createToolRegistry receiver) — not on a wrapper above it — so a
// nested AgentTool launched by the resumed agent inherits it through its
// own config prototype chain. Mirrors the launch path in agent.ts.
const sessionId = 'session-policy';
const agentId = 'agent-policy';
const metaPath = getAgentMetaPath(tempDir, sessionId, agentId);
const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId);

writeAgentMeta(metaPath, {
agentId,
agentType: 'researcher',
description: 'Resume policy stamp',
parentSessionId: sessionId,
parentAgentId: null,
createdAt: '2026-04-20T00:00:00.000Z',
status: 'running',
subagentName: 'researcher',
resolvedApprovalMode: 'auto-edit',
});
fs.writeFileSync(
outputFile,
JSON.stringify({
uuid: 'u1',
parentUuid: null,
sessionId,
timestamp: '2026-04-20T00:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'Resume policy stamp' }] },
}) + '\n',
'utf8',
);

registry.register({
agentId,
description: 'Resume policy stamp',
subagentType: 'researcher',
isBackgrounded: true,
status: 'paused',
startTime: Date.now(),
abortController: new AbortController(),
prompt: 'Resume policy stamp',
outputFile,
metaPath,
});

const subagent = {
execute: vi.fn(async () => {}),
setExternalMessageProvider: vi.fn(),
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
getFinalText: () => 'done',
};

const { service, subagentManager } = createService();
subagentManager.createAgentHeadless.mockResolvedValue({
subagent,
dispose: vi.fn().mockResolvedValue(undefined),
});

const resumed = await service.resumeBackgroundAgent(agentId, 'continue');
expect(resumed).toBeDefined();

const createCall = subagentManager.createAgentHeadless.mock.calls.at(-1);
expect(createCall).toBeDefined();
const bgConfig = createCall![1] as Config;
const contexts = vi.mocked(bgConfig.createToolRegistry).mock.contexts;
// The runtime config IS the createToolRegistry receiver — the config
// whose rebuilt registry a nested AgentTool binds to. A wrapper-only
// stamp would leave these two distinct objects.
expect(contexts[contexts.length - 1]).toBe(bgConfig);
// Non-interactive harness → auto-deny, visible on the tool-bound config
// itself and through any derived config's prototype chain.
expect(bgConfig.getShouldAvoidPermissionPrompts()).toBe(true);
expect(
(Object.create(bgConfig) as Config).getShouldAvoidPermissionPrompts(),
).toBe(true);

await vi.waitFor(() => {
expect(registry.get(agentId)?.status).toBe('completed');
});
});

it('keeps prompts allowed through the chain for a resumed bubble-mode agent in an interactive session', async () => {
// Mirror of the launch-path bubble test (agent.ts): a resumed agent of
// a `bubble` definition in an INTERACTIVE session surfaces
// confirmations to the Background-tasks dialog instead of auto-denying
// them, and nested launches under it must inherit the same policy
// through their config prototype chains.
const sessionId = 'session-bubble';
const agentId = 'agent-bubble';
const metaPath = getAgentMetaPath(tempDir, sessionId, agentId);
const outputFile = getAgentJsonlPath(tempDir, sessionId, agentId);

writeAgentMeta(metaPath, {
agentId,
agentType: 'researcher',
description: 'Resume bubble policy',
parentSessionId: sessionId,
parentAgentId: null,
createdAt: '2026-04-20T00:00:00.000Z',
status: 'running',
subagentName: 'researcher',
resolvedApprovalMode: 'auto-edit',
});
fs.writeFileSync(
outputFile,
JSON.stringify({
uuid: 'u1',
parentUuid: null,
sessionId,
timestamp: '2026-04-20T00:00:00.000Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'Resume bubble policy' }] },
}) + '\n',
'utf8',
);

registry.register({
agentId,
description: 'Resume bubble policy',
subagentType: 'researcher',
isBackgrounded: true,
status: 'paused',
startTime: Date.now(),
abortController: new AbortController(),
prompt: 'Resume bubble policy',
outputFile,
metaPath,
});

const subagent = {
execute: vi.fn(async () => {}),
setExternalMessageProvider: vi.fn(),
getCore: () => ({ getEventEmitter: () => new AgentEventEmitter() }),
getExecutionSummary: () => ({
totalTokens: 0,
outputTokens: 0,
totalDurationMs: 0,
}),
getTerminateMode: () => AgentTerminateMode.GOAL,
getFinalText: () => 'done',
};

const { service, subagentManager, config } = createService();
config.isInteractive = () => true;
subagentManager.loadSubagent.mockResolvedValue({
name: 'researcher',
color: 'cyan',
model: undefined,
approvalMode: 'bubble',
});
subagentManager.createAgentHeadless.mockResolvedValue({
subagent,
dispose: vi.fn().mockResolvedValue(undefined),
});

const resumed = await service.resumeBackgroundAgent(agentId, 'continue');
expect(resumed).toBeDefined();

const createCall = subagentManager.createAgentHeadless.mock.calls.at(-1);
expect(createCall).toBeDefined();
const bgConfig = createCall![1] as Config;
// Bubbling: prompts stay allowed (they park on the entry), and nested
// launches inherit the same policy through the prototype chain.
expect(bgConfig.getShouldAvoidPermissionPrompts()).toBe(false);
expect(
(Object.create(bgConfig) as Config).getShouldAvoidPermissionPrompts(),
).toBe(false);

await vi.waitFor(() => {
expect(registry.get(agentId)?.status).toBe('completed');
});
});

it('fires SubagentStart hooks when resuming and injects hook context', async () => {
const sessionId = 'session-resume';
const agentId = 'agent-resume';
Expand Down Expand Up @@ -1516,6 +1698,7 @@ describe('BackgroundAgentResumeService', () => {
name: 'researcher',
color: 'cyan',
model: 'configured-model',
approvalMode: undefined,
});
subagentManager.createAgentHeadless = createAgentHeadless;

Expand Down
11 changes: 9 additions & 2 deletions packages/core/src/agents/background-agent-resume.ts
Original file line number Diff line number Diff line change
Expand Up @@ -922,8 +922,15 @@ export class BackgroundAgentResumeService {
target.subagentConfig?.approvalMode === BUBBLE_APPROVAL_MODE &&
this.config.isInteractive(),
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const bgConfig = Object.create(activeAgentConfig) as any;
// Stamp the policy on activeAgentConfig itself — the config whose
// rebuilt tool registry a nested AgentTool binds to — so nested
// launches under the resumed agent inherit it through their own
// config prototype chains. Mirrors the launch path (agent.ts); a
// wrapper-only stamp left nested schedulers resolving
// Config.prototype's `false` and hanging on unanswerable approvals.
// activeAgentConfig is created per-resume by
// createApprovalModeOverride, so the stamp leaks nowhere.
const bgConfig = activeAgentConfig;
bgConfig.getShouldAvoidPermissionPrompts = () => !shouldBubble;

const records = await jsonl.read<ChatRecord>(outputFile);
Expand Down
Loading
Loading