Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 139 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,9 @@ function makeDeterministicAdapter(input: {
turn: ProviderAdapter.ProviderAdapterV2TurnInput,
) => Deferred.Deferred<void> | undefined;
readonly response: (turn: ProviderAdapter.ProviderAdapterV2TurnInput) => string;
readonly steerReceived?: (
steer: ProviderAdapter.ProviderAdapterV2SteerInput,
) => Deferred.Deferred<void> | undefined;
}): ProviderAdapter.ProviderAdapterV2["Service"] {
return {
instanceId: input.instanceId,
Expand Down Expand Up @@ -357,7 +360,12 @@ function makeDeterministicAdapter(input: {
},
]);
}),
steerTurn: () => Effect.void,
steerTurn: (steer) => {
const received = input.steerReceived?.(steer);
return received === undefined
? Effect.void
: Deferred.succeed(received, undefined).pipe(Effect.asVoid);
},
interruptTurn: ({ providerThread, providerTurnId }) =>
Effect.gen(function* () {
const turnInput = turnInputs.get(providerTurnId);
Expand Down Expand Up @@ -523,6 +531,7 @@ describe("orchestrator MCP toolkit", () => {
Effect.gen(function* () {
const cwd = yield* checkpointWorkspace("orchestrator-mcp-toolkit");
const capturedTurns = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
const steerReceivedGates = new Map<string, Deferred.Deferred<void>>();
const parentTerminalGates = new Map<ThreadId, Deferred.Deferred<void>>();
const deliveryTerminalGates = new Map<ThreadId, Deferred.Deferred<void>>();
const layerRegistry = ProviderAdapterRegistry.layerFromAdapters([
Expand Down Expand Up @@ -560,6 +569,7 @@ describe("orchestrator MCP toolkit", () => {
turn.message.text === delegatedPrompt
? delegatedResult
: `Claude completed: ${turn.message.text}`,
steerReceived: (steer) => steerReceivedGates.get(steer.message.text),
}),
]);
// Captures parent-wake offers made when a delegated child
Expand Down Expand Up @@ -1952,6 +1962,134 @@ describe("orchestrator MCP toolkit", () => {
yield* invoke("task_status", { taskId: activeChildFollowup.taskId });
yield* Ref.set(continuationOffers, []);

// A send into a child run the server started has no task to join.
// It gets its own task only when it starts a new attempt; a steer
// into the running attempt leaves no run that could settle one.
const childSteerText = "Include the parent's latest guidance before finishing.";
const steerIntoUntaskedChildRun = (
target: { readonly providerInstanceId: ProviderInstanceId; readonly model: string },
name: string,
) =>
Effect.gen(function* () {
const delegateCall = yield* invoke("delegate_task", {
task: "Inspect the steering boundary and return the result.",
target,
mode: "wait",
timeoutMs: 10_000,
clientRequestId: `${name}-delegate`,
});
const child = yield* decodeDelegateTaskResult(delegateCall.structuredContent).pipe(
Effect.orDie,
);
expect(child.status).toBe("completed");
const messageId = MessageId.make(`message:mcp-child:${name}`);
yield* orchestrator.dispatch({
type: "message.dispatch",
createdBy: "agent",
creationSource: "server",
commandId: CommandId.make(`command:mcp-child:${name}`),
threadId: child.childThreadId,
messageId,
text: cancellationPrompt,
attachments: [],
modelSelection: { instanceId: target.providerInstanceId, model: target.model },
dispatchMode: { type: "start_immediately" },
});
const running = yield* waitForProjection(
orchestrator,
child.childThreadId,
(projection) => {
const run = projection.runs.find((row) => row.userMessageId === messageId);
return (
run?.status === "running" &&
projection.providerTurns.some(
(turn) => turn.nodeId === run.rootNodeId && turn.status === "running",
)
);
},
);
const run = running.runs.find((row) => row.userMessageId === messageId)!;
expect(run.delegatedTaskId).toBeUndefined();
const sendCall = yield* invoke("t3_thread_send", {
threadId: child.childThreadId,
message: childSteerText,
mode: "steer",
clientRequestId: `${name}-steer`,
});
const sent = yield* decodeThreadSendResult(sendCall.structuredContent).pipe(
Effect.orDie,
);
expect(sent).toMatchObject({ runId: run.id, delivery: "steered" });
return { child, sent };
});
const waitForChildRun = (
childThreadId: ThreadId,
runId: string,
status: "completed" | "interrupted",
) =>
waitForProjection(orchestrator, childThreadId, (projection) =>
projection.runs.some((run) => run.id === runId && run.status === status),
);

// Claude steers into the running turn, which then ends.
const steerReceived = yield* Deferred.make<void>();
steerReceivedGates.set(childSteerText, steerReceived);
const steeredSend = yield* steerIntoUntaskedChildRun(
{ providerInstanceId: claudeInstanceId, model: claudeModel },
"steer-untasked-run",
);
// A steer that misses the turn is sent again as its own run, so
// end the turn only once the steer reached it.
yield* Deferred.await(steerReceived);
steerReceivedGates.delete(childSteerText);
yield* invoke("t3_thread_interrupt", {
threadId: steeredSend.child.childThreadId,
runId: steeredSend.sent.runId,
clientRequestId: "steer-untasked-run-interrupt",
});
yield* waitForChildRun(
steeredSend.child.childThreadId,
steeredSend.sent.runId,
"interrupted",
);
expect(
(yield* orchestrator.getThreadProjection(parentThreadId)).subagents.filter(
(task) =>
task.origin === "app_owned" &&
task.childThreadId === steeredSend.child.childThreadId &&
task.status === "running",
),
).toEqual([]);
expect(steeredSend.sent.taskId).toBeUndefined();

// This Codex session has active steering off, so the send restarts the
// turn as a new attempt that owns the follow-up task and settles it.
const restarted = yield* steerIntoUntaskedChildRun(
{ providerInstanceId: codexInstanceId, model: codexModel },
"restart-untasked-run",
);
const restartedTaskId = restarted.sent.taskId;
expect(restartedTaskId).toBeDefined();
yield* waitForChildRun(
restarted.child.childThreadId,
restarted.sent.runId,
"completed",
);
yield* waitForProjection(orchestrator, parentThreadId, (projection) =>
projection.subagents.some(
(task) => task.id === restartedTaskId && task.status === "completed",
),
);
expect(
(yield* orchestrator.getThreadProjection(restarted.child.childThreadId)).runs.find(
(run) => run.id === restarted.sent.runId,
)?.delegatedTaskId,
).toBe(restartedTaskId);
yield* invoke("task_status", { taskId: steeredSend.child.taskId });
yield* invoke("task_status", { taskId: restarted.child.taskId });
yield* invoke("task_status", { taskId: restartedTaskId });
yield* Ref.set(continuationOffers, []);

const repeatedDelegatedCall = yield* invoke("delegate_task", {
task: delegatedPrompt,
target: {
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/orchestrator/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ export const ThreadUpdateTool = Tool.make("t3_thread_update", {

const ThreadSendTool = Tool.make("t3_thread_send", {
description:
"Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId; its completion notifies the parent, and the original result stays unchanged. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
"Send a message to any T3 thread in this environment. The target cannot have broader permission modes than the caller. Use delegate_task with the full review context and a new clientRequestId for independent review rounds. For a follow-up that needs the same child's conversation, send to its childThreadId. After the original task completes, a requested follow-up creates a fresh task in that child thread and returns taskId when it starts a turn or restarts a turn without its own task; its completion notifies the parent, and the original result stays unchanged. A steer that joins a running turn returns that turn's task, if it has one. mode='auto' starts an idle thread, steers a fully active turn, or queues behind a turn that is not yet steerable. Use queue for a separate follow-up turn, steer for an in-flight update, or restart to interrupt-and-restart the active turn. clientRequestId makes retries idempotent.",
parameters: OrchestratorMcpThreadSendInput,
success: OrchestratorMcpThreadSendResult,
failure: OrchestratorMcpFailure,
Expand Down
9 changes: 9 additions & 0 deletions apps/server/src/orchestration-v2/Orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6838,6 +6838,15 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
)
.at(-1);
if (message?.runId == null) return;
// Only a message that starts a run or a new attempt can own a task. A
// steer joins the running attempt, which finalizes its run from the
// snapshot taken when it started, so a task bound to it would never settle.
const startsAttempt = pending.some(
(event) =>
(event.type === "run.created" && event.payload.id === message.runId) ||
(event.type === "run-attempt.created" && event.payload.runId === message.runId),
);
if (!startsAttempt) return;
const child = yield* projectionStore.getThread(command.threadId);
const parentThreadId = child.lineage.parentThreadId;
if (
Expand Down
Loading