From 9f99ce559912b11bfc34a0fc22d396d8406e2958 Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:50:00 +0100 Subject: [PATCH 1/8] test(thread-orchestration): cover thread-control requests with unrelated negations Reproduces #2267. Both prompts from the issue are denied today, and a denied call gives no reason the model or user can act on. --- .../__tests__/thread-control-toolkit.test.ts | 12 ++++++---- .../src/__tests__/mcode-instructions.test.ts | 22 +++++++++++++++++-- 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts b/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts index 587b998ad..cb370596d 100644 --- a/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts +++ b/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts @@ -21,7 +21,8 @@ const session: McodeSession = { threadControl: true, }; -const DENIED = "Internal thread-control MCP request denied"; +const NOT_REQUESTED = /^Mcode thread control is off for this turn because the user's message did not explicitly ask for it\./; +const NO_ACTIVE_TURN = /^Mcode thread control is unavailable because no user turn is active for this session\./; const thread = { workspaceId: "workspace-1", @@ -143,12 +144,15 @@ describe("thread-control toolkit", () => { expect(setup({ threadControl: false }).bound).toBeNull(); }); - it.each(["none", "ineligible"] as const)("refuses every call when the turn lease is %s", async (lease) => { + it.each([ + ["none", NO_ACTIVE_TURN], + ["ineligible", NOT_REQUESTED], + ] as const)("refuses every call when the turn lease is %s and says why", async (lease, reason) => { const { call, service } = setup({ lease }); await expect(call("workspace_search", { query: "mcode" })).resolves.toMatchObject({ isError: true, - content: [{ type: "text", text: DENIED }], + content: [{ type: "text", text: expect.stringMatching(reason) }], }); expect(service.workspaceSearch).not.toHaveBeenCalled(); }); @@ -174,7 +178,7 @@ describe("thread-control toolkit", () => { await expect(call("thread_search", { statuses: [] })).resolves.toMatchObject({ isError: true, - content: [{ type: "text", text: DENIED }], + content: [{ type: "text", text: expect.stringMatching(NO_ACTIVE_TURN) }], }); }); diff --git a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts index 38a20577e..4a1ecec37 100644 --- a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts +++ b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts @@ -32,8 +32,26 @@ describe("Mcode runtime instruction sections", () => { expect(isExplicitMcodeThreadRequest(request)).toBe(true); }); - it("fails closed for negated Mcode thread requests", () => { - expect(isExplicitMcodeThreadRequest("Do not create an Mcode thread")).toBe(false); + it.each([ + "Call delegate_task exactly once to hand this review to Codex. Do not call any other tool, and report what delegate_task returned.", + "Delegate this with delegate_task. Don't wait on it, check task_status later.", + "Use delegate_task for the review and never poll it in a loop.", + ])("grants an explicit request that also contains an unrelated negation: %s", (request) => { + expect(isExplicitMcodeThreadRequest(request)).toBe(true); + }); + + it.each([ + "Do not create an Mcode thread", + "Don't use delegate_task, just fix the bug.", + "Do not, under any circumstances, call delegate_task.", + "Without creating an Mcode thread, fix the bug and check the status.", + "Fix the bug.\nNever use delegate_task or task_status here.", + "Don't edit src/a.ts or call delegate_task.", + "Do not bump Node to 20.1 or use delegate_task.", + "Never do any of these:\n- call delegate_task\n- check task_status", + "Don't wait; call delegate_task.", + ])("fails closed when every thread-control mention is negated: %s", (request) => { + expect(isExplicitMcodeThreadRequest(request)).toBe(false); }); it.each(toolkitCombinations)("selects guidance for attached toolkits: %j", (toolkits) => { From 0a9942508f32312eeffc550c9e36c771b8759da4 Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:52:13 +0100 Subject: [PATCH 2/8] fix(thread-orchestration): scope the thread-control negation check to its sentence A negation now voids only the thread-control names after it in the same sentence, so an unrelated "don't" no longer denies an explicit request. A denied call now says why: no active turn, a message that did not ask for thread control, or an invalid tool call id. Fixes #2267 --- .../mcode-mcp/mcode-session-authority.ts | 26 +++++++++++++------ .../src/mcode-instructions.ts | 19 +++++++++----- 2 files changed, 30 insertions(+), 15 deletions(-) diff --git a/apps/server/src/features/mcode-mcp/mcode-session-authority.ts b/apps/server/src/features/mcode-mcp/mcode-session-authority.ts index 03ddca40f..591c80d24 100644 --- a/apps/server/src/features/mcode-mcp/mcode-session-authority.ts +++ b/apps/server/src/features/mcode-mcp/mcode-session-authority.ts @@ -48,10 +48,19 @@ export interface McodeSessionCredential { sessionId: string; } +/** Why a thread-control call was refused. */ +export type McodeThreadControlDenial = "invalid_tool_call" | "no_active_turn" | "not_requested"; + +const DENIAL_MESSAGES: Record = { + invalid_tool_call: "Mcode thread control denied this call because its tool call id is empty or longer than 128 characters.", + no_active_turn: "Mcode thread control is unavailable because no user turn is active for this session. Thread-control tools work only while a user turn runs.", + not_requested: "Mcode thread control is off for this turn because the user's message did not explicitly ask for it. It turns on when the message asks for an Mcode thread or names a thread-control tool such as delegate_task, with an action such as \"use\" or \"call\" and no negation such as \"don't\" before it in the same sentence. Ask the user to request it directly.", +}; + /** Refuses a thread-control call that has no active, eligible turn lease. */ export class McodeThreadControlDeniedError extends Error { - constructor() { - super("Internal thread-control MCP request denied"); + constructor(readonly reason: McodeThreadControlDenial) { + super(DENIAL_MESSAGES[reason]); } } @@ -149,9 +158,7 @@ export class McodeSessionAuthority { * @throws McodeThreadControlDeniedError when the lease is missing, revoked, or ineligible. */ authorizeThreadControl(session: McodeSession, sourceToolCallId: string): InternalThreadControlAuthority { - const authority = authorizeEntry(this.entries.get(session.sessionId), sourceToolCallId); - if (!authority) throw new McodeThreadControlDeniedError(); - return authority; + return authorizeEntry(this.entries.get(session.sessionId), sourceToolCallId); } /** Returns the revocation signal for a session while its turn lease is active. */ @@ -182,10 +189,13 @@ function activeSignal(entry: SessionEntry | undefined): AbortSignal | undefined function authorizeEntry( entry: SessionEntry | undefined, sourceToolCallId: string, -): InternalThreadControlAuthority | undefined { - if (sourceToolCallId.length < 1 || sourceToolCallId.length > 128) return undefined; +): InternalThreadControlAuthority { + if (sourceToolCallId.length < 1 || sourceToolCallId.length > 128) { + throw new McodeThreadControlDeniedError("invalid_tool_call"); + } const lease = entry?.active; - if (!lease?.active || !lease.eligible) return undefined; + if (!lease?.active) throw new McodeThreadControlDeniedError("no_active_turn"); + if (!lease.eligible) throw new McodeThreadControlDeniedError("not_requested"); return { type: "internal", userId: "local-user", diff --git a/packages/thread-orchestration/src/mcode-instructions.ts b/packages/thread-orchestration/src/mcode-instructions.ts index d0d4a2ac4..0edc3e89f 100644 --- a/packages/thread-orchestration/src/mcode-instructions.ts +++ b/packages/thread-orchestration/src/mcode-instructions.ts @@ -4,7 +4,8 @@ export const MCODE_INSTRUCTION_SECTION_MAX_BYTES = 4_000; const EXPLICIT_MCODE_THREAD_TOKEN = /\b(?:mcode(?:[-_\s]+managed)?[-_\s]+threads?|mcode[-_\s]+thread[-_\s]?control|thread_(?:target_list|create_batch|search|get|send|stop|wait)|delegate_task|task_(?:status|cancel))\b/i; -const NEGATED_MCODE_THREAD_REQUEST = /\b(?:do\s+not|don['’]?t|dont|never|without|no)\b[\s\S]{0,96}\b(?:mcode(?:[-_\s]+managed)?[-_\s]+threads?|mcode[-_\s]+thread[-_\s]?control|thread_(?:target_list|create_batch|search|get|send|stop|wait)|delegate_task|task_(?:status|cancel))\b/i; +const NEGATION = /\b(?:do\s+not|don['’]?t|dont|never|without|no)\b/i; +const SENTENCE_BOUNDARY = /[.!?]+\s+(?=[A-Z])/; const MCODE_THREAD_REQUEST_ACTION = /\b(?:create|start|open|launch|use|manage|list|search|send|stop|wait|get|target|delegate|call|check|cancel)\b/i; /** Stable section names used as Codex context tags. */ @@ -40,14 +41,18 @@ export interface BuildMcodeInstructionSectionsInput { * thread control or one of its thread operations. * * Provider-native child-agent language intentionally does not satisfy this - * boundary. Negated requests fail closed so a model cannot inherit a lease - * from wording such as "do not create an Mcode thread". + * boundary. A sentence asks for thread control only through the text before + * its first negation, so "do not create an Mcode thread" grants nothing while + * an unrelated "don't wait on it" in another sentence does not revoke an + * explicit request. A sentence ends only at terminal punctuation followed by + * whitespace and a capital letter, so paths, versions and line breaks cannot + * split a negated request into an affirmative one. */ export function isExplicitMcodeThreadRequest(content: string): boolean { - const request = content.trim(); - if (!request || NEGATED_MCODE_THREAD_REQUEST.test(request)) return false; - if (!EXPLICIT_MCODE_THREAD_TOKEN.test(request)) return false; - return MCODE_THREAD_REQUEST_ACTION.test(request); + return content.split(SENTENCE_BOUNDARY).some((sentence) => { + const affirmative = sentence.split(NEGATION, 1)[0] ?? ""; + return EXPLICIT_MCODE_THREAD_TOKEN.test(affirmative) && MCODE_THREAD_REQUEST_ACTION.test(affirmative); + }); } const IDENTITY_BLOCK = [ From 1832b6c1f216d9baf8018967ab894e76c957d26d Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:06:55 +0100 Subject: [PATCH 3/8] fix(thread-control): grant thread control without a wording check Every turn on a provider that supports thread control now gets an active lease. The user's message no longer decides whether the thread-control tools work, so requests such as "Farm this out to Codex" or prompts with an unrelated "don't" are no longer refused. The permission ceiling from ADR-0032 is the real limit and is already enforced: a supervised Coordinator cannot start a full-access delegate, and an unnamed mode inherits the Coordinator's. Codex also stops restarting its session whenever a turn's eligibility flipped, because eligibility is now fixed per provider. Fixes #2267 Part of #2218 --- .../__tests__/agent-service-gate.test.ts | 26 +++--------- .../orchestration/turn-runtime-controller.ts | 7 +--- .../turn-admission-dispatch-coordinator.ts | 29 +++++--------- .../__tests__/mcode-session-authority.test.ts | 18 +-------- .../__tests__/thread-control-toolkit.test.ts | 16 +++----- .../mcode-mcp/mcode-session-authority.ts | 13 ++---- .../contracts/src/providers/interfaces.ts | 2 +- .../src/__tests__/mcode-instructions.test.ts | 40 ------------------- packages/thread-orchestration/src/index.ts | 1 - .../src/mcode-instructions.ts | 24 ----------- 10 files changed, 26 insertions(+), 150 deletions(-) diff --git a/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts b/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts index 63bc63998..d9db2c083 100644 --- a/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts +++ b/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts @@ -395,9 +395,10 @@ describe("AgentService internal MCP provider allowlist", () => { }); it.each([ - "Spawn exactly one nested provider-native child named leaf_probe", - "Delegate this task to a provider-native subagent", - ])("revokes thread control at turn start for child-agent wording: %s", async (content) => { + "Farm this out to Codex", + "Call delegate_task exactly once to hand this review to Codex. Do not call any other tool.", + "Do not create an Mcode thread", + ])("activates thread control whatever the message says: %s", async (content) => { const { svc, threadControlMcp } = buildService(); await svc.sendMessage({ @@ -409,26 +410,11 @@ describe("AgentService internal MCP provider allowlist", () => { provider: "codex", }); - expect(threadControlMcp.activate).not.toHaveBeenCalled(); - expect(threadControlMcp.revoke).toHaveBeenCalledWith(`mcode-${THREAD_ID}`); - }); - - it("activates thread control only for an explicit Mcode thread request", async () => { - const { svc, threadControlMcp } = buildService(); - - await svc.sendMessage({ - threadId: THREAD_ID, - content: "Create one Mcode thread named leaf_probe", - permissionMode: "default", - model: "claude-sonnet-4-6", - attachments: [], - provider: "codex", - }); - expect(threadControlMcp.revoke).not.toHaveBeenCalled(); expect(threadControlMcp.activate).toHaveBeenCalledWith(expect.objectContaining({ sessionId: `mcode-${THREAD_ID}`, - eligible: true, + sourceThreadId: THREAD_ID, + permissionMode: "supervised", })); }); }); diff --git a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts index 16a1505e0..c3d75ebb7 100644 --- a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts +++ b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts @@ -1067,12 +1067,7 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE /** Apply the provider-neutral thread-control directive selected during admission. */ private applyThreadControlLease(directive: ThreadControlLeaseDirective | null): void { - if (!directive) return; - if (directive.kind === "revoke") { - this.threadControlMcp?.revoke(directive.sessionId); - return; - } - this.threadControlMcp?.activate({ ...directive, eligible: true }); + if (directive) this.threadControlMcp?.activate(directive); } /** Send one attempt and hand only retryable failures to the existing retry owner. */ diff --git a/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts b/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts index bf71e7d27..0f1292789 100644 --- a/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts +++ b/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts @@ -23,7 +23,6 @@ import { type TurnRuntimeSnapshot, } from "@mcode/contracts"; import { logger, validateBranchName } from "@mcode/shared"; -import { isExplicitMcodeThreadRequest } from "@mcode/thread-orchestration"; import { broadcast } from "../../../application/transport/push.js"; import { publishSynthesizedAgentEvents } from "../canonical/synthesized-agent-event-publication.js"; import { AttachmentService } from "../../attachments/storage/attachment-service.js"; @@ -168,16 +167,13 @@ export interface TurnParentStartOwner { } /** The thread-control action selected during generic dispatch preparation. */ -export type ThreadControlLeaseDirective = - | { - readonly kind: "activate"; - readonly sessionId: string; - readonly sourceThreadId: string; - readonly sourceTurnId: string; - readonly sourceProviderId: ProviderId; - readonly permissionMode: "supervised" | "full"; - } - | { readonly kind: "revoke"; readonly sessionId: string }; +export interface ThreadControlLeaseDirective { + readonly sessionId: string; + readonly sourceThreadId: string; + readonly sourceTurnId: string; + readonly sourceProviderId: ProviderId; + readonly permissionMode: "supervised" | "full"; +} /** A provider-ready turn package with no persistence or provider-selection work left. */ export interface PreparedTurnDispatch { @@ -1191,7 +1187,7 @@ export class TurnAdmissionDispatchCoordinator { attachments: attachments.persisted.length > 0 ? attachments.persisted : undefined, reasoningLevel: prepared.command.reasoningLevel, ...guardrails, - threadControlEligible: this.isThreadControlEligible(prepared), + threadControlEligible: supportsInternalThreadControl(prepared.providerId), resumeFrom: this.resumeFrom(prepared, attachments), ...(prepared.command.resumeRequired ? { resumeRequired: true } : {}), providerOptions: this.providerOptions(prepared, settings), @@ -1289,17 +1285,10 @@ export class TurnAdmissionDispatchCoordinator { return thread.sdk_session_id ?? undefined; } - private isThreadControlEligible(prepared: PreparedCommand): boolean { - return supportsInternalThreadControl(prepared.providerId) && isExplicitMcodeThreadRequest(prepared.command.content); - } - private threadControlDirective(prepared: PreparedCommand, sourceTurnId: string): ThreadControlLeaseDirective | null { if (!supportsInternalThreadControl(prepared.providerId)) return null; - const sessionId = `mcode-${prepared.command.threadId}`; - if (!this.isThreadControlEligible(prepared)) return { kind: "revoke", sessionId }; return { - kind: "activate", - sessionId, + sessionId: `mcode-${prepared.command.threadId}`, sourceThreadId: prepared.command.threadId, sourceTurnId, sourceProviderId: prepared.providerId, diff --git a/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts b/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts index 3d8301680..6a043df0d 100644 --- a/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts +++ b/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts @@ -55,20 +55,6 @@ describe("McodeSessionAuthority", () => { expect(authority.signal("mcode-source")).toBeUndefined(); }); - it("denies an ineligible turn from the start without inheriting a prior grant", () => { - const authority = new McodeSessionAuthority(); - authority.activate(turn({ sourceTurnId: "child-turn", eligible: false })); - expect(() => authority.authorizeThreadControl(session, "child-mcp-call")).toThrow(McodeThreadControlDeniedError); - - authority.activate(turn({ sourceTurnId: "explicit-turn", eligible: true })); - expect(authority.authorizeThreadControl(session, "parent-mcp-call")).toMatchObject({ - sourceTurnId: "explicit-turn", - }); - - authority.activate(turn({ sourceTurnId: "next-child-turn", eligible: false })); - expect(() => authority.authorizeThreadControl(session, "next-child-mcp-call")).toThrow(McodeThreadControlDeniedError); - }); - it("rejects unequal credential lengths before constant-time comparison", () => { expect(constantTimeCredentialEqual("credential", "credential")).toBe(true); expect(constantTimeCredentialEqual("credential", "credential-extra")).toBe(false); @@ -146,7 +132,7 @@ describe("McodeSessionAuthority", () => { expect(authority.signal("mcode-source")?.aborted).toBe(false); }); - it("refuses thread control without an active eligible lease and bounds the tool call id", () => { + it("refuses thread control without an active lease and bounds the tool call id", () => { const authority = new McodeSessionAuthority(); const { token } = authority.attach(attachRequest()); const session = authority.resolve(token)!; @@ -156,8 +142,6 @@ describe("McodeSessionAuthority", () => { expect(authority.authorizeThreadControl(session, "call")).toMatchObject({ sourceThreadId: "source" }); expect(() => authority.authorizeThreadControl(session, "")).toThrow(McodeThreadControlDeniedError); expect(() => authority.authorizeThreadControl(session, "x".repeat(129))).toThrow(McodeThreadControlDeniedError); - authority.activate(turn({ eligible: false })); - expect(() => authority.authorizeThreadControl(session, "call")).toThrow(McodeThreadControlDeniedError); }); it("scopes thread-control authority to the resolving session", () => { diff --git a/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts b/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts index cb370596d..65f188dbe 100644 --- a/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts +++ b/apps/server/src/features/mcode-mcp/__tests__/thread-control-toolkit.test.ts @@ -21,7 +21,6 @@ const session: McodeSession = { threadControl: true, }; -const NOT_REQUESTED = /^Mcode thread control is off for this turn because the user's message did not explicitly ask for it\./; const NO_ACTIVE_TURN = /^Mcode thread control is unavailable because no user turn is active for this session\./; const thread = { @@ -58,17 +57,15 @@ function createService() { } satisfies ThreadControlToolService; } -function setup(options: { lease?: "active" | "ineligible" | "none"; threadControl?: boolean } = {}) { +function setup(options: { lease?: "active" | "none"; threadControl?: boolean } = {}) { const authority = new McodeSessionAuthority(); - const lease = options.lease ?? "active"; - if (lease !== "none") { + if (options.lease !== "none") { authority.activate({ sessionId: session.sessionId, sourceThreadId: session.threadId, sourceTurnId: "source-turn", sourceProviderId: "claude", permissionMode: "full", - eligible: lease === "active", }); } const service = createService(); @@ -144,15 +141,12 @@ describe("thread-control toolkit", () => { expect(setup({ threadControl: false }).bound).toBeNull(); }); - it.each([ - ["none", NO_ACTIVE_TURN], - ["ineligible", NOT_REQUESTED], - ] as const)("refuses every call when the turn lease is %s and says why", async (lease, reason) => { - const { call, service } = setup({ lease }); + it("refuses every call without a turn lease and says why", async () => { + const { call, service } = setup({ lease: "none" }); await expect(call("workspace_search", { query: "mcode" })).resolves.toMatchObject({ isError: true, - content: [{ type: "text", text: expect.stringMatching(reason) }], + content: [{ type: "text", text: expect.stringMatching(NO_ACTIVE_TURN) }], }); expect(service.workspaceSearch).not.toHaveBeenCalled(); }); diff --git a/apps/server/src/features/mcode-mcp/mcode-session-authority.ts b/apps/server/src/features/mcode-mcp/mcode-session-authority.ts index 591c80d24..17ee7fcde 100644 --- a/apps/server/src/features/mcode-mcp/mcode-session-authority.ts +++ b/apps/server/src/features/mcode-mcp/mcode-session-authority.ts @@ -9,8 +9,6 @@ interface ActiveLease { sourceProviderId: string; permissionMode: "supervised" | "full"; active: boolean; - /** Whether the current user request explicitly authorized Mcode thread control. */ - eligible: boolean; controller: AbortController; } @@ -38,8 +36,6 @@ export interface ActivateMcodeSessionLease { sourceTurnId: string; sourceProviderId: string; permissionMode: "supervised" | "full"; - /** Explicit per-turn eligibility derived from the original user request. */ - eligible?: boolean; } /** Opaque credential and stable session binding supplied to a provider transport. */ @@ -49,15 +45,14 @@ export interface McodeSessionCredential { } /** Why a thread-control call was refused. */ -export type McodeThreadControlDenial = "invalid_tool_call" | "no_active_turn" | "not_requested"; +export type McodeThreadControlDenial = "invalid_tool_call" | "no_active_turn"; const DENIAL_MESSAGES: Record = { invalid_tool_call: "Mcode thread control denied this call because its tool call id is empty or longer than 128 characters.", no_active_turn: "Mcode thread control is unavailable because no user turn is active for this session. Thread-control tools work only while a user turn runs.", - not_requested: "Mcode thread control is off for this turn because the user's message did not explicitly ask for it. It turns on when the message asks for an Mcode thread or names a thread-control tool such as delegate_task, with an action such as \"use\" or \"call\" and no negation such as \"don't\" before it in the same sentence. Ask the user to request it directly.", }; -/** Refuses a thread-control call that has no active, eligible turn lease. */ +/** Refuses a thread-control call that has no active turn lease. */ export class McodeThreadControlDeniedError extends Error { constructor(readonly reason: McodeThreadControlDenial) { super(DENIAL_MESSAGES[reason]); @@ -119,7 +114,6 @@ export class McodeSessionAuthority { sourceTurnId: input.sourceTurnId, sourceProviderId: input.sourceProviderId, permissionMode: input.permissionMode, - eligible: input.eligible ?? true, active: true, controller: new AbortController(), }; @@ -155,7 +149,7 @@ export class McodeSessionAuthority { /** * Builds the thread-control authority from the session's turn lease. - * @throws McodeThreadControlDeniedError when the lease is missing, revoked, or ineligible. + * @throws McodeThreadControlDeniedError when the lease is missing or revoked. */ authorizeThreadControl(session: McodeSession, sourceToolCallId: string): InternalThreadControlAuthority { return authorizeEntry(this.entries.get(session.sessionId), sourceToolCallId); @@ -195,7 +189,6 @@ function authorizeEntry( } const lease = entry?.active; if (!lease?.active) throw new McodeThreadControlDeniedError("no_active_turn"); - if (!lease.eligible) throw new McodeThreadControlDeniedError("not_requested"); return { type: "internal", userId: "local-user", diff --git a/packages/contracts/src/providers/interfaces.ts b/packages/contracts/src/providers/interfaces.ts index 23f8d7236..6783498ed 100644 --- a/packages/contracts/src/providers/interfaces.ts +++ b/packages/contracts/src/providers/interfaces.ts @@ -129,7 +129,7 @@ export interface TurnRequest

{ maxBudgetUsd?: number; /** Max agent turns for this Turn. Undefined or 0 disables. */ maxTurns?: number; - /** Whether this turn's user request explicitly authorizes Mcode thread control. */ + /** Whether the server grants this turn Mcode thread control. */ threadControlEligible?: boolean; /** * SDK session id to resume from. When defined the Provider resumes that diff --git a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts index 4a1ecec37..d9422ef06 100644 --- a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts +++ b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts @@ -2,7 +2,6 @@ import * as NodeBuffer from "node:buffer"; import { describe, expect, it } from "vitest"; import { buildMcodeInstructionSections, - isExplicitMcodeThreadRequest, MCODE_INSTRUCTION_SECTION_MAX_BYTES, renderMcodeInstructionSections, } from "../index.js"; @@ -15,45 +14,6 @@ const toolkitCombinations = [ ]; describe("Mcode runtime instruction sections", () => { - it.each([ - "Spawn exactly one nested provider-native child named leaf_probe", - "Delegate this task to a provider-native subagent", - ])("does not grant Mcode thread control to provider-native child wording: %s", (request) => { - expect(isExplicitMcodeThreadRequest(request)).toBe(false); - }); - - it.each([ - "Create one Mcode thread named leaf_probe", - "Use thread_create_batch for the requested Mcode thread", - "Use the mcode delegate_task tool to hand this to Claude", - "Call task_status for the delegated task", - "Cancel it with task_cancel", - ])("recognizes explicit Mcode thread requests: %s", (request) => { - expect(isExplicitMcodeThreadRequest(request)).toBe(true); - }); - - it.each([ - "Call delegate_task exactly once to hand this review to Codex. Do not call any other tool, and report what delegate_task returned.", - "Delegate this with delegate_task. Don't wait on it, check task_status later.", - "Use delegate_task for the review and never poll it in a loop.", - ])("grants an explicit request that also contains an unrelated negation: %s", (request) => { - expect(isExplicitMcodeThreadRequest(request)).toBe(true); - }); - - it.each([ - "Do not create an Mcode thread", - "Don't use delegate_task, just fix the bug.", - "Do not, under any circumstances, call delegate_task.", - "Without creating an Mcode thread, fix the bug and check the status.", - "Fix the bug.\nNever use delegate_task or task_status here.", - "Don't edit src/a.ts or call delegate_task.", - "Do not bump Node to 20.1 or use delegate_task.", - "Never do any of these:\n- call delegate_task\n- check task_status", - "Don't wait; call delegate_task.", - ])("fails closed when every thread-control mention is negated: %s", (request) => { - expect(isExplicitMcodeThreadRequest(request)).toBe(false); - }); - it.each(toolkitCombinations)("selects guidance for attached toolkits: %j", (toolkits) => { const sections = buildMcodeInstructionSections({ toolkits }); const keys = sections.map((section) => section.key); diff --git a/packages/thread-orchestration/src/index.ts b/packages/thread-orchestration/src/index.ts index 20449f1da..57c32a7d4 100644 --- a/packages/thread-orchestration/src/index.ts +++ b/packages/thread-orchestration/src/index.ts @@ -63,7 +63,6 @@ export function isInternalThreadTargetAllowed( export { MCODE_INSTRUCTION_SECTION_MAX_BYTES, buildMcodeInstructionSections, - isExplicitMcodeThreadRequest, renderMcodeInstructionSections, } from "./mcode-instructions.js"; export type { diff --git a/packages/thread-orchestration/src/mcode-instructions.ts b/packages/thread-orchestration/src/mcode-instructions.ts index 0edc3e89f..650496e4a 100644 --- a/packages/thread-orchestration/src/mcode-instructions.ts +++ b/packages/thread-orchestration/src/mcode-instructions.ts @@ -3,11 +3,6 @@ /** Codex silently cuts the middle out of an additionalContext value above ~1,000 tokens (UTF-8 bytes / 4). */ export const MCODE_INSTRUCTION_SECTION_MAX_BYTES = 4_000; -const EXPLICIT_MCODE_THREAD_TOKEN = /\b(?:mcode(?:[-_\s]+managed)?[-_\s]+threads?|mcode[-_\s]+thread[-_\s]?control|thread_(?:target_list|create_batch|search|get|send|stop|wait)|delegate_task|task_(?:status|cancel))\b/i; -const NEGATION = /\b(?:do\s+not|don['’]?t|dont|never|without|no)\b/i; -const SENTENCE_BOUNDARY = /[.!?]+\s+(?=[A-Z])/; -const MCODE_THREAD_REQUEST_ACTION = /\b(?:create|start|open|launch|use|manage|list|search|send|stop|wait|get|target|delegate|call|check|cancel)\b/i; - /** Stable section names used as Codex context tags. */ export type McodeInstructionSectionKey = | "mcode_identity" @@ -36,25 +31,6 @@ export interface BuildMcodeInstructionSectionsInput { readonly nestedDelegationModel?: string; } -/** - * Returns true only when the user's current request explicitly names Mcode - * thread control or one of its thread operations. - * - * Provider-native child-agent language intentionally does not satisfy this - * boundary. A sentence asks for thread control only through the text before - * its first negation, so "do not create an Mcode thread" grants nothing while - * an unrelated "don't wait on it" in another sentence does not revoke an - * explicit request. A sentence ends only at terminal punctuation followed by - * whitespace and a capital letter, so paths, versions and line breaks cannot - * split a negated request into an affirmative one. - */ -export function isExplicitMcodeThreadRequest(content: string): boolean { - return content.split(SENTENCE_BOUNDARY).some((sentence) => { - const affirmative = sentence.split(NEGATION, 1)[0] ?? ""; - return EXPLICIT_MCODE_THREAD_TOKEN.test(affirmative) && MCODE_THREAD_REQUEST_ACTION.test(affirmative); - }); -} - const IDENTITY_BLOCK = [ "You are operating inside an Mcode-managed session.", "Mcode runtime guidance is advisory; MCP authorization and lease state remain authoritative.", From e5bdb872eee66b058912143f180302d66e89920a Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:17:09 +0100 Subject: [PATCH 4/8] fix(thread-control): activate the lease on provider-started turns --- .../__tests__/agent-service-container.test.ts | 16 +++++++++++++++- .../orchestration/turn-runtime-controller.ts | 1 + 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts b/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts index 8a3f876c9..cf300a612 100644 --- a/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts +++ b/apps/server/src/features/agents/composition/__tests__/agent-service-container.test.ts @@ -34,6 +34,7 @@ import { PlanTurnService } from "../../planning/plan-turn-service.js"; import { PlanRepo } from "../../planning/persistence/plan-repo.js"; import { ProviderTurnEventApplication } from "../../turns/provider-turn-event-application.js"; import { ProviderAvailabilityService } from "../../../providers/availability/provider-availability-service.js"; +import { McodeSessionAuthority } from "../../../mcode-mcp/mcode-session-authority.js"; import type { WorkerOwnedProviderEventResult } from "../../../providers/composition/provider-host-ports.js"; import { addClient, removeClient, subscribeClientToThread } from "../../../../application/transport/push.js"; @@ -206,7 +207,7 @@ describe("AgentService container composition", () => { }); }); - it("admits a provider-started turn with its opening notice and releases it after Ended", async () => { + it("admits a provider-started turn with its opening notice and active lease, then releases it after Ended", async () => { const sendTurn = vi.fn(async (request: TurnRequest) => { await submitCodexEvent(workerRuntime!, request, 1, { type: "turnComplete", threadId: request.threadId, turnExecutionId: request.turnExecutionId, providerId: "codex", reason: "end_turn", costUsd: null, @@ -222,8 +223,19 @@ describe("AgentService container composition", () => { await container.resolve(AgentService).sendMessage({ threadId: thread.id, content: "start a background task", provider: "codex", model: "gpt-5.5", permissionMode: "full" }); await waitFor(() => workerRuntime?.owner.current(thread.id) === undefined); + const authority = container.resolve(McodeSessionAuthority); + const { token } = authority.attach({ providerId: "codex", sessionId: `mcode-${thread.id}`, threadId: thread.id, threadControl: true }); + const session = authority.resolve(token); + if (!session) throw new Error("Provider session was not attached"); + expect(() => authority.authorizeThreadControl(session, "before-opening")) + .toThrow(expect.objectContaining({ reason: "no_active_turn" })); + const opening = await container.resolve(TurnRuntimeController).openProviderTurn({ threadId: thread.id, notice: "Background command finished" }); if (opening.kind !== "opened") throw new Error(`Provider turn was declined: ${opening.reason}`); + expect(authority.authorizeThreadControl(session, "provider-continuation")).toMatchObject({ + sourceThreadId: thread.id, + sourceTurnId: opening.routing.turnId, + }); const adopted = { threadId: opening.routing.threadId, turnId: opening.routing.turnId, turnExecutionId: opening.routing.executionId } as TurnRequest; await submitCodexEvent(workerRuntime!, adopted, 1, { type: "textDelta", threadId: thread.id, turnExecutionId: adopted.turnExecutionId, delta: "the task finished", isFinalResponse: true }); @@ -239,6 +251,8 @@ describe("AgentService container composition", () => { expect(notice).toMatchObject({ role: "user", systemNotice: { kind: "provider-turn" } }); expect(container.resolve(TurnRuntimeController).snapshot(thread.id)).toMatchObject({ phase: "completed", turnExecutionId: adopted.turnExecutionId }); expect(container.resolve(ThreadRepo).findById(thread.id)?.model).toBe("gpt-5.5"); + expect(() => authority.authorizeThreadControl(session, "after-ended")) + .toThrow(expect.objectContaining({ reason: "no_active_turn" })); }); it("admits a provider-started turn that opens while the ended turn is still releasing", async () => { diff --git a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts index c3d75ebb7..e122dc6fe 100644 --- a/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts +++ b/apps/server/src/features/agents/orchestration/turn-runtime-controller.ts @@ -2193,6 +2193,7 @@ export class TurnRuntimeController implements TurnLifecycleControl, TurnRuntimeE /** Record an adopted turn's dispatch state without sending a prompt the provider never received. */ private adoptPreparedProviderDispatch(prepared: PreparedTurnDispatch): void { + this.applyThreadControlLease(prepared.threadControl); const dispatch = this.createRetryDispatch(prepared); dispatch.dispatchStarted = true; this.turnRetryDispatchByThread.set(prepared.lease.threadId, dispatch); From f1ac17f9e9e4397e621d5ee79d58375a60e2615c Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:17:09 +0100 Subject: [PATCH 5/8] fix(thread-control): grant the lease the turn's effective permission mode --- .../__tests__/agent-service-gate.test.ts | 28 +++++++++++++++++-- .../turn-admission-dispatch-coordinator.ts | 2 +- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts b/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts index d9db2c083..0aed63b67 100644 --- a/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts +++ b/apps/server/src/features/agents/orchestration/__tests__/agent-service-gate.test.ts @@ -87,6 +87,7 @@ function buildService({ assertUsable = vi.fn(), resolveProvider = vi.fn(), threadStatus = "idle", + threadPermissionMode = null, approvalReviewSupport, threadControlMcp = { activate: vi.fn(), @@ -96,10 +97,11 @@ function buildService({ assertUsable?: ReturnType; resolveProvider?: ReturnType; threadStatus?: PersistedThreadStatus; + threadPermissionMode?: Thread["permission_mode"]; approvalReviewSupport?: ApprovalReviewSupport; threadControlMcp?: { activate: ReturnType; revoke: ReturnType }; } = {}) { - const thread = makeThread({ status: threadStatus }); + const thread = makeThread({ status: threadStatus, permission_mode: threadPermissionMode }); const database = createOwnedTestDatabase(); const { db, writer } = database; db.run("INSERT INTO workspaces (id, name, path) VALUES (?, ?, ?)", ["ws-1", "Fixture", process.cwd()]); @@ -399,7 +401,7 @@ describe("AgentService internal MCP provider allowlist", () => { "Call delegate_task exactly once to hand this review to Codex. Do not call any other tool.", "Do not create an Mcode thread", ])("activates thread control whatever the message says: %s", async (content) => { - const { svc, threadControlMcp } = buildService(); + const { svc, threadControlMcp, providerStub } = buildService(); await svc.sendMessage({ threadId: THREAD_ID, @@ -416,5 +418,27 @@ describe("AgentService internal MCP provider allowlist", () => { sourceThreadId: THREAD_ID, permissionMode: "supervised", })); + expect(providerStub.sendTurn).toHaveBeenCalledWith(expect.objectContaining({ + threadControlEligible: true, + })); + }); + + it("activates full thread control for a default command on a persisted full thread", async () => { + const { svc, threadControlMcp } = buildService({ threadPermissionMode: "full" }); + + await svc.sendMessage({ + threadId: THREAD_ID, + content: "Farm this out to Codex", + permissionMode: "default", + model: "claude-sonnet-4-6", + attachments: [], + provider: "codex", + }); + + expect(threadControlMcp.activate).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: `mcode-${THREAD_ID}`, + sourceThreadId: THREAD_ID, + permissionMode: "full", + })); }); }); diff --git a/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts b/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts index 0f1292789..dab7ce00a 100644 --- a/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts +++ b/apps/server/src/features/agents/turns/turn-admission-dispatch-coordinator.ts @@ -1292,7 +1292,7 @@ export class TurnAdmissionDispatchCoordinator { sourceThreadId: prepared.command.threadId, sourceTurnId, sourceProviderId: prepared.providerId, - permissionMode: prepared.command.permissionMode === "full" ? "full" : "supervised", + permissionMode: this.effectivePermissionMode(prepared.command.permissionMode, prepared.thread.permission_mode ?? "supervised"), }; } From 5d63e2ba14f18ace929683bd08645fb666666e22 Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:17:09 +0100 Subject: [PATCH 6/8] fix(thread-orchestration): drop the wording gate from thread-control guidance --- .../src/__tests__/mcode-instructions.test.ts | 2 +- packages/thread-orchestration/src/mcode-instructions.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts index d9422ef06..cdf12eb11 100644 --- a/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts +++ b/packages/thread-orchestration/src/__tests__/mcode-instructions.test.ts @@ -68,7 +68,7 @@ describe("Mcode runtime instruction sections", () => { const sections = buildMcodeInstructionSections({ toolkits: { threadControl: false, browser: false } }); expect(renderMcodeInstructionSections(sections)).toBe( "You are operating inside an Mcode-managed session. Mcode runtime guidance is advisory; MCP authorization and lease state remain authoritative.\n\n" - + "A child-agent or sub-agent request uses provider-native collaboration in the current turn. Use collaboration tools, preserve the exact requested brief, and keep direct and nested parent relationships intact. A child-agent request does not authorize Mcode thread control; use thread tools only for an explicit Mcode thread request.\n\n" + + "A child-agent or sub-agent request uses provider-native collaboration in the current turn. Use collaboration tools, preserve the exact requested brief, and keep direct and nested parent relationships intact. Use Mcode delegation for a child-agent request only when native collaboration cannot run the requested provider or model.\n\n" + "For broad repository searches, first list files with rg -l. Exclude fixtures and captured traces (*.ndjson) unless the task targets them, and bound line output with rg --max-columns 240 before reading matches.", ); }); diff --git a/packages/thread-orchestration/src/mcode-instructions.ts b/packages/thread-orchestration/src/mcode-instructions.ts index 650496e4a..99aa74ed1 100644 --- a/packages/thread-orchestration/src/mcode-instructions.ts +++ b/packages/thread-orchestration/src/mcode-instructions.ts @@ -39,7 +39,8 @@ const IDENTITY_BLOCK = [ // The thread-control and Browser tool descriptions repeat these rules on purpose. With deferred tool loading, a description arrives only after the model has chosen a tool. const THREAD_CONTROL_BLOCK = [ "Mcode thread control", - "- Use the mcode MCP thread_* tools only when the user explicitly asks for Mcode threads. An Mcode thread is a persistent user-visible conversation, different from provider-native child agents.", + "- The mcode MCP tools manage Mcode threads: persistent, user-visible conversations. Use delegate_task when the user asks to hand work to another provider or model, even if they never say \"Mcode thread\".", + "- A sub-agent request uses your provider-native collaboration tool when it can run the requested model. Never launch an Mcode thread just because the user said \"sub-agent\".", "- If no Project is named, use the active source thread's Project without discovery. Resolve an explicitly named Project with workspace_search. Use worktree_list only to resolve an explicit worktree or reference.", "- Delegate child work with delegate_task. Preserve the user's title and task in the prompt. Choose isolation shared for the Coordinator's checkout or worktree for a new checkout at its local commit. Retry the same input with the same clientRequestId to return the existing task.", "- Call thread_target_list before choosing a provider or model, and pass the exact returned providerId and modelId. Pass any named provider or execution setting. Never assume or enumerate providers or models.", @@ -51,7 +52,7 @@ const THREAD_CONTROL_BLOCK = [ const PROVIDER_SUBAGENT_BLOCK = [ "A child-agent or sub-agent request uses provider-native collaboration in the current turn.", "Use collaboration tools, preserve the exact requested brief, and keep direct and nested parent relationships intact.", - "A child-agent request does not authorize Mcode thread control; use thread tools only for an explicit Mcode thread request.", + "Use Mcode delegation for a child-agent request only when native collaboration cannot run the requested provider or model.", ].join(" "); const REPOSITORY_SEARCH_BLOCK = [ From 75c2b23dd9a1a89c4e876aa7fcf8fdf6e77e5a24 Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:17:09 +0100 Subject: [PATCH 7/8] test(mcode-mcp): assert thread-control denial reasons --- .../mcode-mcp/__tests__/mcode-session-authority.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts b/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts index 6a043df0d..e34360f6f 100644 --- a/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts +++ b/apps/server/src/features/mcode-mcp/__tests__/mcode-session-authority.test.ts @@ -137,11 +137,14 @@ describe("McodeSessionAuthority", () => { const { token } = authority.attach(attachRequest()); const session = authority.resolve(token)!; - expect(() => authority.authorizeThreadControl(session, "call")).toThrow(McodeThreadControlDeniedError); + expect(() => authority.authorizeThreadControl(session, "call")) + .toThrow(expect.objectContaining({ reason: "no_active_turn" })); authority.activate(turn()); expect(authority.authorizeThreadControl(session, "call")).toMatchObject({ sourceThreadId: "source" }); - expect(() => authority.authorizeThreadControl(session, "")).toThrow(McodeThreadControlDeniedError); - expect(() => authority.authorizeThreadControl(session, "x".repeat(129))).toThrow(McodeThreadControlDeniedError); + expect(() => authority.authorizeThreadControl(session, "")) + .toThrow(expect.objectContaining({ reason: "invalid_tool_call" })); + expect(() => authority.authorizeThreadControl(session, "x".repeat(129))) + .toThrow(expect.objectContaining({ reason: "invalid_tool_call" })); }); it("scopes thread-control authority to the resolving session", () => { From a818a1933877339c75d4210465245d98fba4617d Mon Sep 17 00:00:00 2001 From: "(CJ) Chukwudi Nwobodo" <142016413+chuks-qua@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:03:34 +0100 Subject: [PATCH 8/8] test(providers): match the reworded sub-agent guidance --- .../src/__tests__/codex/codex-provider-first-turn.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts b/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts index e536306a1..31e82e1c4 100644 --- a/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts +++ b/packages/providers/src/__tests__/codex/codex-provider-first-turn.test.ts @@ -599,7 +599,7 @@ describe("CodexProvider first turn on new session", () => { await new Promise((resolve) => setImmediate(resolve)); expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("provider-native collaboration"); expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("preserve the exact requested brief"); - expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("does not authorize Mcode thread control"); + expect(server.options.instructionSections?.find((section) => section.key === "mcode_provider_subagents")?.text).toContain("only when native collaboration cannot run the requested provider or model"); expect(sendTurnMock).toHaveBeenCalledWith( [{ type: "text", text: childBrief }], { model, approvalsReviewer: "user" },