diff --git a/apps/server/src/orchestration-v2/EventSink.ts b/apps/server/src/orchestration-v2/EventSink.ts index 6b27f90aa6d5c..10bc126b90411 100644 --- a/apps/server/src/orchestration-v2/EventSink.ts +++ b/apps/server/src/orchestration-v2/EventSink.ts @@ -348,11 +348,7 @@ const layerBase: Layer.Layer< const staleRequests = new Set(); const staleNodes = new Set(); for (const event of events) { - if ( - event.type !== "runtime-request.updated" || - event.payload.kind !== "user_input" || - event.payload.status !== "cancelled" - ) + if (event.type !== "runtime-request.updated" || event.payload.status !== "cancelled") continue; const current = yield* projectionStore.getRuntimeRequest( event.threadId, @@ -360,7 +356,7 @@ const layerBase: Layer.Layer< ); if ( current?.status !== "pending" || - current.kind !== "user_input" || + current.kind !== event.payload.kind || current.providerTurnId !== event.payload.providerTurnId || current.responseCapability.type === "message" ) { @@ -368,22 +364,44 @@ const layerBase: Layer.Layer< staleNodes.add(event.payload.nodeId); } } - return events.filter((event) => { - switch (event.type) { - case "runtime-request.updated": - return event.payload.status !== "cancelled" || !staleRequests.has(event.payload.id); - case "node.updated": - return event.payload.status !== "cancelled" || !staleNodes.has(event.payload.id); - case "turn-item.updated": - return ( - event.payload.type !== "user_input_request" || - event.payload.status !== "cancelled" || - !staleRequests.has(event.payload.requestId) - ); - default: - return true; - } - }); + return yield* Effect.filter( + events, + Effect.fnUntraced(function* (event) { + const requestId = + event.type === "node.updated" && + (event.payload.kind === "user_input_request" || + event.payload.kind === "approval_request") + ? event.payload.runtimeRequestId + : event.type === "turn-item.updated" && + (event.payload.type === "user_input_request" || + event.payload.type === "approval_request") + ? event.payload.requestId + : null; + if ( + requestId !== null && + (event.type === "node.updated" || event.type === "turn-item.updated") && + event.payload.status === "cancelled" + ) { + const current = yield* projectionStore.getRuntimeRequest(event.threadId, requestId); + if (current?.status === "resolved") return false; + } + switch (event.type) { + case "runtime-request.updated": + return event.payload.status !== "cancelled" || !staleRequests.has(event.payload.id); + case "node.updated": + return event.payload.status !== "cancelled" || !staleNodes.has(event.payload.id); + case "turn-item.updated": + return ( + (event.payload.type !== "user_input_request" && + event.payload.type !== "approval_request") || + event.payload.status !== "cancelled" || + !staleRequests.has(event.payload.requestId) + ); + default: + return true; + } + }), + ); }); const normalizeEvents = (events: ReadonlyArray) => { diff --git a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts index a751b0bb462d1..77cf0a27fbbc5 100644 --- a/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts +++ b/apps/server/src/orchestration-v2/ProviderEventIngestor.test.ts @@ -1397,174 +1397,217 @@ layer("ProviderEventIngestorV2", (it) => { }), ); - it.effect( - "preserves an answer committed after terminal normalization reads a pending question", - () => - Effect.gen(function* () { - const eventSink = yield* EventSink.EventSinkV2; - const eventStore = yield* EventStore.EventStoreV2; - const projections = yield* ProjectionStore.ProjectionStoreV2; - const idAllocator = yield* IdAllocator.IdAllocatorV2; - const now = yield* DateTime.now; - const threadEvent = yield* threadCreatedEvent(now); - const threadId = threadEvent.threadId; - const providerSessionId = yield* idAllocator.allocate.providerSession({ - providerInstanceId: modelSelection.instanceId, - threadId, - }); - const providerThreadId = idAllocator.derive.providerThread({ - driver: CODEX_DRIVER, - nativeThreadId: `${threadId}:race`, - }); - const providerTurnId = idAllocator.derive.providerTurn({ - driver: CODEX_DRIVER, - nativeTurnId: `${threadId}:race`, - }); - const nodeId = NodeId.make(`${threadId}:question`); - const request: OrchestrationV2RuntimeRequest = { - id: RuntimeRequestId.make(`${threadId}:question`), - nodeId, - providerTurnId, - nativeRequestRef: null, - kind: "user_input", - status: "pending", - responseCapability: { type: "live", providerSessionId }, - createdAt: now, - resolvedAt: null, - }; - const node: OrchestrationV2ExecutionNode = { - id: nodeId, - threadId, - runId: null, - parentNodeId: null, - rootNodeId: nodeId, - kind: "user_input_request", - status: "waiting", - countsForRun: false, - providerThreadId, - providerTurnId, - nativeItemRef: null, - runtimeRequestId: request.id, - checkpointScopeId: null, - startedAt: now, - completedAt: null, - }; - const item: OrchestrationV2TurnItem = { - id: TurnItemId.make(`${threadId}:question`), - threadId, - runId: null, - nodeId, - providerThreadId, - providerTurnId, - nativeItemRef: null, - parentItemId: null, - ordinal: 1, - status: "waiting", - title: "Which option?", - startedAt: now, - completedAt: null, - updatedAt: now, - type: "user_input_request", - requestId: request.id, - questions: [], - }; - const seedEvents: Array = [threadEvent]; - for (const payload of [ - { type: "runtime-request.updated" as const, payload: request }, - { type: "node.updated" as const, payload: node }, - { type: "turn-item.updated" as const, payload: item }, - ]) - seedEvents.push({ - id: yield* idAllocator.allocate.event({ threadId }), + it.effect.each([ + { kind: "user_input", source: "terminal" }, + { kind: "user_input", source: "callback" }, + { kind: "command", source: "callback" }, + ] as const)( + "preserves a $kind response committed before a $source cancellation", + ({ kind, source }) => + Effect.scoped( + Effect.gen(function* () { + const eventSink = yield* EventSink.EventSinkV2; + const eventStore = yield* EventStore.EventStoreV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const now = yield* DateTime.now; + const threadEvent = yield* threadCreatedEvent(now); + const threadId = threadEvent.threadId; + const providerSessionId = yield* idAllocator.allocate.providerSession({ + providerInstanceId: modelSelection.instanceId, threadId, - occurredAt: now, - ...payload, }); - yield* eventSink.write({ events: seedEvents }); - const normalized = yield* Deferred.make(); - const releaseTerminalWrite = yield* Deferred.make(); - const gatedSink = EventSink.EventSinkV2.of({ - ...eventSink, - write: (input) => - Deferred.succeed(normalized, undefined).pipe( - Effect.andThen(Deferred.await(releaseTerminalWrite)), - Effect.andThen(eventSink.write(input)), - ), - }); - const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2.pipe( - Effect.provide(Layer.fresh(ProviderEventIngestor.layer)), - Effect.provideService(EventSink.EventSinkV2, gatedSink), - ); - const terminal = yield* ingestor - .ingestNormalized({ - providerSessionId, - providerInstanceId: modelSelection.instanceId, + const providerThreadId = idAllocator.derive.providerThread({ + driver: CODEX_DRIVER, + nativeThreadId: `${threadId}:race`, + }); + const providerTurnId = idAllocator.derive.providerTurn({ + driver: CODEX_DRIVER, + nativeTurnId: `${threadId}:race`, + }); + const nodeId = NodeId.make(`${threadId}:question`); + const request: OrchestrationV2RuntimeRequest = { + id: RuntimeRequestId.make(`${threadId}:question`), + nodeId, + providerTurnId, + nativeRequestRef: null, + kind, + status: "pending", + responseCapability: { type: "live", providerSessionId }, + createdAt: now, + resolvedAt: null, + }; + const node: OrchestrationV2ExecutionNode = { + id: nodeId, threadId, - event: { - type: "provider_turn.updated", - driver: CODEX_DRIVER, - providerTurn: { - id: providerTurnId, - providerThreadId, - nodeId, - runAttemptId: null, - nativeTurnRef: null, - ordinal: 1, - status: "completed", - startedAt: now, - completedAt: now, - }, - }, - }) - .pipe(Effect.forkScoped); - yield* Deferred.await(normalized); - const answers = { decision: "Use the existing workspace" }; - const responseEvents: Array = []; - for (const payload of [ - { - type: "runtime-request.updated" as const, - payload: { ...request, status: "resolved" as const, answers, resolvedAt: now }, - }, - { - type: "node.updated" as const, - payload: { ...node, status: "completed" as const, completedAt: now }, - }, - { - type: "turn-item.updated" as const, - payload: { ...item, status: "completed" as const, completedAt: now }, - }, - ]) - responseEvents.push({ - id: yield* idAllocator.allocate.event({ threadId }), + runId: null, + parentNodeId: null, + rootNodeId: nodeId, + kind: kind === "command" ? "approval_request" : "user_input_request", + status: "waiting", + countsForRun: false, + providerThreadId, + providerTurnId, + nativeItemRef: null, + runtimeRequestId: request.id, + checkpointScopeId: null, + startedAt: now, + completedAt: null, + }; + const item: OrchestrationV2TurnItem = { + id: TurnItemId.make(`${threadId}:question`), threadId, - occurredAt: now, - ...payload, + runId: null, + nodeId, + providerThreadId, + providerTurnId, + nativeItemRef: null, + parentItemId: null, + ordinal: 1, + status: "waiting", + title: "Which option?", + startedAt: now, + completedAt: null, + updatedAt: now, + ...(kind === "command" + ? { type: "approval_request", requestId: request.id, requestKind: "command" } + : { type: "user_input_request", requestId: request.id, questions: [] }), + }; + const seedEvents: Array = [threadEvent]; + for (const payload of [ + { type: "runtime-request.updated" as const, payload: request }, + { type: "node.updated" as const, payload: node }, + { type: "turn-item.updated" as const, payload: item }, + ]) + seedEvents.push({ + id: yield* idAllocator.allocate.event({ threadId }), + threadId, + occurredAt: now, + ...payload, + }); + yield* eventSink.write({ events: seedEvents }); + const normalized = yield* Deferred.make(); + const releaseTerminalWrite = yield* Deferred.make(); + const gatedSink = EventSink.EventSinkV2.of({ + ...eventSink, + write: (input) => + Deferred.succeed(normalized, undefined).pipe( + Effect.andThen(Deferred.await(releaseTerminalWrite)), + Effect.andThen(eventSink.write(input)), + ), }); - yield* eventSink.write({ events: responseEvents }); - yield* Deferred.succeed(releaseTerminalWrite, undefined); - const committedTerminal = yield* Fiber.join(terminal); - assert.deepEqual( - committedTerminal.map((stored) => stored.event.type), - ["provider-turn.updated"], - ); - const projection = yield* projections.getThreadProjection(threadId); - const answered = projection.runtimeRequests.find((entry) => entry.id === request.id)!; - assert.equal(answered.status, "resolved"); - assert.deepEqual(answered.answers, answers); - assert.equal(projection.nodes.find((entry) => entry.id === nodeId)!.status, "completed"); - assert.equal( - projection.turnItems.find((entry) => entry.id === item.id)!.status, - "completed", - ); - const history = yield* eventStore.read({ threadId }).pipe(Stream.runCollect); - assert.isFalse( - history.some( - (stored) => - stored.event.type === "runtime-request.updated" && - stored.event.payload.status === "cancelled", - ), - ); - }), + const ingestor = yield* ProviderEventIngestor.ProviderEventIngestorV2.pipe( + Effect.provide(Layer.fresh(ProviderEventIngestor.layer)), + Effect.provideService(EventSink.EventSinkV2, gatedSink), + ); + const terminal = yield* ( + source === "terminal" + ? ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId, + event: { + type: "provider_turn.updated", + driver: CODEX_DRIVER, + providerTurn: { + id: providerTurnId, + providerThreadId, + nodeId, + runAttemptId: null, + nativeTurnRef: null, + ordinal: 1, + status: "completed", + startedAt: now, + completedAt: now, + }, + }, + }) + : Effect.forEach( + [ + { + type: "runtime_request.updated" as const, + driver: CODEX_DRIVER, + runtimeRequest: { ...request, status: "cancelled" as const, resolvedAt: now }, + }, + { + type: "node.updated" as const, + driver: CODEX_DRIVER, + node: { ...node, status: "cancelled" as const, completedAt: now }, + }, + { + type: "turn_item.updated" as const, + driver: CODEX_DRIVER, + turnItem: { + ...item, + status: "cancelled" as const, + completedAt: now, + updatedAt: now, + }, + }, + ], + (event) => + ingestor.ingestNormalized({ + providerSessionId, + providerInstanceId: modelSelection.instanceId, + threadId, + event, + }), + { concurrency: 1 }, + ).pipe(Effect.map((batches) => batches.flat())) + ).pipe(Effect.forkScoped); + yield* Deferred.await(normalized); + const response = + kind === "command" + ? { decision: "accept" as const } + : { answers: { decision: "Use the existing workspace" } }; + const responseEvents: Array = []; + for (const payload of [ + { + type: "runtime-request.updated" as const, + payload: { ...request, status: "resolved" as const, ...response, resolvedAt: now }, + }, + { + type: "node.updated" as const, + payload: { ...node, status: "completed" as const, completedAt: now }, + }, + { + type: "turn-item.updated" as const, + payload: { ...item, status: "completed" as const, completedAt: now }, + }, + ]) + responseEvents.push({ + id: yield* idAllocator.allocate.event({ threadId }), + threadId, + occurredAt: now, + ...payload, + }); + yield* eventSink.write({ events: responseEvents }); + yield* Deferred.succeed(releaseTerminalWrite, undefined); + const committedTerminal = yield* Fiber.join(terminal); + assert.deepEqual( + committedTerminal.map((stored) => stored.event.type), + source === "terminal" ? ["provider-turn.updated"] : [], + ); + const projection = yield* projections.getThreadProjection(threadId); + const answered = projection.runtimeRequests.find((entry) => entry.id === request.id)!; + assert.equal(answered.status, "resolved"); + assert.deepEqual(answered.answers, "answers" in response ? response.answers : undefined); + assert.equal(answered.decision, "decision" in response ? response.decision : undefined); + assert.equal(projection.nodes.find((entry) => entry.id === nodeId)!.status, "completed"); + assert.equal( + projection.turnItems.find((entry) => entry.id === item.id)!.status, + "completed", + ); + const history = yield* eventStore.read({ threadId }).pipe(Stream.runCollect); + assert.isFalse( + history.some( + (stored) => + stored.event.type === "runtime-request.updated" && + stored.event.payload.status === "cancelled", + ), + ); + }), + ), ); it.effect("persists a failed provider terminal as one expected error item", () => diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts index 97ab0a8db1657..2bfd5e3ff2c14 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.test.ts @@ -2,6 +2,7 @@ import { expect, it } from "@effect/vitest"; import { CommandId, EnvironmentId, + EventId, MessageId, type OrchestrationV2Command, type OrchestrationV2Run, @@ -13,7 +14,9 @@ import { type OrchestrationV2ThreadProjection, ProjectId, ProviderInstanceId, + ProviderTurnId, RunId, + RuntimeRequestId, ThreadId, } from "@t3tools/contracts"; import * as Deferred from "effect/Deferred"; @@ -32,11 +35,17 @@ import { AgentScope } from "@t3tools/shared/AgentScope"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as SqlitePersistence from "../persistence/Sqlite.ts"; +import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts"; import * as CommandReceiptStore from "./CommandReceiptStore.ts"; +import * as EffectOutbox from "./EffectOutbox.ts"; +import * as EventStore from "./EventStore.ts"; import * as Orchestrator from "./Orchestrator.ts"; +import * as ProjectionStore from "./ProjectionStore.ts"; +import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts"; import * as ProviderSessionManager from "./ProviderSessionManager.ts"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import * as ThreadManagementService from "./ThreadManagementService.ts"; +import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts"; import * as ThreadCommandExecutor from "./ThreadCommandExecutor.ts"; import * as LegacyV1ThreadImporter from "./legacy/LegacyV1ThreadImporter.ts"; import { @@ -267,6 +276,7 @@ it.effect.each([ }, ] : [], + runtimeRequests: [], messages: [], turnItems: [], } as unknown as OrchestrationV2ThreadProjection), @@ -833,6 +843,156 @@ it.effect.each([ }), ); +it.effect("an auto send queues behind a turn waiting on the user's answer", () => { + const projectId = ProjectId.make("project:thread-management:auto-send"); + const threadId = ThreadId.make("thread:thread-management:auto-send"); + const instanceId = ProviderInstanceId.make("codex"); + const layerDatabase = SqlitePersistence.layerMemory; + const adapter = { + instanceId, + driver: ProviderDriverKind.make("codex"), + getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2), + planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }), + openSession: () => Effect.die("Runs here never reach a provider"), + } as ProviderAdapter.ProviderAdapterV2["Service"]; + const layerTest = ThreadManagementService.layer.pipe( + Layer.provideMerge( + Layer.mergeAll( + layerDatabase, + ProjectionStore.layer.pipe(Layer.provide(layerDatabase)), + EventStore.layer.pipe(Layer.provide(layerDatabase)), + EffectOutbox.layer.pipe(Layer.provide(layerDatabase)), + ProviderReplayHarness.layerWithRegistry( + { name: "auto-send" }, + ProviderAdapterRegistry.layerFromAdapters([adapter]), + { databaseLayer: layerDatabase, runEffectWorker: false }, + ), + ), + ), + Layer.provideMerge(ServerSettings.layerTest()), + Layer.provideMerge(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))), + ); + + return Effect.gen(function* () { + const orchestrator = yield* Orchestrator.OrchestratorV2; + const projections = yield* ProjectionStore.ProjectionStoreV2; + const threads = yield* ThreadManagementService.ThreadManagementService; + yield* orchestrator.dispatch({ + type: "thread.create", + commandId: CommandId.make("command:auto-send:create"), + threadId, + projectId, + title: "Auto send", + modelSelection: { instanceId, model: "gpt-5.1-codex" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdBy: "user", + creationSource: "web", + }); + yield* orchestrator.dispatch({ + type: "message.dispatch", + commandId: CommandId.make("command:auto-send:first"), + threadId, + messageId: MessageId.make("message:auto-send:first"), + text: "Ask me a question", + attachments: [], + dispatchMode: { type: "start_immediately" }, + createdBy: "user", + creationSource: "web", + }); + const now = yield* DateTime.now; + const initial = yield* orchestrator.getThreadProjection(threadId); + const run = initial.runs[0]!; + const providerTurnId = ProviderTurnId.make("provider-turn:auto-send"); + const providerSessionId = ProviderSessionId.make("provider-session:auto-send"); + yield* projections.apply({ + id: EventId.make("event:auto-send:session"), + type: "provider-session.attached", + threadId, + occurredAt: now, + payload: { + id: providerSessionId, + driver: adapter.driver, + providerInstanceId: instanceId, + status: "ready", + cwd: "/repo", + model: "gpt-5.1-codex", + capabilities: CodexProviderCapabilitiesV2, + createdAt: now, + updatedAt: now, + lastError: null, + }, + }); + yield* projections.apply({ + id: EventId.make("event:auto-send:provider-thread"), + type: "provider-thread.updated", + threadId, + occurredAt: now, + payload: { ...initial.providerThreads[0]!, providerSessionId }, + }); + yield* projections.apply({ + id: EventId.make("event:auto-send:running"), + type: "run.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { ...run, status: "running", startedAt: now }, + }); + yield* projections.apply({ + id: EventId.make("event:auto-send:turn"), + type: "provider-turn.updated", + threadId, + occurredAt: now, + payload: { + id: providerTurnId, + providerThreadId: run.providerThreadId!, + nodeId: run.rootNodeId!, + runAttemptId: run.activeAttemptId!, + nativeTurnRef: null, + ordinal: 1, + status: "running", + startedAt: now, + completedAt: null, + }, + }); + yield* projections.apply({ + id: EventId.make("event:auto-send:question"), + type: "runtime-request.updated", + threadId, + runId: run.id, + occurredAt: now, + payload: { + id: RuntimeRequestId.make("request:auto-send"), + nodeId: run.rootNodeId!, + providerTurnId, + nativeRequestRef: null, + kind: "user_input", + status: "pending", + responseCapability: { type: "live", providerSessionId }, + createdAt: now, + resolvedAt: null, + }, + }); + + const sent = yield* threads.sendToThread({ + projectId, + commandId: CommandId.make("command:auto-send:other-thread"), + threadId, + messageId: MessageId.make("message:auto-send:other-thread"), + text: "A message from another thread", + attachments: [], + mode: "auto", + createdBy: "agent", + creationSource: "mcp", + }); + + expect(sent.delivery).toBe("queued"); + expect(sent.run.status).toBe("queued"); + }).pipe(Effect.provide(layerTest)); +}); + it.effect.each([ { name: "kills the thread's own agent and ends its turn", diff --git a/apps/server/src/orchestration-v2/ThreadManagementService.ts b/apps/server/src/orchestration-v2/ThreadManagementService.ts index fde3baa43c3cc..3fb202c9bb36a 100644 --- a/apps/server/src/orchestration-v2/ThreadManagementService.ts +++ b/apps/server/src/orchestration-v2/ThreadManagementService.ts @@ -598,6 +598,27 @@ function latestSteerableRun( .toSorted((left, right) => right.ordinal - left.ordinal)[0]; } +// Steering into a turn blocked on the user's answer can abort the question, +// and the provider asks it again with the user's typed answers gone. +function runAwaitsUser( + projection: Pick, + run: OrchestrationV2Run, +): boolean { + if (!isActiveRun(run)) return false; + const turnIds = new Set( + projection.providerTurns + .filter((turn) => turn.runAttemptId === run.activeAttemptId) + .map((turn) => turn.id), + ); + return projection.runtimeRequests.some( + (request) => + request.status === "pending" && + request.responseCapability.type === "live" && + request.providerTurnId !== null && + turnIds.has(request.providerTurnId), + ); +} + const SETTLE_AFTER_RUN_WAIT_MS = 24 * 60 * 60 * 1_000; const FORCE_STOP_REASON = "Force stopped by user"; const FORCE_STOP_RELEASE_WAIT = "10 seconds"; @@ -866,7 +887,11 @@ const make = Effect.gen(function* () { const sendToThread: ThreadManagementServiceShape["sendToThread"] = Effect.fn( "ThreadManagementService.sendToThread", )(function* (input) { - const target = yield* getProjectThreadRecords(input, ["runs", "providerTurns"]); + const target = yield* getProjectThreadRecords(input, [ + "runs", + "providerTurns", + "runtimeRequests", + ]); if (target.thread.archivedAt !== null) { return yield* new ThreadManagementThreadArchivedError({ threadId: input.threadId, @@ -875,7 +900,8 @@ const make = Effect.gen(function* () { const steerableRun = latestSteerableRun(target); // A fresh send never joins a running turn, so it needs none to steer or restart. const mode = - input.freshContext && (input.mode === "steer" || input.mode === "restart") + (input.freshContext && (input.mode === "steer" || input.mode === "restart")) || + (input.mode === "auto" && target.runs.some((run) => runAwaitsUser(target, run))) ? "queue" : input.mode; let dispatchMode: Extract< diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 3e439be81b286..43b699cdde806 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -473,7 +473,7 @@ detail also exposes an in-flight title regeneration. Sends a message to any ordinary or delegated thread in the environment: - `auto` starts an idle thread, steers a fully active turn, or queues behind a - turn that is not yet steerable; + turn that is not yet steerable or is waiting on the user's answer; - `queue` creates a separate follow-up run after active work; - `steer` requires a steerable active provider turn; and - `restart` requires an active provider turn and uses the orchestrator's