diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts index 97fb058bf092f..d4f7d48c2a5d2 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.test.ts @@ -1872,6 +1872,68 @@ describe("CodexAdapterV2 post-settle continuation", () => { ), ); + // regression: #36520 MCP servers can tell a delegate_task child from a user thread + it.effect("starts delegate_task children with the subagent thread source", () => + Effect.gen(function* () { + const scenario = "delegated-thread-source"; + const preamble = codexReplayPreamble({ + nativeThreadId: "delegated-parent-thread", + nativeTurnId: "unused", + prompt: "unused", + }).slice(0, 5); + const childStart = codexReplayPreamble({ + nativeThreadId: "delegated-child-thread", + nativeTurnId: "unused", + prompt: "unused", + }) + .slice(3, 5) + .map((entry) => withReplayRequestId(entry, 3)) + .map((entry) => + entry.type === "expect_outbound" && + Predicate.isObject(entry.frame) && + Predicate.isObject(entry.frame["params"]) + ? { + ...entry, + frame: { + ...entry.frame, + params: { ...entry.frame["params"], threadSource: "subagent" }, + }, + } + : entry, + ); + const threadSources: Array = []; + const harness = yield* makeCodexReplayHarness( + makeCodexReplayTranscript({ scenario, entries: [...preamble, ...childStart] }), + () => Effect.void, + (method, params) => + Effect.sync(() => { + if (method === "thread/start" && Predicate.isObject(params)) { + threadSources.push(params["threadSource"]); + } + }), + ); + const childThreadId = (yield* IdAllocator.IdAllocatorV2).derive.delegatedTaskThread({ + commandId: CommandId.make(`mcp:${scenario}:delegate-task:child`), + }); + yield* harness.runtime.ensureThread({ + threadId: childThreadId, + modelSelection: CODEX_TEST_MODEL_SELECTION, + runtimePolicy: CODEX_TEST_RUNTIME_POLICY, + }); + assert.deepEqual(threadSources, [undefined, "subagent"]); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ); + // regression: #15839 launch-arg MCP servers survive the thread's t3-code server it.effect("replays historical and dotted MCP config while enforcing other thread config", () => Effect.gen(function* () { diff --git a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts index b6673c0b29ec7..95acbf13a3a80 100644 --- a/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts @@ -7701,15 +7701,18 @@ export const makeCodexAdapterV2 = Effect.fn("makeCodexAdapterV2")(function* ( ensureInitialized.pipe( Effect.andThen(mcpSessions.read(threadInput.threadId)), Effect.flatMap((mcpSession) => - client.request( - "thread/start", - codexThreadRuntimeParams({ + client.request("thread/start", { + ...codexThreadRuntimeParams({ threadId: threadInput.threadId, mcpSession, modelSelection: threadInput.modelSelection, runtimePolicy: threadInput.runtimePolicy, }), - ), + // Codex tags the child's MCP calls with thread_source "subagent". + ...(IdAllocator.isDelegatedTaskThread(threadInput.threadId) + ? { threadSource: "subagent" } + : {}), + }), ), Effect.map((response): OrchestrationV2ProviderThread => providerThreadFromCodexThread({ diff --git a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson index 6ba29577937a6..8f42b9cc89a8e 100644 --- a/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson +++ b/apps/server/src/orchestration-v2/testkit/fixtures/delegated_task_status/codex_transcript.ndjson @@ -11,7 +11,7 @@ {"type":"emit_inbound","label":"parent.turn/start","frame":{"id":4,"result":{"turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":null,"completedAt":null,"durationMs":null}}}} {"type":"emit_inbound","label":"parent.thread/status/changed","frame":{"method":"thread/status/changed","params":{"threadId":"019f1000-0000-7000-8000-000000000001","status":{"type":"active","activeFlags":[]}}}} {"type":"emit_inbound","label":"parent.turn/started","frame":{"method":"turn/started","params":{"threadId":"019f1000-0000-7000-8000-000000000001","turn":{"id":"019f1000-0000-7000-8000-000000000002","items":[],"status":"inProgress","error":null,"startedAt":1776739467,"completedAt":null,"durationMs":null}}}} -{"type":"expect_outbound","label":"child.thread/start","frame":{"id":5,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true}}}} +{"type":"expect_outbound","label":"child.thread/start","frame":{"id":5,"method":"thread/start","params":{"config":{"tools.update_plan.enabled":true,"mcp_optional_startup_grace_ms":10000,"features.concurrent_reasoning_summaries":true},"threadSource":"subagent"}}} {"type":"emit_inbound","label":"child.thread/start","frame":{"id":5,"result":{"thread":{"id":"019f1000-0000-7000-8000-000000000003","forkedFromId":null,"preview":"","ephemeral":false,"modelProvider":"openai","createdAt":1776739468,"updatedAt":1776739468,"status":{"type":"idle"},"path":"/home/replay-user/.codex/sessions/delegated-task-status-child.jsonl","cwd":"/home/replay-user/t3code","cliVersion":"0.120.0","source":"vscode","agentNickname":null,"agentRole":null,"gitInfo":null,"name":null,"turns":[]},"model":"gpt-5.4","modelProvider":"openai","serviceTier":"fast","cwd":"/home/replay-user/t3code","approvalPolicy":"on-request","approvalsReviewer":"user","sandbox":{"type":"workspaceWrite","writableRoots":[],"readOnlyAccess":{"type":"fullAccess"},"networkAccess":false,"excludeTmpdirEnvVar":false,"excludeSlashTmp":false},"reasoningEffort":"xhigh"}}} {"type":"expect_outbound","label":"config/read","frame":{"id":6,"method":"config/read","params":{"cwd":"","includeLayers":false}}} {"type":"emit_inbound","label":"config/read","frame":{"id":6,"result":{"config":{},"origins":{}}}} diff --git a/packages/provider-core/src/server/IdAllocator.ts b/packages/provider-core/src/server/IdAllocator.ts index 02ecadba198e5..b03a98cc4e86c 100644 --- a/packages/provider-core/src/server/IdAllocator.ts +++ b/packages/provider-core/src/server/IdAllocator.ts @@ -271,6 +271,11 @@ const randomId = ), ); +const DELEGATED_TASK_THREAD_PREFIX = joinId("thread", "delegated-task", ""); + +export const isDelegatedTaskThread = (threadId: ThreadId): boolean => + threadId.startsWith(DELEGATED_TASK_THREAD_PREFIX); + export const derive: IdAllocatorV2Derive = { providerSession: (input) => ProviderSessionId.make( @@ -278,7 +283,7 @@ export const derive: IdAllocatorV2Derive = { ), delegatedTaskNode: (input) => NodeId.make(joinId("node", "delegated-task", input.commandId)), delegatedTaskThread: (input) => - ThreadId.make(joinId("thread", "delegated-task", input.commandId)), + ThreadId.make(`${DELEGATED_TASK_THREAD_PREFIX}${encodePart(input.commandId)}`), delegatedTaskMessage: (input) => MessageId.make(joinId("message", "delegated-task", input.commandId)), delegatedTaskTurnItem: (input) =>