LibreChat/api/server/services/Endpoints/agents/initialize.spec.js
Danny Avila 259f1e0c32
🛰️ feat: Route Live Subagent Controls Across Replicas (#14971)
* feat: route live subagent controls across replicas

* fix: initialize task routing in cluster workers

* fix: harden cross-replica task routing

* fix: expire routed task owners independently

* fix: close cross-replica routing edge cases

* fix: bound owner refresh and close routed cancellation gaps

Refresh owned task registrations in bounded parallel batches so a full
heartbeat pass stays well inside the 30-second directory lease instead of
serializing one Redis EVAL per registration.

Route conversation-deletion cancellation through a dedicated owner-side
scope operation. The owner applies the deletion predicate to its complete
local task set, so a scope holding more children than the model-facing
list cap no longer leaves live executors running after their parent is
removed.

Key a consumed claim's retained response by its operation rather than by
one caller's correlation id, so a later poll recovers a terminal result
whose responses were all lost. Live claim statuses stay uncached so a
poll always observes the task's current state.

Type the model-facing `maxLength` bounds with a narrow local string
schema; the SDK's JsonSchemaType does not declare the keyword, and the
runtime checks continue to enforce the same limits.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: retain claimed results apart from control replays

A consumed claim is the only routed response whose loss destroys data, so
it no longer shares one bounded cache with control replays that unrelated
command traffic can evict. Claims are retained under their own budget, and
the requester acknowledges a result it received so the owner releases the
copy immediately instead of holding it for the full replay window.

Resolve the post-delete cancellation pass from durable leases. The deleted
conversations cannot be read back, so re-reading each one only scaled the
cascade while probing the owner directory once per removed id; one lease
read now resolves every live child address instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: never consume a result the owner cannot replay

Retention for consumed claims is bounded, so a burst of undelivered results
could evict an earlier one and lose it for good. The owner now admits a claim
only while it can retain a worst-case result, and refuses the routed claim
otherwise instead of consuming it, leaving the result on the task for a later
poll. Retained claims are never displaced; control replays keep evicting.

Key a control replay by the command itself rather than by one caller's
correlation id. The transport's own retry reuses a single envelope, but a
caller that saw the owner as unavailable reissues the command under a new id,
which steered, queued, or interrupted the child a second time.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: own a claimed result until it is acknowledged

A consumed terminal result is task-owned state, not a cache entry. It now
carries no expiry at all: the owner holds it until a caller acknowledges
receipt, and only then is it released. Retention stays bounded by the
existing admission gate, which refuses a claim the owner could not keep
rather than consuming a result it might drop.

Identify a control by the caller's invocation instead of by its content.
The tool mints one id per invocation and routing carries it, so a routed
retransmission of that invocation replays the owner's result while two
deliberate identical commands arrive under distinct ids and both apply.
Content-derived identity could not tell those apart and would have
answered the second from a stale snapshot.

Wait for the dpkg frontend lock in the best-effort Playwright font step.
Its timeout kills npx while the apt-get it spawned keeps the lock, which
then failed the fatal Redis install and ended the MCP replica jobs before
any test ran (#14983).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: treat acknowledgement as part of delivering a result

Publishing an acknowledgement once and ignoring the outcome meant a result
could be reported as delivered while the owner never learned it could let
go, and since that retention neither expires nor evicts, enough lost
acknowledgements would fill it and refuse every later remote claim.

An acknowledgement is now confirmed: publishing to zero subscribers is not
success, it retries inside the ordinary request window, and a claim whose
acknowledgement cannot be confirmed reports the retryable unavailable path
instead of handing back a result the owner still holds. A later poll
recovers that result and acknowledges it, and releasing is idempotent.
Owner registration also outlives the task while a result is unacknowledged,
so the retained result cannot become unreachable.

Take the control invocation identity from the provider's tool-call id
rather than minting one per execution, so replaying the same tool call
stays idempotent while two distinct calls with identical payloads both
apply.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* style: sort the widened node:crypto import

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: own control invocations and cancellation plans at the task seam

Applies one logical control exactly once for its owning task rather than
in the transport, so a local caller and a routed caller of the same
invocation agree, and reusing an invocation id for different content is
refused instead of silently applied. Invocation identity now comes from
the run, agent, and provider tool-call id hashed to a bounded 32
characters, so a repeated `call_0` never bleeds across tasks and no id can
overrun the routed bound.

Cancellation for conversation deletion is now resolved into a plan while
those rows are still readable, then replayed against the owner directory
after the cascade is deleted. Owner registration is awaited before any
provider work, so a child that cannot be addressed never starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* style: separate the control invocation map from the next member

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: close subagent deletion, claim, and control invocation gaps

Bulk conversation deletion now runs behind a durable owner admission fence.
Draining alone could not close the race: a child admitted on another replica
after the drain read its leases would start provider work against a parent
about to disappear. The fence is written before any lease is read and each
child revalidates it after its own lease is written, so one of the two always
observes the other. It expires on its own, so a process lost mid-deletion
cannot leave an account unable to run subagents.

A terminal child result is no longer kept alive in the owning replica's
memory until someone acknowledges it. Collection is recorded durably on the
child's own message against the polling invocation, so the poll whose
response was lost recovers its own result while a different invocation is
told the result was already collected. Owner-side retention returns to an
ordinary bounded cache that expires, which is what abandoned polls needed:
they can no longer occupy claim capacity until the process restarts.

The deletion drain now cancels each task under one invocation held for the
whole drain, stops re-sending once the owner answers, and retries only
deliveries it could not confirm. A routed control replay also validates the
command fingerprint, so one invocation id carrying different content reaches
the owner to be refused instead of collecting the earlier command's success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: assert the drain's calls before restoring its spies

Restoring a spy also clears its recorded calls, so the drain assertions
ran against an emptied mock. Formats the durable claim method tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: close the follow-on gaps in the deletion fence and result claim

The admission fence now carries an ownership token, so an overlapping
deletion's fence is never lifted by the one that finishes first, and both
fence writes invalidate the cached auth user document. It also covers the
other bulk-delete path: `DELETE /` with no conversation filter removes every
conversation, so it runs behind the same fence rather than a bare drain.

The durable record now decides who holds a one-shot result. An owner replaying
a retained response could hand the same terminal claim to a second invocation;
that invocation is told the result was already collected, while the one that
consumed it still recovers its own. A task with no durable record to arbitrate
keeps whatever the owner answered.

Drain cancellation treats `not_found` as unconfirmed: a missing registration
while the durable lease is still live means the child may be running, so the
command is retried under its invocation once the owner republishes itself.
Control fingerprints are hashed, so retaining one per invocation costs a fixed
few bytes instead of a bounded message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: hold every deletion fence and keep live idempotency records

An owner now holds one admission fence per concurrent bulk deletion instead
of one at a time, so admission reopens only when the last deletion finishes
regardless of completion order. Expired fences are pruned as new ones arrive
and the set is bounded, so an abandoned fence cannot accumulate or lock an
account out.

A failed durable claim write is no longer read as an absent record. Handing a
terminal result over without recording its claimant would let another
invocation collect the same one-shot output once the database recovered, so
the collection reports the retryable unavailable path and leaves the result
for a later poll.

Control invocation records now evict tasks the store no longer holds before
live ones, over a bounded scan. Dropping a live task's record would let a
caller retry apply its queue, steer, or interrupt a second time once the
transport replay had also expired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: keep the deletion fence portable and never drop a live record

The admission fence is written with plain update operators again. DocumentDB
rejects pipeline-form updates, and this runs before any deletion, so the
pipeline form would have failed both bulk-delete endpoints outright on a
supported database target.

An excess deletion is now refused rather than silently displacing the oldest
active fence, which would have reopened admission for a deletion still
running. Expired fences are pruned before the cap is tested, so only genuinely
concurrent deletions count against it.

Control invocation records now sweep every settled task's entry when the
window fills, and a window of entirely live records refuses the new control
before touching the child instead of evicting one. Applying a command with no
room to record it would let the caller's own retry apply it twice.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: hold the fence, bound recovered results, and expire stale commands

The admission fence is renewed for as long as its deletion runs, so a very
large account or a stalled database cannot let it lapse while conversations
are still being removed. Only the deletion's own fence is renewed, and the
renewal stops with the operation.

Cancellation now covers every conversation the cascade removed, not only the
ones a plan named: a grandchild lives in its own parent's scope, which a plan
naming the deleted root never reaches.

A routed request carries the deadline its caller waits for, and an owner drops
one that arrives past it. A publisher disconnected mid-request queues the
envelope offline and delivers it after the caller was told the owner was
unavailable, which would otherwise steer a child the caller believes untouched.

A result recovered from its durable child message is bounded like a routed one.
The message keeps the child's untruncated output, so recovery could otherwise
return far more than the routed result limit allows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: size the fence window so a renewal can be observed

The renewal test set a 30ms drain timeout but the five-minute grace window
dominates it, so the interval was 100 seconds and no renewal could fire
inside the test's deletion. The grace window is an option now, matching the
store's other timings, and the test sizes the window to 90ms.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix: wire the durable claim method and close the fence follow-ons

The production store never received `claimSubagentTaskResult`, so every
terminal result would have surfaced as unavailable once a task settled. The
host wires that object from JavaScript, where the factory's parameter type
checks nothing, so the factory now refuses a store missing any method it
calls rather than failing at the first claim.

The routing transport takes a dedicated publisher with the offline queue
disabled. The shared client held commands issued during a disconnect and
delivered them after the caller had given up, which the request deadline
narrowed but could not close inside the clock-skew allowance.

Fence renewal invalidates the cached auth document like the fence and release
paths, and a renewal reporting its entry gone re-takes the fence instead of
letting the deletion run on unfenced. The post-delete cancellation retries a
transiently unreachable owner: the conversations are already gone, so it is
the only pass that can still stop a late-admitted child.

A replaced replay entry no longer leaves its bytes counted, which would have
inflated the cache's total until unrelated responses were evicted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: wait on observed lease renewal instead of a fixed delay

The shared-lease renewal test held a 60ms lease and slept 100ms before
asserting an overlapping worker was refused, so a loaded runner that
starved the 10ms heartbeat past the TTL let the lease lapse and the
second worker run. Spy on acquisition and renewal, then wait until a
renewal succeeds past the acquired lease's own deadline — direct
evidence the heartbeat carried it past expiry, with no timing
assumption — and give the lease enough headroom that a stalled timer
no longer decides the outcome.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): close the routing, fence, and cache gaps found in review

Five separate seams, each with its own failure:

`Cluster.duplicate` reads its first argument as a startup-node list and
its second as the overrides, unlike `Redis.duplicate`, so the publisher's
`enableOfflineQueue: false` was silently dropped under
`USE_REDIS_CLUSTER` and a command issued mid-disconnect could still
reach a child after its caller was told `unavailable`. Route both
through `duplicateIoRedisClient`.

The control window's capacity refusal ran before the store knew whether
it owned the task, so unrelated local load could veto a cancellation
bound for another replica. Establish that the task is local first and
leave a remote one to its owner's window.

`clearInterval` stops only future fence renewals. One already waiting on
the database could resolve after the release, read its own lifted fence
as expiry, and write a replacement that nothing remained to lift —
closing subagent admission for the account until it aged out. Track the
in-flight renewal, refuse overlapping passes, and await it before
releasing.

Every owner bounds its own task list, but the aggregation appended each
batch whole, so the model-facing list grew with the number of replicas
holding the scope. Cap the merged list while still reading every reply
for the stale-registration sweep.

The admission-fence prune commits independently of the fence that
follows it, so a refused or failed push left the cached auth document
describing entries the collection no longer held. Invalidate whichever
way the second write goes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): cap the merged task list the poll tool actually reads

Each owner bounds its own reply and the remote aggregation bounds their
sum, but `listTasks` merged that bounded remote list with however many
children this replica owns and returned it whole. `check_background_task`
could therefore still receive roughly twice the advertised cap. Bound the
deduplicated, sorted result and export the cap so both seams share one
number.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: admit every task the merged-list cap test starts

The base store admits ten concurrent runs per scope by default, so
starting 150 at once left most refused for capacity and the assertion
never reached the merge it was written to check. Raise the cap for this
store only; admission is a different invariant with its own tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): let a deletion notice its admission fence lapsing

Renewal failures were logged and swallowed, so a run of rejected writes
let the last confirmed `fencedUntil` pass while the deletion carried on
believing admission was still closed — long enough for another replica
to admit a child against conversations about to be removed. Track the
deadline only a confirmed write advances, and check it after the drain,
before anything is deleted: nothing has been removed at that point, so
the operation fails closed and the caller retries once the fence can be
held. A lapse detected after the rows are gone is logged instead, since
reporting failure there would invite a retry against conversations that
no longer exist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: raise both concurrency caps the merged-list test trips

Raising the per-scope limit left the store-wide `maxRunningTotal` at its
default hundred, so fifty of the hundred and fifty starts were still
refused. Verified against the base store directly this time: with only
the per-scope cap raised it admits a hundred, and with both raised it
admits all hundred and fifty.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): close the fence renewal gap and keep running tasks listed

A renewal that started before its deadline but landed after it was still
credited with extending the fence from its own start time, so a window in
which admission stood open was papered over: a child could take a lease
the drain had already read past and the deletion would proceed without
cancelling it. The deadline now only advances when the write lands while
the previous one still holds; anything later records a lapse the fence
cannot be restored backwards over.

The model-facing cap sorted oldest-first and sliced, which dropped the
newest tasks — including children that had only just started running,
and which the poll tool offers no other way to discover. Bound by status
instead: running children first, then the most recent settled results.
Both caps share one helper, and the routed aggregation now bounds after
its loop so the choice is made across every owner's reply rather than by
whichever answered first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): finish the cap and the fence at the seams they still missed

The status-aware cap only reached the requester: an owner's own reply
still sliced positionally, so a replica holding more than the cap dropped
its running children before the requester could bound anything. Both
sides now share `boundedTaskList`.

A fence that lapsed during the deletion itself was only logged. The rows
are gone by then, so failing is still wrong, but the child another
replica admitted while the fence was down is not: the fence is retaken
and the drain repeated to cancel it.

A child's lease renewal had the same retroactive hole the admission fence
had — Mongo filters on the `now` captured before the call, so a write
landing after the lease expired still moves the row forward, while an
owner drain reading active leases in that gap saw the thread as free. The
lease now carries its own deadline and a late renewal stops the executor.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* test: cover the lease lapse and the post-deletion re-drain

The owner-side cap shipped with a regression test; these two did not.
One drives a lease renewal that succeeds only after the lease it was
extending had expired and asserts the executor stops; the other lets the
fence lapse during the deletion itself and asserts a second drain runs
while the request still reports success.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01H1cCMDrTWaRNkmtKjpWELZ

* fix(agents): close live-task lifecycle gaps

* test(redis): exercise cluster node discovery

* fix(test): type cluster discovery seam

* fix(ci): wait for orphaned apt processes

* fix(ci): reserve time for apt drain

* fix(ci): skip optional fonts in MCP jobs

* fix(agents): recover tasks after owner loss

* fix(agents): preserve local task discovery

* fix(agents): initialize fail-fast cluster publisher

* style(agents): sort routing test imports

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-19 02:16:35 -04:00

2104 lines
70 KiB
JavaScript

const mongoose = require('mongoose');
const {
ResourceType,
PermissionBits,
PrincipalType,
PrincipalModel,
MAX_SUBAGENT_DEPTH,
MAX_SUBAGENT_GRAPH_NODES,
MAX_SUBAGENT_RUN_CONFIGS,
Constants,
ErrorTypes,
} = require('librechat-data-provider');
const { MongoMemoryServer } = require('mongodb-memory-server');
const mockInitializeAgent = jest.fn();
const mockValidateAgentModel = jest.fn();
function deferred() {
let resolve;
const promise = new Promise((resolvePromise) => {
resolve = resolvePromise;
});
return { promise, resolve };
}
jest.mock('@librechat/agents', () => ({
...jest.requireActual('@librechat/agents'),
createContentAggregator: jest.fn(() => ({
contentParts: [],
aggregateContent: jest.fn(),
})),
}));
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
initializeAgent: (...args) => mockInitializeAgent(...args),
validateAgentModel: (...args) => mockValidateAgentModel(...args),
GenerationJobManager: { setCollectedUsage: jest.fn() },
getCustomEndpointConfig: jest.fn(),
createSequentialChainEdges: jest.fn(),
}));
/** Captured by the `getDefaultHandlers` mock so tests can drive the
* `ON_TOOL_EXECUTE` pipeline with a real subagent id and observe whether
* the tool context (agent, tool_resources, skill ACLs) was preserved. */
let capturedToolExecuteOptions;
let capturedDefaultHandlerOptions;
const mockArtifactToolEndCallback = jest.fn();
jest.mock('~/server/controllers/agents/callbacks', () => ({
createToolEndCallback: jest.fn(() => mockArtifactToolEndCallback),
createAttachmentEmitter: jest.fn(() => jest.fn()),
createBackgroundCodeResultHandler: jest.fn(() => jest.fn()),
getDefaultHandlers: jest.fn((opts) => {
capturedDefaultHandlerOptions = opts;
capturedToolExecuteOptions = opts?.toolExecuteOptions;
return {};
}),
}));
const mockLoadToolsForExecution = jest.fn();
jest.mock('~/server/services/ToolService', () => ({
loadAgentTools: jest.fn(),
loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args),
isFatalAgentInitializationError: (error) =>
[
'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
'resource_recovery_required',
'stateful_code_environment_not_allowed',
].includes(error?.code),
}));
jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: jest.fn().mockResolvedValue({}),
}));
let agentClientArgs;
jest.mock('~/server/controllers/agents/client', () => {
return jest.fn().mockImplementation((args) => {
agentClientArgs = args;
return {};
});
});
jest.mock('./addedConvo', () => ({
processAddedConvo: jest.fn().mockResolvedValue({ userMCPAuthMap: undefined }),
}));
jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));
const { initializeClient } = require('./initialize');
const { processAddedConvo } = require('./addedConvo');
const { getSkillDbMethods, getSkillToolDeps } = require('./skillDeps');
const { loadAgentTools } = require('~/server/services/ToolService');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { logger } = require('@librechat/data-schemas');
const { User, AclEntry } = require('~/db/models');
const { createAgent, createSkill, updateAgent } = require('~/models');
const db = require('~/models');
jest.spyOn(logger, 'warn').mockImplementation(() => {});
const PRIMARY_ID = 'agent_primary';
const TARGET_ID = 'agent_target';
const AUTHORIZED_ID = 'agent_authorized';
describe('initializeClient — processAgent ACL gate', () => {
let mongoServer;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
jest.clearAllMocks();
agentClientArgs = undefined;
capturedDefaultHandlerOptions = undefined;
testUser = await User.create({
email: 'test@example.com',
name: 'Test User',
username: 'testuser',
role: 'USER',
});
mockValidateAgentModel.mockResolvedValue({ isValid: true });
});
const makeReq = () => ({
user: { id: testUser._id.toString(), role: 'USER' },
body: { conversationId: 'conv_1', files: [] },
resolvedConversation: null,
config: { endpoints: {} },
_resumableStreamId: null,
});
const makeEndpointOption = () => ({
agent: Promise.resolve({
id: PRIMARY_ID,
name: 'Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
}),
model_parameters: { model: 'gpt-4' },
endpoint: 'agents',
});
const makePrimaryConfig = (edges) => ({
id: PRIMARY_ID,
endpoint: 'agents',
edges,
toolDefinitions: [],
toolRegistry: new Map(),
userMCPAuthMap: null,
tool_resources: {},
resendFiles: true,
maxContextTokens: 4096,
codeExecutionContext: {
baseUrl: 'https://code-default.example.com',
codeSessionKey: 'execute_code',
executionProfile: 'default',
statefulSessions: false,
},
});
it('replaces untrusted artifact route metadata with the executing agent context', async () => {
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
await initializeClient({
req: makeReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
const data = { output: { name: 'execute_code', artifact: { files: [] } } };
await capturedDefaultHandlerOptions.toolEndCallback(data, {
langgraph_node: `tools=${PRIMARY_ID}`,
codeExecutionContext: {
baseUrl: 'https://attacker.invalid',
executionProfile: 'stateful',
},
});
expect(mockArtifactToolEndCallback).toHaveBeenLastCalledWith(
data,
expect.objectContaining({
codeExecutionContext: expect.objectContaining({
baseUrl: 'https://code-default.example.com',
executionProfile: 'default',
}),
}),
);
});
it('threads the owning job epoch into resumable event handlers', async () => {
const {
createAttachmentEmitter,
createToolEndCallback,
} = require('~/server/controllers/agents/callbacks');
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req._resumableStreamId = 'conv_1';
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
jobCreatedAt: 1234,
});
expect(capturedDefaultHandlerOptions).toEqual(
expect.objectContaining({
streamId: 'conv_1',
jobCreatedAt: 1234,
}),
);
expect(createToolEndCallback).toHaveBeenCalledWith(
expect.objectContaining({
streamId: 'conv_1',
jobCreatedAt: 1234,
}),
);
expect(createAttachmentEmitter).toHaveBeenCalledWith({
res: {},
streamId: 'conv_1',
jobCreatedAt: 1234,
});
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [], configurable: {} });
await capturedToolExecuteOptions.loadTools([], PRIMARY_ID);
expect(mockLoadToolsForExecution).toHaveBeenCalledWith(
expect.objectContaining({
streamId: 'conv_1',
jobCreatedAt: 1234,
}),
);
});
it('propagates an expected-MCP-tools failure from the runtime tool loader', async () => {
const toolError = Object.assign(new Error('Expected MCP tools are unavailable'), {
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
statusCode: 503,
});
loadAgentTools.mockRejectedValueOnce(toolError);
mockInitializeAgent.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
await loadTools({
req,
res,
tools: ['run_query_mcp_warehouse'],
model: agent.model,
agentId: agent.id,
provider: agent.provider,
});
return makePrimaryConfig([]);
});
await expect(
initializeClient({
req: makeReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toBe(toolError);
});
it('aborts the run when a handoff target resolves none of its expected MCP tools', async () => {
const target = await createAgent({
id: AUTHORIZED_ID,
name: 'Target Agent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['run_query_mcp_warehouse'],
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: target._id,
permBits: PermissionBits.VIEW,
grantedBy: testUser._id,
});
const toolError = Object.assign(new Error('Target Agent expected MCP tools are unavailable'), {
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
statusCode: 503,
});
loadAgentTools.mockRejectedValueOnce(toolError);
mockInitializeAgent
.mockResolvedValueOnce(
makePrimaryConfig([{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }]),
)
.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
await loadTools({
req,
res,
tools: agent.tools,
model: agent.model,
agentId: agent.id,
provider: agent.provider,
});
});
await expect(
initializeClient({
req: makeReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toBe(toolError);
});
it('should skip handoff agent and filter its edge when user lacks VIEW access', async () => {
await createAgent({
id: TARGET_ID,
name: 'Target Agent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
});
const edges = [{ from: PRIMARY_ID, to: TARGET_ID, edgeType: 'handoff' }];
mockInitializeAgent.mockResolvedValue(makePrimaryConfig(edges));
await initializeClient({
req: makeReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
expect(agentClientArgs.agent.edges).toEqual([]);
});
it('should initialize handoff agent and keep its edge when user has VIEW access', async () => {
const authorizedAgent = await createAgent({
id: AUTHORIZED_ID,
name: 'Authorized Agent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: authorizedAgent._id,
permBits: PermissionBits.VIEW,
grantedBy: testUser._id,
});
const edges = [{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }];
const requestAttachment = { file_id: 'request_file', filename: 'request.txt' };
const primaryContextAttachment = { file_id: 'primary_context', filename: 'primary.txt' };
const handoffContextAttachment = { file_id: 'handoff_context', filename: 'handoff.txt' };
const primaryConfig = {
...makePrimaryConfig(edges),
attachments: [primaryContextAttachment, requestAttachment],
requestAttachments: [requestAttachment],
agentContextAttachments: [primaryContextAttachment],
};
const handoffConfig = {
id: AUTHORIZED_ID,
edges: [],
toolDefinitions: [],
toolRegistry: new Map(),
userMCPAuthMap: null,
tool_resources: {},
agentContextAttachments: [handoffContextAttachment],
};
let callCount = 0;
mockInitializeAgent.mockImplementation(() => {
callCount++;
return callCount === 1 ? Promise.resolve(primaryConfig) : Promise.resolve(handoffConfig);
});
await initializeClient({
req: makeReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
expect(agentClientArgs.agent.edges).toHaveLength(1);
expect(agentClientArgs.agent.edges[0].to).toBe(AUTHORIZED_ID);
expect(agentClientArgs.attachments).toEqual([requestAttachment]);
expect(agentClientArgs.agentContextAttachmentsByAgentId.get(PRIMARY_ID)).toEqual([
primaryContextAttachment,
]);
expect(agentClientArgs.agentContextAttachmentsByAgentId.get(AUTHORIZED_ID)).toEqual([
handoffContextAttachment,
]);
});
it('does not enable skill authoring for VIEW-only shared skills', async () => {
const { skill } = await createSkill({
name: 'shared-view-only',
description: 'Use for read-only sharing.',
body: '# Shared view-only skill\n',
author: new mongoose.Types.ObjectId(),
authorName: 'Skill Owner',
});
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.SKILL,
resourceId: skill._id,
permBits: PermissionBits.VIEW,
grantedBy: testUser._id,
});
const endpointOption = makeEndpointOption();
endpointOption.agent = Promise.resolve({
id: PRIMARY_ID,
name: 'Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
skills_enabled: true,
});
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req.config.endpoints.agents = { capabilities: ['skills'] };
const canCreateSkillSpy = jest
.spyOn(getSkillToolDeps(), 'canCreateSkill')
.mockResolvedValue(false);
try {
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption,
});
} finally {
canCreateSkillSpy.mockRestore();
}
const initializeParams = mockInitializeAgent.mock.calls[0][0];
expect(initializeParams.accessibleSkillIds.map(String)).toContain(skill._id.toString());
expect(initializeParams.skillAuthoringAvailable).toBe(false);
});
it('enables skill authoring when model specs enable skills for an ephemeral agent', async () => {
const endpointOption = makeEndpointOption();
endpointOption.spec = 'spec-skills';
endpointOption.agent = Promise.resolve({
id: Constants.EPHEMERAL_AGENT_ID,
name: 'Ephemeral Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
});
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req.config.endpoints.agents = { capabilities: ['skills'] };
req.config.modelSpecs = {
list: [{ name: 'spec-skills', skills: true }],
};
const canCreateSkillSpy = jest
.spyOn(getSkillToolDeps(), 'canCreateSkill')
.mockResolvedValue(true);
try {
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption,
});
} finally {
canCreateSkillSpy.mockRestore();
}
const initializeParams = mockInitializeAgent.mock.calls[0][0];
expect(initializeParams.agent.skills_enabled).toBe(true);
expect(initializeParams.skillAuthoringAvailable).toBe(true);
});
it('loads model validation and skill permissions without serial waits', async () => {
const models = deferred();
const createPermission = deferred();
const req = makeReq();
req.config.endpoints.agents = { capabilities: ['skills'] };
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
getModelsConfig.mockReturnValueOnce(models.promise);
const canCreateSkillSpy = jest
.spyOn(getSkillToolDeps(), 'canCreateSkill')
.mockReturnValue(createPermission.promise);
try {
const initialization = initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(getModelsConfig).toHaveBeenCalledWith(req);
expect(canCreateSkillSpy).toHaveBeenCalledWith({ req });
models.resolve({});
await new Promise((resolve) => setImmediate(resolve));
expect(mockValidateAgentModel).toHaveBeenCalledTimes(1);
expect(mockInitializeAgent).not.toHaveBeenCalled();
createPermission.resolve(false);
await initialization;
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
} finally {
canCreateSkillSpy.mockRestore();
}
});
it('resolves model-spec skill names through deployment-aware skill methods', async () => {
const deploymentSkillId = new mongoose.Types.ObjectId();
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.SKILL,
resourceId: new mongoose.Types.ObjectId(),
permBits: PermissionBits.VIEW,
grantedBy: testUser._id,
});
const endpointOption = makeEndpointOption();
endpointOption.spec = 'spec-deployment-skill';
endpointOption.agent = Promise.resolve({
id: Constants.EPHEMERAL_AGENT_ID,
name: 'Ephemeral Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
});
mockInitializeAgent.mockResolvedValue(makePrimaryConfig([]));
const req = makeReq();
req.config.endpoints.agents = { capabilities: ['skills'] };
req.config.modelSpecs = {
list: [{ name: 'spec-deployment-skill', skills: ['deployment-skill'] }],
};
const getSkillByNameSpy = jest.spyOn(getSkillDbMethods(), 'getSkillByName').mockResolvedValue({
_id: deploymentSkillId,
name: 'deployment-skill',
source: 'deployment',
});
const canCreateSkillSpy = jest
.spyOn(getSkillToolDeps(), 'canCreateSkill')
.mockResolvedValue(false);
try {
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption,
});
expect(getSkillByNameSpy).toHaveBeenCalledWith(
'deployment-skill',
expect.any(Array),
expect.any(Object),
);
const initializeParams = mockInitializeAgent.mock.calls[0][0];
expect(initializeParams.agent.skills_enabled).toBe(true);
expect(initializeParams.agent.skills).toEqual([deploymentSkillId.toString()]);
} finally {
getSkillByNameSpy.mockRestore();
canCreateSkillSpy.mockRestore();
}
});
});
describe('initializeClient — subagent loading', () => {
const SUBAGENT_ID = 'agent_subagent_1';
const DUPLICATE_SUBAGENT_ID = 'agent_subagent_dup';
const HANDOFF_AND_SUB_ID = 'agent_handoff_and_sub';
let mongoServer;
let testUser;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
jest.clearAllMocks();
agentClientArgs = undefined;
capturedToolExecuteOptions = undefined;
mockLoadToolsForExecution.mockReset();
mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [] });
testUser = await User.create({
email: 'subagent@example.com',
name: 'Subagent User',
username: 'subuser',
role: 'USER',
});
mockValidateAgentModel.mockResolvedValue({ isValid: true });
});
/** Grant the test user VIEW on an agent so processAgent loads it. */
const grantView = async (agentDoc) => {
await AclEntry.create({
principalType: PrincipalType.USER,
principalId: testUser._id,
principalModel: PrincipalModel.USER,
resourceType: ResourceType.AGENT,
resourceId: agentDoc._id,
permBits: PermissionBits.VIEW,
grantedBy: testUser._id,
});
};
/** Build a request with the `subagents` capability enabled. */
const makeSubagentReq = () => ({
user: { id: testUser._id.toString(), role: 'USER' },
body: { conversationId: 'conv_sub', files: [] },
resolvedConversation: null,
config: {
endpoints: {
agents: {
capabilities: ['subagents'],
},
},
},
_resumableStreamId: null,
});
const makeEndpointOption = () => ({
agent: Promise.resolve({
id: PRIMARY_ID,
name: 'Primary',
provider: 'openai',
model: 'gpt-4',
tools: [],
}),
model_parameters: { model: 'gpt-4' },
endpoint: 'agents',
});
const makePrimaryConfig = ({ edges = [], subagents, agent_ids }) => ({
id: PRIMARY_ID,
endpoint: 'agents',
edges,
toolDefinitions: [],
toolRegistry: new Map(),
userMCPAuthMap: null,
tool_resources: {},
resendFiles: true,
maxContextTokens: 4096,
subagents,
agent_ids,
});
const makeSubagentConfig = (id) => ({
id,
endpoint: 'agents',
edges: [],
toolDefinitions: [{ name: 'web', description: 'web', parameters: {} }],
toolRegistry: new Map([['web', { name: 'web' }]]),
userMCPAuthMap: null,
tool_resources: { file_search: { file_ids: ['file_1'] } },
accessibleSkillIds: ['skill_1'],
actionsEnabled: true,
resendFiles: false,
maxContextTokens: 4096,
});
const makeNestedSubagentConfig = (id, childIds = []) => ({
...makeSubagentConfig(id),
subagents: { enabled: true, allowSelf: false, agent_ids: childIds },
});
const createViewableAgent = async (id, subagents) => {
const agent = await createAgent({
id,
name: id,
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
subagents,
});
await grantView(agent);
return agent;
};
it('creates one trusted durable thread scope for detached subagents', async () => {
mockInitializeAgent.mockResolvedValue(
makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
}),
);
const req = makeSubagentReq();
req.config.endpoints.agents.capabilities.push('run_in_background');
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.subagentTasks).toBe(capturedToolExecuteOptions.subagentTasks);
expect(agentClientArgs.subagentTasks.store.supportsThreadContinuation).toBe(true);
expect(JSON.parse(agentClientArgs.subagentTasks.scopeId)).toEqual({
version: 1,
userId: testUser._id.toString(),
parentConversationId: 'conv_sub',
});
});
it('keeps an existing detached task controllable after subagent config is disabled', async () => {
mockInitializeAgent.mockResolvedValue(
makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
}),
);
const initialReq = makeSubagentReq();
initialReq.config.endpoints.agents.capabilities.push('run_in_background');
await initializeClient({
req: initialReq,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
const existingConfig = agentClientArgs.subagentTasks;
const hasTasksSpy = jest.spyOn(existingConfig.store, 'hasTasks').mockResolvedValueOnce(true);
mockInitializeAgent.mockResolvedValue(makePrimaryConfig({}));
const changedReq = makeSubagentReq();
changedReq.config.endpoints.agents.capabilities.push('run_in_background');
await initializeClient({
req: changedReq,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.subagentTasks).toEqual(existingConfig);
expect(capturedToolExecuteOptions.subagentTasks).toEqual(existingConfig);
expect(agentClientArgs.agent.subagents).toBeUndefined();
hasTasksSpy.mockRestore();
});
it('disables every nested subagent path at the durable child-thread depth limit', async () => {
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
const req = makeSubagentReq();
req.config.endpoints.agents.capabilities.push('run_in_background');
req.resolvedConversation = {
conversationId: 'conv_sub',
subagentThread: { depth: 1 },
};
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.subagentTasks).toBeUndefined();
expect(capturedToolExecuteOptions.subagentTasks).toBeUndefined();
expect(agentClientArgs.agent.subagents).toBeUndefined();
});
it('keeps detached subagents disabled without the admin background capability', async () => {
mockInitializeAgent.mockResolvedValue(
makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
}),
);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.subagentTasks).toBeUndefined();
expect(capturedToolExecuteOptions.subagentTasks).toBeUndefined();
});
it('defers pure-subagent MCP initialization until the descriptor is selected', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Data Subagent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['run_query_mcp_warehouse'],
});
await grantView(subAgent);
const toolError = Object.assign(new Error('Subagent expected MCP tools are unavailable'), {
code: 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE',
statusCode: 503,
});
loadAgentTools.mockRejectedValueOnce(toolError);
mockInitializeAgent
.mockResolvedValueOnce(
makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] },
}),
)
.mockImplementationOnce(async ({ req, res, loadTools, agent }) => {
await loadTools({
req,
res,
tools: agent.tools,
model: agent.model,
agentId: agent.id,
provider: agent.provider,
});
});
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
await expect(
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
}),
).rejects.toBe(toolError);
});
it('defers pure-subagent resource recovery until the descriptor is selected', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Code Subagent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['execute_code'],
});
await grantView(subAgent);
const resourceRecoveryError = Object.assign(new Error('resource recovery required'), {
code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED,
status: 409,
statusCode: 409,
});
mockInitializeAgent
.mockResolvedValueOnce(
makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] },
}),
)
.mockRejectedValueOnce(resourceRecoveryError);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
await expect(
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
}),
).rejects.toBe(resourceRecoveryError);
});
it('advertises a configured subagent without initializing it, then initializes it on selection', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Explicit Subagent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['web'],
stateful_code_environment: 'agent-user',
});
await grantView(subAgent);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] },
});
const subagentConfig = makeSubagentConfig(SUBAGENT_ID);
let call = 0;
let subagentConvoFileIds;
mockInitializeAgent.mockImplementation(async (params, dbDeps) => {
call += 1;
if (call === 1) {
return primaryConfig;
}
subagentConvoFileIds = await dbDeps.getConvoFiles(params.conversationId);
return subagentConfig;
});
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(1);
expect(agentClientArgs.agent.lazySubagentConfigs[0]).toEqual(
expect.objectContaining({
id: SUBAGENT_ID,
configId: expect.any(String),
statefulCodeEnvironment: 'agent-user',
}),
);
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('tools');
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('tool_resources');
expect(agentClientArgs.agent.lazySubagentConfigs[0]).not.toHaveProperty('_id');
await agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
const subagentDbDeps = mockInitializeAgent.mock.calls[1][1];
expect(subagentDbDeps.getConvoFiles).toBeInstanceOf(Function);
expect(subagentDbDeps.db).toBeUndefined();
expect(subagentConvoFileIds).toEqual([]);
/** Subagent-only agents must NOT appear in `agentConfigs` — otherwise the
* graph would treat them as a parallel/handoff node. */
expect(agentClientArgs.agentConfigs).toBeDefined();
expect(agentClientArgs.agentConfigs.has(SUBAGENT_ID)).toBe(false);
});
it('rejects a disallowed lazy subagent scope before exposing it for prewarm', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Disallowed Stateful Subagent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['execute_code'],
stateful_code_sessions: true,
stateful_code_environment: 'conversation',
});
await grantView(subAgent);
mockInitializeAgent.mockResolvedValue(
makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
}),
);
const req = makeSubagentReq();
req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions');
req.config.endpoints.agents.statefulCodeSessions = { allowedEnvironments: ['user'] };
await expect(
initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toMatchObject({
code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED,
});
expect(agentClientArgs).toBeUndefined();
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
});
it('omits a descriptor when its metadata lookup fails without aborting the primary run', async () => {
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
jest.spyOn(db, 'getAgentWithVersionCount').mockRejectedValueOnce(new Error('transient read'));
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.agent.lazySubagentConfigs).toEqual([]);
expect(logger.error).toHaveBeenCalledWith(
expect.stringContaining(`Error loading subagent metadata ${SUBAGENT_ID}`),
expect.any(Error),
);
});
it('uses exact custom-endpoint identity for descriptor reasoning history', async () => {
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Custom Subagent',
provider: 'caseprovider',
model: 'custom-model',
author: new mongoose.Types.ObjectId(),
tools: [],
});
await grantView(subAgent);
mockInitializeAgent.mockResolvedValue(
makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
}),
);
const req = makeSubagentReq();
req.config.endpoints.custom = [
{ name: 'CaseProvider', customParams: { includeReasoningHistory: true } },
{ name: 'caseprovider', customParams: { includeReasoningHistory: false } },
];
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.agent.lazySubagentConfigs[0].includeReasoningHistory).toBe(false);
});
it('fails closed when a selected subagent configuration changes after advertisement', async () => {
await createViewableAgent(SUBAGENT_ID);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
await updateAgent({ id: SUBAGENT_ID }, { instructions: 'Changed after descriptor creation.' });
await expect(
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
}),
).rejects.toThrow(`Subagent ${SUBAGENT_ID} changed`);
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
});
it('rechecks VIEW permission when a descriptor is selected', async () => {
const subAgent = await createViewableAgent(SUBAGENT_ID);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
await AclEntry.deleteMany({ resourceId: subAgent._id });
await expect(
agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
}),
).rejects.toThrow(`no longer have access to subagent ${SUBAGENT_ID}`);
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
});
it('preserves subagent tool context for ON_TOOL_EXECUTE (Codex P1 regression guard)', async () => {
/** Verifies the Codex P1 fix: `agentToolContexts.delete(subagentId)` is
* NOT called for subagent-only agents, so when the child dispatches
* `ON_TOOL_EXECUTE` the parent can still resolve its tool context
* (agent, tool_resources, skill ACLs, actionsEnabled) to run tools
* with the right scope. We drive the real `loadTools` closure that
* `initializeClient` wires into `toolExecuteOptions`. */
const subAgent = await createAgent({
id: SUBAGENT_ID,
name: 'Explicit Subagent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['web'],
});
await grantView(subAgent);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
});
const subagentConfig = makeSubagentConfig(SUBAGENT_ID);
let call = 0;
mockInitializeAgent.mockImplementation(() =>
Promise.resolve(++call === 1 ? primaryConfig : subagentConfig),
);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
await agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: new AbortController().signal,
});
expect(capturedToolExecuteOptions?.loadTools).toBeInstanceOf(Function);
/** Invoke the real closure with the subagent's id. If `agentToolContexts`
* had been deleted (as in the pre-fix code), this would call
* `loadToolsForExecution` with `agent: undefined` — actions/resource-
* scoped tools would silently drop. */
await capturedToolExecuteOptions.loadTools(['web'], SUBAGENT_ID);
expect(mockLoadToolsForExecution).toHaveBeenCalledTimes(1);
const arg = mockLoadToolsForExecution.mock.calls[0][0];
expect(arg.agent).toBeDefined();
expect(arg.agent.id).toBe(SUBAGENT_ID);
expect(arg.toolRegistry).toBeInstanceOf(Map);
expect(arg.tool_resources).toEqual({ file_search: { file_ids: ['file_1'] } });
expect(arg.actionsEnabled).toBe(true);
});
it('threads run-scoped MCP tool definitions into ON_TOOL_EXECUTE loading', async () => {
/** Regression guard for the request-scoped MCP/PTC handoff: the
* `mcpAvailableTools` discovered at run start must survive
* `buildAgentToolContext` and reach `loadToolsForExecution`, otherwise
* request-scoped servers reinitialize on every programmatic tool call
* and can trip the MCP circuit breaker under parallel calls. */
const mcpTool = 'list_tables_mcp_ClickHouse';
const mcpAvailableTools = {
ClickHouse: {
[mcpTool]: {
type: 'function',
function: {
name: mcpTool,
description: 'List tables',
parameters: { type: 'object', properties: {} },
},
},
},
};
const primaryConfig = {
...makePrimaryConfig({}),
toolRegistry: new Map([[mcpTool, { name: mcpTool }]]),
mcpAvailableTools,
};
mockInitializeAgent.mockResolvedValue(primaryConfig);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(capturedToolExecuteOptions?.loadTools).toBeInstanceOf(Function);
await capturedToolExecuteOptions.loadTools([mcpTool], PRIMARY_ID);
expect(mockLoadToolsForExecution).toHaveBeenCalledTimes(1);
expect(mockLoadToolsForExecution).toHaveBeenCalledWith(
expect.objectContaining({ mcpAvailableTools }),
);
});
it('deduplicates repeated ids in subagents.agent_ids', async () => {
const subAgent = await createAgent({
id: DUPLICATE_SUBAGENT_ID,
name: 'Dup Subagent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
});
await grantView(subAgent);
const primaryConfig = makePrimaryConfig({
subagents: {
enabled: true,
allowSelf: false,
/** Same id three times — the backend must not load the agent
* repeatedly and must not emit three SubagentConfig entries. */
agent_ids: [DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID],
},
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
/** Repeated ids produce one lightweight descriptor and no eager child initialization. */
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
expect(agentClientArgs.agent.lazySubagentConfigs).toHaveLength(1);
});
it('does not initialize a descriptor after its SDK cancellation signal aborts', async () => {
await createViewableAgent(SUBAGENT_ID);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
const controller = new AbortController();
controller.abort(new Error('cancelled'));
await expect(
agentClientArgs.agent.lazySubagentConfigs[0].resolve({ signal: controller.signal }),
).rejects.toThrow('cancelled');
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
});
it('rejects promptly when cancellation occurs during lazy initialization', async () => {
await createViewableAgent(SUBAGENT_ID);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] },
});
const initialization = deferred();
const initializationStarted = deferred();
mockInitializeAgent.mockResolvedValueOnce(primaryConfig).mockImplementationOnce((params) => {
initializationStarted.resolve(params);
return initialization.promise;
});
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
const controller = new AbortController();
const resolution = agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: controller.signal,
});
const selectedInitParams = await initializationStarted.promise;
controller.abort(new Error('cancelled in flight'));
await expect(resolution).rejects.toThrow('cancelled in flight');
expect(selectedInitParams.loadTools).not.toBe(mockInitializeAgent.mock.calls[0][0].loadTools);
initialization.resolve(makeSubagentConfig(SUBAGENT_ID));
});
it('resolves a graph subagent as an isolated all-member team', async () => {
const memberIds = ['agent_graph_researcher', 'agent_graph_writer'];
for (const memberId of memberIds) {
await createViewableAgent(memberId);
}
const definition = {
type: 'research_team',
name: 'Research team',
description: 'Researches and writes a final answer',
agent_ids: memberIds,
edges: [{ from: memberIds[0], to: memberIds[1], edgeType: 'direct' }],
entry_agent_id: memberIds[0],
result_agent_id: memberIds[1],
};
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, graphs: [definition] },
});
const memberConfigs = new Map(
memberIds.map((id, index) => [
id,
{
...makeSubagentConfig(id),
userMCPAuthMap: { [`server_${index}`]: { token: `token_${index}` } },
},
]),
);
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : memberConfigs.get(agent.id)),
);
const req = makeSubagentReq();
req.config.endpoints.agents.capabilities.push('run_in_background');
const { userMCPAuthMap } = await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([
{
definition,
memberConfigs: memberIds.map((id) => memberConfigs.get(id)),
},
]);
expect(memberIds.every((id) => !agentClientArgs.agentConfigs.has(id))).toBe(true);
expect(agentClientArgs.subagentTasks).toBe(capturedToolExecuteOptions.subagentTasks);
expect(userMCPAuthMap).toEqual({
server_0: { token: 'token_0' },
server_1: { token: 'token_1' },
});
});
it('resolves graph teams only after their lazy parent is selected', async () => {
const childId = 'agent_lazy_graph_parent';
const memberId = 'agent_lazy_graph_member';
const definition = {
type: 'lazy_team',
name: 'Lazy team',
description: 'Loads with its selected parent',
agent_ids: [childId, memberId],
edges: [{ from: childId, to: memberId, edgeType: 'direct' }],
entry_agent_id: childId,
result_agent_id: memberId,
};
await createViewableAgent(childId, {
enabled: true,
allowSelf: false,
graphs: [definition],
});
const memberAgent = await createAgent({
id: memberId,
name: memberId,
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: ['execute_code'],
stateful_code_sessions: true,
});
await grantView(memberAgent);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [childId] },
});
const memberMCPAuthMap = { late_server: { token: 'late_token' } };
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(
agent.id === PRIMARY_ID
? primaryConfig
: {
...makeSubagentConfig(agent.id),
subagents: agent.subagents,
...(agent.id === memberId ? { userMCPAuthMap: memberMCPAuthMap } : {}),
},
),
);
const req = makeSubagentReq();
req.config.endpoints.agents.capabilities.push(
'execute_code',
'run_in_background',
'tool_intents',
'stateful_code_sessions',
);
const { userMCPAuthMap } = await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
const descriptor = agentClientArgs.agent.lazySubagentConfigs[0];
expect(descriptor.subagentGraphMemberMetadata).toEqual([
expect.objectContaining({
id: memberId,
codeEnvAvailable: true,
statefulCodeSessions: true,
}),
]);
expect(userMCPAuthMap).toEqual({});
const resolvedChild = await descriptor.resolve({
signal: new AbortController().signal,
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(3);
expect(mockInitializeAgent).toHaveBeenCalledWith(
expect.objectContaining({
agent: expect.objectContaining({ id: memberId }),
backgroundToolsAvailable: true,
toolIntentsAvailable: true,
}),
expect.anything(),
);
expect(resolvedChild.subagentGraphConfigs).toEqual([
{
definition,
memberConfigs: [resolvedChild, expect.objectContaining({ id: memberId })],
},
]);
expect(userMCPAuthMap).toEqual(memberMCPAuthMap);
});
it('aborts lazy graph member initialization with the descriptor signal', async () => {
const childId = 'agent_lazy_cancel_parent';
const memberId = 'agent_lazy_cancel_member';
const definition = {
type: 'lazy_cancel_team',
name: 'Lazy cancel team',
description: 'Stops member initialization with its selected parent',
agent_ids: [memberId],
edges: [],
entry_agent_id: memberId,
result_agent_id: memberId,
};
await createViewableAgent(childId, {
enabled: true,
allowSelf: false,
graphs: [definition],
});
await createViewableAgent(memberId);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [childId] },
});
const memberInitialization = deferred();
const memberStarted = deferred();
mockInitializeAgent.mockImplementation(({ agent }) => {
if (agent.id === PRIMARY_ID) return Promise.resolve(primaryConfig);
if (agent.id === childId) {
return Promise.resolve({ ...makeSubagentConfig(childId), subagents: agent.subagents });
}
memberStarted.resolve();
return memberInitialization.promise;
});
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
const controller = new AbortController();
const resolution = agentClientArgs.agent.lazySubagentConfigs[0].resolve({
signal: controller.signal,
});
await memberStarted.promise;
controller.abort(new Error('cancelled graph resolution'));
await expect(resolution).rejects.toThrow('cancelled graph resolution');
memberInitialization.resolve(makeSubagentConfig(memberId));
});
it('coalesces shared graph member initialization across parallel lazy resolutions', async () => {
const childIds = ['agent_lazy_shared_parent_a', 'agent_lazy_shared_parent_b'];
const memberId = 'agent_lazy_shared_member';
for (const childId of childIds) {
await createViewableAgent(childId, {
enabled: true,
allowSelf: false,
graphs: [
{
type: `shared_team_${childId}`,
name: `Shared team ${childId}`,
description: 'Loads one shared member',
agent_ids: [memberId],
edges: [],
entry_agent_id: memberId,
result_agent_id: memberId,
},
],
});
}
await createViewableAgent(memberId);
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: childIds },
});
const memberStarted = deferred();
const memberRelease = deferred();
mockInitializeAgent.mockImplementation(async ({ agent }) => {
if (agent.id === PRIMARY_ID) return primaryConfig;
if (childIds.includes(agent.id)) {
return { ...makeSubagentConfig(agent.id), subagents: agent.subagents };
}
memberStarted.resolve();
await memberRelease.promise;
return makeSubagentConfig(memberId);
});
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
const resolutions = agentClientArgs.agent.lazySubagentConfigs.map((descriptor) =>
descriptor.resolve({ signal: new AbortController().signal }),
);
await memberStarted.promise;
memberRelease.resolve();
const resolvedChildren = await Promise.all(resolutions);
expect(
mockInitializeAgent.mock.calls.filter(([{ agent }]) => agent.id === memberId),
).toHaveLength(1);
expect(resolvedChildren).toHaveLength(2);
expect(
resolvedChildren.every(
(child) => child.subagentGraphConfigs[0].memberConfigs[0].id === memberId,
),
).toBe(true);
});
it('loads independent graph members concurrently', async () => {
const memberIds = ['agent_graph_parallel_a', 'agent_graph_parallel_b'];
for (const memberId of memberIds) {
await createViewableAgent(memberId);
}
const definition = {
type: 'parallel_team',
name: 'Parallel team',
description: 'Loads independent members concurrently',
agent_ids: memberIds,
edges: [{ from: memberIds[0], to: memberIds[1], edgeType: 'direct' }],
entry_agent_id: memberIds[0],
result_agent_id: memberIds[1],
};
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, graphs: [definition] },
});
const memberStarts = new Map(memberIds.map((id) => [id, deferred()]));
const memberReleases = new Map(memberIds.map((id) => [id, deferred()]));
mockInitializeAgent.mockImplementation(async ({ agent }) => {
if (agent.id === PRIMARY_ID) {
return primaryConfig;
}
memberStarts.get(agent.id)?.resolve();
await memberReleases.get(agent.id)?.promise;
return makeSubagentConfig(agent.id);
});
const getAgentWithVersionCount = jest.spyOn(db, 'getAgentWithVersionCount');
const initialization = initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
await Promise.all(memberIds.map((id) => memberStarts.get(id)?.promise));
for (const release of memberReleases.values()) {
release.resolve();
}
await initialization;
expect(agentClientArgs.agent.subagentGraphConfigs[0].memberConfigs).toHaveLength(2);
for (const memberId of memberIds) {
expect(
getAgentWithVersionCount.mock.calls.filter(([query]) => query.id === memberId),
).toHaveLength(1);
}
});
it('reuses the primary config when the parent is a graph member', async () => {
const memberId = 'agent_graph_self_worker';
await createViewableAgent(memberId);
const definition = {
type: 'self_team',
name: 'Self team',
description: 'Uses the parent as the entry member',
agent_ids: [PRIMARY_ID, memberId],
edges: [{ from: PRIMARY_ID, to: memberId, edgeType: 'direct' }],
entry_agent_id: PRIMARY_ID,
result_agent_id: memberId,
};
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, graphs: [definition] },
});
const memberConfig = makeSubagentConfig(memberId);
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : memberConfig),
);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(2);
expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([
{ definition, memberConfigs: [primaryConfig, memberConfig] },
]);
expect(agentClientArgs.agentConfigs.has(PRIMARY_ID)).toBe(false);
});
it('keeps an added-conversation agent that is also a graph member', async () => {
const addedAgentId = 'agent_added_graph_member';
const definition = {
type: 'added_member_team',
name: 'Added member team',
description: 'Runs the selected parallel agent as a graph member',
agent_ids: [addedAgentId],
edges: [],
entry_agent_id: addedAgentId,
result_agent_id: addedAgentId,
};
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, graphs: [definition] },
});
const addedConfig = makeSubagentConfig(addedAgentId);
mockInitializeAgent.mockResolvedValue(primaryConfig);
processAddedConvo.mockImplementationOnce(async ({ agentConfigs }) => {
agentConfigs.set(addedAgentId, addedConfig);
return { userMCPAuthMap: undefined };
});
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.agentConfigs.get(addedAgentId)).toBe(addedConfig);
expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([
{ definition, memberConfigs: [addedConfig] },
]);
});
it('skips the whole graph subagent when one persisted member is missing', async () => {
const existingId = 'agent_graph_existing';
const missingId = 'agent_graph_missing';
await createViewableAgent(existingId);
const definition = {
type: 'incomplete_team',
name: 'Incomplete team',
description: 'Legacy graph with a deleted member',
agent_ids: [existingId, missingId],
edges: [{ from: existingId, to: missingId, edgeType: 'direct' }],
entry_agent_id: existingId,
result_agent_id: missingId,
};
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, graphs: [definition] },
});
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : makeSubagentConfig(agent.id)),
);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.agent.subagentGraphConfigs).toEqual([]);
expect(agentClientArgs.agentConfigs.has(existingId)).toBe(false);
expect(logger.warn).toHaveBeenCalledWith(
'[initializeClient] Skipping incomplete graph subagent',
expect.objectContaining({ graphType: 'incomplete_team', resolvedMemberCount: 1 }),
);
});
it('counts initialized members from incomplete graph teams against the global node limit', async () => {
const retainedMemberIds = Array.from(
{ length: 31 },
(_, index) => `agent_incomplete_retained_${index}`,
);
const overflowMemberIds = Array.from(
{ length: 20 },
(_, index) => `agent_incomplete_overflow_${index}`,
);
await Promise.all(
[...retainedMemberIds, ...overflowMemberIds].map((id) => createViewableAgent(id)),
);
const definitions = [
{
type: 'incomplete_retained_team',
name: 'Incomplete retained team',
description: 'Loads members before discovering a missing result',
agent_ids: [...retainedMemberIds, 'agent_incomplete_missing_result'],
edges: [],
entry_agent_id: retainedMemberIds[0],
result_agent_id: 'agent_incomplete_missing_result',
},
{
type: 'incomplete_overflow_team',
name: 'Incomplete overflow team',
description: 'Must be rejected before loading any more members',
agent_ids: [...overflowMemberIds, 'agent_incomplete_missing_overflow'],
edges: [],
entry_agent_id: overflowMemberIds[0],
result_agent_id: 'agent_incomplete_missing_overflow',
},
];
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, graphs: definitions },
});
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : makeSubagentConfig(agent.id)),
);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(mockInitializeAgent).toHaveBeenCalledTimes(1 + retainedMemberIds.length);
expect(logger.warn).toHaveBeenCalledWith(
'[initializeClient] Subagent graph node limit exceeded',
expect.objectContaining({
loadedSubagentCount: retainedMemberIds.length + 1,
stagedSubagentCount: overflowMemberIds.length + 1,
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
}),
);
});
it('rejects nested subagent chains deeper than MAX_SUBAGENT_DEPTH', async () => {
const ids = Array.from(
{ length: MAX_SUBAGENT_DEPTH + 1 },
(_, index) => `agent_depth_${index}`,
);
for (const [index, id] of ids.entries()) {
await createViewableAgent(id, {
enabled: true,
allowSelf: false,
agent_ids: index < ids.length - 1 ? [ids[index + 1]] : [],
});
}
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: [ids[0]] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
expect(logger.warn).toHaveBeenCalledWith(
'[initializeClient] Subagent graph depth limit exceeded',
expect.objectContaining({
agentId: ids[MAX_SUBAGENT_DEPTH - 1],
primaryAgentId: PRIMARY_ID,
depth: MAX_SUBAGENT_DEPTH,
maxSubagentDepth: MAX_SUBAGENT_DEPTH,
}),
);
expect(agentClientArgs).toBeUndefined();
});
it('preserves deeper path depth when a handoff root is also a nested subagent', async () => {
const chainIds = Array.from(
{ length: MAX_SUBAGENT_DEPTH },
(_, index) => `agent_overlap_depth_${index}`,
);
await createViewableAgent(HANDOFF_AND_SUB_ID, {
enabled: true,
allowSelf: false,
agent_ids: [chainIds[0]],
});
for (const [index, id] of chainIds.entries()) {
await createViewableAgent(id, {
enabled: true,
allowSelf: false,
agent_ids: index < chainIds.length - 1 ? [chainIds[index + 1]] : [],
});
}
const edges = [{ from: PRIMARY_ID, to: HANDOFF_AND_SUB_ID, edgeType: 'handoff' }];
const primaryConfig = makePrimaryConfig({
edges,
subagents: { enabled: true, allowSelf: false, agent_ids: [HANDOFF_AND_SUB_ID] },
});
const sharedConfig = makeNestedSubagentConfig(HANDOFF_AND_SUB_ID, [chainIds[0]]);
mockInitializeAgent.mockImplementation(({ agent }) =>
Promise.resolve(agent.id === PRIMARY_ID ? primaryConfig : sharedConfig),
);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum depth of ${MAX_SUBAGENT_DEPTH}`);
expect(agentClientArgs).toBeUndefined();
});
it('rejects subagent graphs that exceed MAX_SUBAGENT_GRAPH_NODES unique agents', async () => {
const firstLevelIds = Array.from({ length: 10 }, (_, index) => `agent_graph_${index}`);
const secondLevelIdsByParent = new Map(
firstLevelIds.map((id) => [
id,
Array.from({ length: 5 }, (_, index) => `${id}_child_${index}`),
]),
);
for (const id of firstLevelIds) {
await createViewableAgent(id, {
enabled: true,
allowSelf: false,
agent_ids: secondLevelIdsByParent.get(id),
});
}
for (const id of Array.from(secondLevelIdsByParent.values()).flat()) {
await createViewableAgent(id, { enabled: true, allowSelf: false, agent_ids: [] });
}
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: firstLevelIds },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents`);
expect(logger.warn).toHaveBeenCalledWith(
'[initializeClient] Subagent graph node limit exceeded',
expect.objectContaining({
primaryAgentId: PRIMARY_ID,
loadedSubagentCount: MAX_SUBAGENT_GRAPH_NODES,
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
}),
);
expect(agentClientArgs).toBeUndefined();
});
it('bounds capability metadata reads across lazy graph teams', async () => {
const firstChildId = 'agent_lazy_graph_cap_first';
const secondChildId = 'agent_lazy_graph_cap_second';
const firstMemberIds = Array.from({ length: 32 }, (_, index) => `agent_cap_first_${index}`);
const secondMemberIds = Array.from({ length: 17 }, (_, index) => `agent_cap_second_${index}`);
const makeGraph = (type, memberIds) => ({
type,
name: type,
description: type,
agent_ids: memberIds,
edges: memberIds.slice(0, -1).map((memberId, index) => ({
from: memberId,
to: memberIds[index + 1],
edgeType: 'direct',
})),
entry_agent_id: memberIds[0],
result_agent_id: memberIds.at(-1),
});
await createViewableAgent(firstChildId, {
enabled: true,
allowSelf: false,
graphs: [makeGraph('first_capability_team', firstMemberIds)],
});
await createViewableAgent(secondChildId, {
enabled: true,
allowSelf: false,
graphs: [makeGraph('second_capability_team', secondMemberIds)],
});
await Promise.all(
[...firstMemberIds, ...secondMemberIds].map((memberId) => createViewableAgent(memberId)),
);
const primaryConfig = makePrimaryConfig({
subagents: {
enabled: true,
allowSelf: false,
agent_ids: [firstChildId, secondChildId],
},
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum of ${MAX_SUBAGENT_GRAPH_NODES} unique agents`);
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
'[initializeClient] Subagent graph node limit exceeded',
expect.objectContaining({
loadedSubagentCount: 34,
stagedSubagentCount: secondMemberIds.length,
maxSubagentGraphNodes: MAX_SUBAGENT_GRAPH_NODES,
}),
);
});
it('rejects a branching DAG that exceeds expanded descriptor capacity', async () => {
const width = 3;
const layers = Array.from({ length: MAX_SUBAGENT_DEPTH }, (_, level) =>
Array.from({ length: width }, (_, index) => `agent_descriptor_${level}_${index}`),
);
for (let level = 0; level < layers.length; level++) {
const childIds = level < layers.length - 1 ? layers[level + 1] : [];
for (const id of layers[level]) {
await createViewableAgent(id, { enabled: true, allowSelf: false, agent_ids: childIds });
}
}
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: false, agent_ids: layers[0] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await expect(
initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
}),
).rejects.toThrow(`maximum of ${MAX_SUBAGENT_RUN_CONFIGS} expanded entries`);
expect(logger.warn).toHaveBeenCalledWith(
'[initializeClient] Subagent run configuration limit exceeded',
expect.objectContaining({
expandedConfigCount: MAX_SUBAGENT_RUN_CONFIGS + 1,
maxSubagentRunConfigs: MAX_SUBAGENT_RUN_CONFIGS,
rootAgentIds: [PRIMARY_ID],
}),
);
expect(agentClientArgs).toBeUndefined();
});
it('keeps an agent in `agentConfigs` when it is BOTH a handoff target and a subagent', async () => {
/** Overlap case: the same child is used both via handoff edges (needs to
* be in agentConfigs) and as a subagent (needs to be in
* subagentAgentConfigs, and its tool context preserved). The pipeline
* shouldn't silently drop it from the handoff map. */
const shared = await createAgent({
id: HANDOFF_AND_SUB_ID,
name: 'Shared Agent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
});
await grantView(shared);
const edges = [{ from: PRIMARY_ID, to: HANDOFF_AND_SUB_ID, edgeType: 'handoff' }];
const primaryConfig = makePrimaryConfig({
edges,
subagents: {
enabled: true,
allowSelf: false,
agent_ids: [HANDOFF_AND_SUB_ID],
},
});
const sharedConfig = makeSubagentConfig(HANDOFF_AND_SUB_ID);
let call = 0;
mockInitializeAgent.mockImplementation(() =>
Promise.resolve(++call === 1 ? primaryConfig : sharedConfig),
);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1);
/** Shared agent must stay in agentConfigs — it's still the handoff target. */
expect(agentClientArgs.agentConfigs.has(HANDOFF_AND_SUB_ID)).toBe(true);
});
it('clears subagents config on primary when the capability is disabled', async () => {
/** Admin can turn subagents off at the endpoint level even if an agent was
* configured for them. The primary's `subagents` field should be
* suppressed so run.ts never builds a SubagentConfig. */
const primaryConfig = makePrimaryConfig({
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
const req = makeSubagentReq();
/** Remove the capability from the admin config. */
req.config.endpoints.agents.capabilities = [];
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
expect(agentClientArgs.agent.subagents).toBeUndefined();
expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]);
});
it('clears subagents on handoff agents too when capability is disabled (Codex P2 regression)', async () => {
/** Codex P2: the capability gate must suppress `subagents` on EVERY
* loaded config, not just the primary. `run.ts` iterates all agents
* and calls `buildSubagentConfigs` per agent, so a handoff target
* with `subagents.enabled: true` persisted on its document would
* otherwise still expose self-spawn even when the admin has disabled
* the capability globally. */
const authorized = await createAgent({
id: AUTHORIZED_ID,
name: 'Handoff Agent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
tools: [],
});
await grantView(authorized);
const edges = [{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }];
const primaryConfig = makePrimaryConfig({
edges,
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
});
const handoffConfig = {
id: AUTHORIZED_ID,
endpoint: 'agents',
edges: [],
toolDefinitions: [],
toolRegistry: new Map(),
userMCPAuthMap: null,
tool_resources: {},
/** Handoff agent document has subagents enabled of its own accord. */
subagents: { enabled: true, allowSelf: true, agent_ids: [] },
};
let callCount = 0;
mockInitializeAgent.mockImplementation(() =>
Promise.resolve(++callCount === 1 ? primaryConfig : handoffConfig),
);
const req = makeSubagentReq();
/** Capability OFF at endpoint level. */
req.config.endpoints.agents.capabilities = [];
await initializeClient({
req,
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
/** Primary cleared. */
expect(agentClientArgs.agent.subagents).toBeUndefined();
/** Handoff target must ALSO be cleared — otherwise its self-spawn
* would still fire when run.ts iterates agentConfigs. */
const handoffLoaded = agentClientArgs.agentConfigs.get(AUTHORIZED_ID);
expect(handoffLoaded).toBeDefined();
expect(handoffLoaded.subagents).toBeUndefined();
expect(handoffLoaded.subagentAgentConfigs).toBeUndefined();
});
it('skips subagent loading entirely when the feature is disabled on the agent', async () => {
const primaryConfig = makePrimaryConfig({
subagents: { enabled: false, allowSelf: true, agent_ids: [SUBAGENT_ID] },
});
mockInitializeAgent.mockResolvedValue(primaryConfig);
await initializeClient({
req: makeSubagentReq(),
res: {},
signal: new AbortController().signal,
endpointOption: makeEndpointOption(),
});
/** Only one initializeAgent call — for the primary. No subagent loaded. */
expect(mockInitializeAgent).toHaveBeenCalledTimes(1);
expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]);
});
});