Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions packages/cli/src/ui/hooks/useGeminiStream.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
SendMessageType,
ToolErrorType,
ToolConfirmationOutcome,
getRuntimeContentGenerator,
runWithRuntimeContentGenerator,
} from '@qwen-code/qwen-code-core';
import type { Part, PartListUnion } from '@google/genai';
import type { UseHistoryManagerReturn } from './useHistoryManager.js';
Expand Down Expand Up @@ -6435,6 +6437,50 @@ describe('useGeminiStream', () => {
mockSendMessageStream.mock.calls[0][3].modelOverride,
).toBeUndefined();
});

// Regression for #7156: progress setState calls issued from inside a
// background subagent's AsyncLocalStorage frame can batch with the
// notification trigger into one React commit, so the drain effect
// executes on a stack that still carries the subagent's frame. The
// drained turn — and every async continuation it starts — then
// resolves Config.getModel() to the subagent's runtime view and the
// main session switches onto the subagent's model. The drain effect
// must therefore run via runOutsideAgentContext. This test drives the
// notification callback from inside an agent frame (bypassing the
// producer-side guard in BackgroundTaskRegistry.emitNotification) and
// fails if the consumer-side wrapping is removed.
it('drains a notification outside a background agent ALS frame', async () => {
renderTestHook();
const callback = mockBackgroundShellRegistry.setNotificationCallback
.mock.calls[0][0] as (displayText: string, modelText: string) => void;

let capturedRuntimeView: unknown = 'unset';
mockSendMessageStream.mockImplementationOnce(() => {
capturedRuntimeView = getRuntimeContentGenerator();
return (async function* () {})();
});

const subagentView = {
contentGenerator: {},
contentGeneratorConfig: { model: 'small-default' },
} as never;
// The whole act() flush runs inside the agent frame, mirroring the
// contaminated React commit from the issue.
await runWithRuntimeContentGenerator(subagentView, async () => {
await act(async () => {
callback(
'Background shell "npm test" completed.',
'<task-notification>completed</task-notification>',
);
});
});

await waitFor(() => expect(mockSendMessageStream).toHaveBeenCalled());
expect(mockSendMessageStream.mock.calls[0][3]).toMatchObject({
type: SendMessageType.Notification,
});
expect(capturedRuntimeView).toBeUndefined();
});
});
});

Expand Down
87 changes: 51 additions & 36 deletions packages/cli/src/ui/hooks/useGeminiStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
useLayoutEffect,
} from 'react';
import {
runOutsideAgentContext,
type Config,
type EditorType,
type GeminiClient,
Expand Down Expand Up @@ -3760,45 +3761,59 @@ export const useGeminiStream = (
!isSubmittingQueryRef.current &&
notificationQueueRef.current.length > 0
) {
const queue = notificationQueueRef.current;
const targetType = queue[0]!.sendMessageType;

// Cron prompts must run as individual turns — each needs its own
// slash/shell/@ preprocessing and approval cycle. Only batch
// Notification items (which pass through without preprocessing).
if (targetType === SendMessageType.Cron) {
const item = queue.shift()!;
addItem(
{ type: 'notification' as const, text: item.displayText },
Date.now(),
);
submitQuery(item.modelText, item.sendMessageType, undefined, {
notificationDisplayText: item.displayText,
onDelivered: item.onDelivered,
onDeliveryFailed: item.onDeliveryFailed,
});
return;
}
// Consumer-side guard for #7156: this effect can run on a render pass
// that React batched together with progress setState calls issued from
// INSIDE a subagent's AsyncLocalStorage frame, in which case the whole
// synchronous effect stack — and every async continuation submitQuery
// starts — inherits the subagent's runtime view, and the notification
// turn resolves Config.getModel() to the SUBAGENT's model. Exiting the
// frame here guarantees the drained turn always runs on the main
// session's configuration, regardless of which producer's setState
// triggered the commit.
runOutsideAgentContext(() => {
const queue = notificationQueueRef.current;
const targetType = queue[0]!.sendMessageType;
Comment on lines +3773 to +3775

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 consumer-side runOutsideAgentContext guard — described in the PR as the "decisive" fix for #7156 — has no targeted unit regression test. The existing notification test in useGeminiStream.test.tsx (line 6388, regression for #7114) invokes the drain without any agent ALS frame, so storage.exit(fn) is a transparent no-op in that test. Removing this wrapping would not change that test's outcome.

Failure scenario: a future refactor that removes or restructures the runOutsideAgentContext wrapping around the drain effect would silently re-introduce the model leak — notification turns resolve to the subagent's model, causing 400 errors on smaller-context models. No automated test would catch this regression.

The producer-side guard in background-tasks.ts has a well-constructed regression test that proves the mechanism. A similar test here — invoking the drain from inside runWithAgentContext/runWithRuntimeContentGenerator and asserting the drained submitQuery uses the main session's model — would close the gap.

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Added in 5a1a6aa: drains a notification outside a background agent ALS frame (useGeminiStream.test.tsx). It drives the notification callback with the whole act() flush inside runWithRuntimeContentGenerator — mirroring the contaminated React commit — bypassing the producer-side guard, and asserts the drained sendMessageStream call observes no runtime view. Verified it fails when the runOutsideAgentContext wrapping around the drain effect is removed (captured view = the subagent's). The branch is also rebased onto latest main: the earlier Test-job failure was the NOTICES.txt drift guard from #7161 tripping on this branch's pre-#7161 base, unrelated to the changed files.

中文:已在 5a1a6aa 补上该回归测试——整个 act() flush 在 runWithRuntimeContentGenerator 内执行以复刻被污染的 React commit(绕过生产端防线),断言汇入的 sendMessageStream 观察不到 runtime view;移除 drain effect 的 runOutsideAgentContext 包裹后该测试确实失败。分支已 rebase 到最新 main:此前 Test job 失败是 #7161 引入的 NOTICES.txt 漂移守卫在旧基线上触发,与本 PR 改动无关。


// Cron prompts must run as individual turns — each needs its own
// slash/shell/@ preprocessing and approval cycle. Only batch
// Notification items (which pass through without preprocessing).
if (targetType === SendMessageType.Cron) {
const item = queue.shift()!;
addItem(
{ type: 'notification' as const, text: item.displayText },
Date.now(),
);
submitQuery(item.modelText, item.sendMessageType, undefined, {
notificationDisplayText: item.displayText,
onDelivered: item.onDelivered,
onDeliveryFailed: item.onDeliveryFailed,
});
return;
}

// Drain contiguous leading Notification items into one batch.
let splitIdx = 0;
while (
splitIdx < queue.length &&
queue[splitIdx]!.sendMessageType === targetType
) {
splitIdx++;
}
const batch = queue.splice(0, splitIdx);
// Drain contiguous leading Notification items into one batch.
let splitIdx = 0;
while (
splitIdx < queue.length &&
queue[splitIdx]!.sendMessageType === targetType
) {
splitIdx++;
}
const batch = queue.splice(0, splitIdx);

const now = Date.now();
for (const item of batch) {
addItem({ type: 'notification' as const, text: item.displayText }, now);
}
const now = Date.now();
for (const item of batch) {
addItem(
{ type: 'notification' as const, text: item.displayText },
now,
);
}

const combinedModelText = batch.map((e) => e.modelText).join('\n\n');
const combinedDisplayText = batch.map((e) => e.displayText).join('; ');
submitQuery(combinedModelText, targetType, undefined, {
notificationDisplayText: combinedDisplayText,
const combinedModelText = batch.map((e) => e.modelText).join('\n\n');
const combinedDisplayText = batch.map((e) => e.displayText).join('; ');
submitQuery(combinedModelText, targetType, undefined, {
notificationDisplayText: combinedDisplayText,
});
});
}
}, [streamingState, submitQuery, notificationTrigger, addItem]);
Expand Down
56 changes: 56 additions & 0 deletions packages/core/src/agents/background-tasks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ import {
type BackgroundApproval,
type BackgroundTaskEntry,
} from './background-tasks.js';
import {
getCurrentAgentId,
getRuntimeContentGenerator,
runWithAgentContext,
runWithRuntimeContentGenerator,
} from './runtime/agent-context.js';
import * as transcript from './agent-transcript.js';
import { AgentEventEmitter, AgentEventType } from './runtime/agent-events.js';
import { ToolConfirmationOutcome } from '../tools/tools.js';
Expand Down Expand Up @@ -52,6 +58,56 @@ function makeRegistration(
};
}

describe('notification emission and agent context (#7156)', () => {
// A background agent's terminal transition fires inside its own
// AsyncLocalStorage frame, and ALS context follows every async
// continuation the notification callback starts (React state updates,
// the drain effect, the next conversation turn). The callback must
// therefore run with NO agent frame, or Config.getModel() resolves to
// the subagent's model for the notification turn and the main session's
// history can overflow the smaller context window.
it('invokes the notification callback outside the agent ALS frame', async () => {
const registry = new BackgroundTaskRegistry();
const seen: Array<{
agentId: string | null;
runtimeView: unknown;
}> = [];
registry.setNotificationCallback(() => {
seen.push({
agentId: getCurrentAgentId(),
runtimeView: getRuntimeContentGenerator(),
});
});

registry.register({
agentId: 'bg-1',
description: 'bg agent',
status: 'running',
startTime: Date.now(),
abortController: new AbortController(),
isBackgrounded: true,
outputFile: '/tmp/bg.jsonl',
});

const fakeView = {
contentGenerator: {} as never,
contentGeneratorConfig: { model: 'subagent-model' } as never,
};
await runWithAgentContext('bg-1', () =>
runWithRuntimeContentGenerator(fakeView, async () => {
// Sanity: we ARE inside the subagent frame here.
expect(getCurrentAgentId()).toBe('bg-1');
expect(getRuntimeContentGenerator()).toBe(fakeView);
registry.complete('bg-1', 'done');
}),
);

expect(seen).toHaveLength(1);
expect(seen[0]!.agentId).toBeNull();
expect(seen[0]!.runtimeView).toBeUndefined();
});
});

describe('BackgroundTaskRegistry', () => {
let registry: BackgroundTaskRegistry;

Expand Down
14 changes: 13 additions & 1 deletion packages/core/src/agents/background-tasks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { createDebugLogger } from '../utils/debugLogger.js';
import { parsePositiveIntegerEnv } from '../utils/env.js';
import { escapeXml } from '../utils/xml.js';
import { patchAgentMeta } from './agent-transcript.js';
import { runOutsideAgentContext } from './runtime/agent-context.js';
import {
AgentEventType,
type AgentApprovalRequestEvent,
Expand Down Expand Up @@ -1428,7 +1429,18 @@ export class BackgroundTaskRegistry {
};

try {
this.notificationCallback(displayLine, xmlParts.join('\n'), meta);
// The terminal transition (complete/fail/cancel) that reaches this
// point runs inside the finished agent's AsyncLocalStorage frame, and
// ALS context follows every async continuation the callback starts —
// including the React state update that drains the notification into
// a new conversation turn. Without exiting the frame here, that turn
// resolves Config.getModel() to the SUBAGENT's model and the main
// session's history can overflow its smaller context window (#7156).
// A notification is main-session-owned, so emit it with no agent
// frame at all.
runOutsideAgentContext(() =>
this.notificationCallback!(displayLine, xmlParts.join('\n'), meta),
);
} catch (error) {
debugLogger.error('Failed to emit background notification:', error);
}
Expand Down
17 changes: 17 additions & 0 deletions packages/core/src/agents/runtime/agent-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,23 @@ export function getRuntimeContentGenerator():
return storage.getStore()?.runtimeView;
}

/**
* Runs `fn` with NO agent frame on the async-local stack, so
* `Config.getModel()` / `getContentGeneratorConfig()` resolve to the main
* session's configuration and `getCurrentAgentId()` returns null.
*
* AsyncLocalStorage context propagates through every async continuation
* started inside `fn` — React state updates, queued microtasks, timers —
* which is exactly how a background agent's runtime view leaked into the
* notification drain and switched the main session onto the subagent's
* model (#7156). Wrap main-session-owned work that can be triggered from
* inside an agent frame (notification emission, completion bookkeeping)
* with this helper.
*/
export function runOutsideAgentContext<T>(fn: () => T): T {
return storage.exit(fn);
}

/**
* True when there is no active agent frame — i.e. we are in the top-level
* user session, not inside a sub-agent. The canonical "top-level only"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export {
getRuntimeContentGenerator,
runWithRuntimeContentGenerator,
type RuntimeContentGeneratorView,
runOutsideAgentContext,
} from './agents/runtime/agent-context.js';
export * from './core/reasoning-effort.js';
export * from './core/coreToolScheduler.js';
Expand Down
Loading