diff --git a/packages/core/src/agents/runtime/agent-core.test.ts b/packages/core/src/agents/runtime/agent-core.test.ts index 33b34d5aa5e..9e4b0a577fb 100644 --- a/packages/core/src/agents/runtime/agent-core.test.ts +++ b/packages/core/src/agents/runtime/agent-core.test.ts @@ -15,6 +15,13 @@ import { type RuntimeContentGeneratorView, } from './agent-context.js'; import { subagentNameContext } from '../../utils/subagentNameContext.js'; +import { + getAgentName, + getTeammateContext, + isTeammate, + runWithTeammateIdentity, +} from '../team/identity.js'; +import type { TeammateIdentity } from '../team/types.js'; import type { Config } from '../../config/config.js'; import type { ModelConfig, @@ -213,6 +220,48 @@ describe('AgentCore.runInAgentFrames', () => { expect(observedAgentId).toBe('agent-123'); }); + it('restores the teammate identity for deferred-approval continuations', async () => { + // Regression: a teammate's `send_message`/`task_update` that requires + // confirmation resumes from the UI's async chain, outside the + // teammate identity frame TeamManager established. Before the fix, + // `getAgentName()` returned undefined there and send_message fell back + // to the leader — forging a `from="leader"` envelope and slipping past + // the leader-only `isTeammate()` guard. The respond closure must carry + // the identity captured at emit time back into the resumed tool body. + const core = makeCore('approval-agent'); + const teammateIdentity: TeammateIdentity = { + agentId: 'scribe@demo', + agentName: 'scribe', + teamName: 'demo', + isTeamLead: false, + }; + + let respondClosure: (() => Promise) | undefined; + let observedAgentName: string | undefined; + let observedIsTeammate: boolean | undefined; + const onConfirm = async () => { + observedAgentName = getAgentName(); + observedIsTeammate = isTeammate(); + }; + + // Simulate the teammate's loop frame being live at emit time. + await runWithTeammateIdentity(teammateIdentity, async () => { + const inherited = getTeammateContext(); + respondClosure = () => + core.runInAgentFrames(onConfirm, undefined, undefined, inherited); + }); + + // Teammate frame is gone; jump to a fresh microtask chain to be sure. + expect(getAgentName()).toBeUndefined(); + expect(isTeammate()).toBe(false); + await new Promise((resolve) => setImmediate(resolve)); + + await respondClosure!(); + + expect(observedAgentName).toBe('scribe'); + expect(observedIsTeammate).toBe(true); + }); + it("prefers the agent's own view over inheritedView when both are present", async () => { // Defensive: if a future caller wires both, the agent's explicit view // wins — we never want a captured snapshot to override the agent's diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 396d273956e..08f99a2487e 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -76,7 +76,12 @@ import { matchesMcpPattern } from '../../permissions/rule-parser.js'; import { ToolNames } from '../../tools/tool-names.js'; import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { type ContextState, templateString } from './agent-headless.js'; -import { isTeammate } from '../team/identity.js'; +import { + isTeammate, + getTeammateContext, + runWithTeammateIdentity, +} from '../team/identity.js'; +import type { TeammateIdentity } from '../team/types.js'; /** * Result of a single reasoning loop invocation. @@ -572,6 +577,15 @@ export class AgentCore { * from the parent UI chain, after the subagent's AsyncLocalStorage frame * has unwound. * + * `inheritedTeammateIdentity` restores the in-process teammate identity + * frame (`teammateIdentityStore`). Deferred approval needs it for the + * same reason as the others: when a teammate's `send_message` / + * `task_update` resumes from the UI chain, `getAgentName()` would + * otherwise be undefined and the tool would mis-attribute the message + * to the leader (forged `from="leader"` envelope) and slip past the + * leader-only `isTeammate()` guard. No-op on the reasoning-loop path, + * where TeamManager already establishes this frame. + * * Exposed (rather than inlined twice) so the contract stays testable in * isolation; see `agent-core.test.ts`. */ @@ -579,13 +593,18 @@ export class AgentCore { fn: () => Promise, inheritedView?: RuntimeContentGeneratorView, inheritedAgentId?: string, + inheritedTeammateIdentity?: TeammateIdentity, ): Promise { - return subagentNameContext.run(this.name, () => { - const runWithView = () => this.withRuntimeView(fn, inheritedView); - return inheritedAgentId - ? runWithAgentContext(inheritedAgentId, runWithView) - : runWithView(); - }); + const runInner = () => + subagentNameContext.run(this.name, () => { + const runWithView = () => this.withRuntimeView(fn, inheritedView); + return inheritedAgentId + ? runWithAgentContext(inheritedAgentId, runWithView) + : runWithView(); + }); + return inheritedTeammateIdentity + ? runWithTeammateIdentity(inheritedTeammateIdentity, runInner) + : runInner(); } /** @@ -1243,6 +1262,11 @@ export class AgentCore { // restore it. See `runInAgentFrames` for the wiring. const inheritedView = getRuntimeContentGenerator(); const inheritedAgentId = getCurrentAgentId(); + // Capture the teammate identity frame too, while the loop + // frame is still live, so the deferred-approval continuation + // can restore it. See `runInAgentFrames` for why this matters + // (mis-attributed `from="leader"` + leader-guard bypass). + const inheritedTeammateIdentity = getTeammateContext(); this.eventEmitter?.emit(AgentEventType.TOOL_WAITING_APPROVAL, { subagentId: this.subagentId, round: currentRound, @@ -1272,6 +1296,7 @@ export class AgentCore { () => waiting.confirmationDetails.onConfirm(outcome, payload), inheritedView, inheritedAgentId ?? undefined, + inheritedTeammateIdentity, ); }, timestamp: Date.now(),