Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
11 changes: 10 additions & 1 deletion apps/server/src/mcp/OrchestratorMcpService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,16 @@ describe("OrchestratorMcpService", () => {
} as unknown as OrchestrationV2ThreadProjection;
const childProjection = {
thread: { id: childThreadId },
runs: [{ id: RunId.make("run:mcp-restart-child"), ordinal: 1, status: "cancelled" }],
runs: [
{
id: RunId.make("run:mcp-restart-child"),
ordinal: 1,
status: "cancelled",
delegatedTaskId: taskId,
},
// An unrelated later run cannot supply this follow-up's result.
{ id: RunId.make("run:mcp-restart-later"), ordinal: 2, status: "completed" },
],
contextTransfers: [],
messages: [],
subagents: [],
Expand Down
147 changes: 97 additions & 50 deletions apps/server/src/mcp/OrchestratorMcpService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,8 @@ export function delegatedTaskRun(
childProjection: Pick<OrchestrationV2ThreadProjection, "runs" | "contextTransfers">,
task: OrchestrationV2Subagent,
): OrchestrationV2Run | undefined {
const taskRun = childProjection.runs.find((run) => run.delegatedTaskId === task.id);
if (taskRun !== undefined) return taskRun;
const spawnTransfer = childProjection.contextTransfers.find(
(transfer) =>
transfer.type === "subagent_spawn" &&
Expand Down Expand Up @@ -390,22 +392,60 @@ function isTerminalTaskStatus(
);
}

function directAppOwnedChildTask(
function directAppOwnedChildTasks(
parent: Pick<OrchestrationV2ThreadProjection, "thread" | "subagents">,
target: Pick<OrchestrationV2ThreadProjection, "thread">,
): OrchestrationV2Subagent | undefined {
target: Pick<OrchestrationV2ThreadProjection, "thread" | "runs">,
returnedRunIds: ReadonlySet<RunId | null>,
): ReadonlyArray<OrchestrationV2Subagent> {
if (
target.thread.lineage.parentThreadId !== parent.thread.id ||
target.thread.lineage.relationshipToParent !== "subagent"
) {
return undefined;
return [];
}
return parent.subagents.find(
const original = parent.subagents.find(
(task) =>
task.origin === "app_owned" &&
task.threadId === parent.thread.id &&
task.childThreadId === target.thread.id,
);
const taskIds = new Set(
target.runs
.filter((run) => returnedRunIds.has(run.id))
.map((run) => run.delegatedTaskId ?? original?.id),
);
return parent.subagents.filter(
(task) =>
taskIds.has(task.id) &&
task.origin === "app_owned" &&
task.childThreadId === target.thread.id,
);
}

function taskResultTransfer(
parent: Pick<OrchestrationV2ThreadProjection, "thread" | "contextTransfers">,
child: Pick<OrchestrationV2ThreadProjection, "runs" | "contextTransfers">,
task: OrchestrationV2Subagent,
) {
const originalRun = delegatedTaskRun(child, task);
const resultRunIds = new Set(
child.runs
.filter(
(run) =>
run.delegatedTaskId === task.id ||
(originalRun?.delegatedTaskId === undefined && run.delegatedTaskId === undefined),
)
.map((run) => run.id),
);
if (originalRun !== undefined) resultRunIds.add(originalRun.id);
return parent.contextTransfers.find(
(transfer) =>
transfer.type === "subagent_result" &&
transfer.sourceThreadId === task.childThreadId &&
transfer.targetThreadId === parent.thread.id &&
((transfer.sourcePoint.runId !== undefined && resultRunIds.has(transfer.sourcePoint.runId)) ||
(transfer.sourcePoint.runId === undefined && originalRun?.delegatedTaskId === undefined)),
);
}

function pageIncludesTerminalTaskResult(input: {
Expand All @@ -418,12 +458,7 @@ function pageIncludesTerminalTaskResult(input: {
>;
readonly maxChars: number;
}): boolean {
const transfer = input.parent.contextTransfers.find(
(transfer) =>
transfer.type === "subagent_result" &&
transfer.sourceThreadId === input.target.thread.id &&
transfer.targetThreadId === input.parent.thread.id,
);
const transfer = taskResultTransfer(input.parent, input.target, input.task);
if (transfer === undefined) return false;
const run =
transfer.sourcePoint.runId === undefined
Expand Down Expand Up @@ -1241,9 +1276,15 @@ const make = Effect.gen(function* () {
const childRun = delegatedTaskRun(childControls, task);
const terminalRun = latestTerminalResultRun(childControls, childRun);
const progress = delegatedTaskProgress(childControls);
const taskResultRun =
childRun?.delegatedTaskId === task.id
? childControls.runs
.filter((run) => run.delegatedTaskId === task.id)
.toSorted((a, b) => b.ordinal - a.ordinal)[0]
: progress.resultRun;
const resultRunIds = [
...new Set(
[progress.resultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined),
[taskResultRun?.id, terminalRun?.id].filter((id): id is RunId => id !== undefined),
),
];
const resultRecords = yield* threadManagement
Expand Down Expand Up @@ -1280,15 +1321,15 @@ const make = Effect.gen(function* () {
: childRun,
)
: workState === "result_available"
? taskStatusForRun(progress.resultRun ?? childRun)
? taskStatusForRun(taskResultRun ?? childRun)
: taskStatusForRun(childRun) === "queued"
? "queued"
: "running";
const derivedResult =
task.result !== null
? task.result
: progress.resultRun !== undefined && isTerminalTaskStatus(status)
? subagentResultForRun(childProjection, progress.resultRun).text
: taskResultRun !== undefined && isTerminalTaskStatus(status)
? subagentResultForRun(childProjection, taskResultRun).text
: null;
const resultTransfers = parentProjection.contextTransfers.filter(
(transfer) =>
Expand All @@ -1304,7 +1345,7 @@ const make = Effect.gen(function* () {
? resultTransfers.find((transfer) => transfer.sourcePoint.runId === undefined)
: undefined) ??
null);
const resultTransfer = resultTransfers[0] ?? null;
const resultTransfer = taskResultTransfer(parentProjection, childProjection, task) ?? null;
const terminalStatus = terminalRun === undefined ? null : taskStatusForRun(terminalRun);
const response = {
taskId: task.id,
Expand Down Expand Up @@ -2315,45 +2356,48 @@ const make = Effect.gen(function* () {
{ concurrency: 1 },
);
const messagesByThreadId = new Map(sourceMessages);
const task = parent === undefined ? undefined : directAppOwnedChildTask(parent, target);
const returnedRunIds = new Set(
page
.filter((row) => row.sourceThreadId === target.thread.id)
.map((row) => row.item.runId),
);
const tasks =
parent === undefined ? [] : directAppOwnedChildTasks(parent, target, returnedRunIds);
if (
parent !== undefined &&
scope.thread !== undefined &&
task !== undefined &&
tasks.length > 0 &&
(input.textOffset ?? 0) === 0
) {
const transfer = parent.contextTransfers.find(
(transfer) =>
transfer.type === "subagent_result" &&
transfer.sourceThreadId === target.thread.id &&
transfer.targetThreadId === parent.thread.id,
);
const resultRunId = transfer?.sourcePoint.runId ?? delegatedTaskRun(target, task)?.id;
const resultRunIds = resultRunId === undefined ? [] : [resultRunId];
const resultRecords = yield* threadManagement
.getThreadRecords(target.thread.id, ["messages", "turnItems"], {
messageRoles: ["assistant"],
messageRunIds: resultRunIds,
turnItemRunIds: resultRunIds,
turnItemTypes: ["assistant_message", "error"],
})
.pipe(Effect.mapError(threadManagementFailure));
if (
pageIncludesTerminalTaskResult({
parent,
page,
task,
target: { ...target, ...resultRecords },
maxChars,
})
) {
yield* readTask(
scope as McpThreadInvocationScope,
task.id,
false,
true,
"thread-read-acknowledge",
);
for (const task of tasks) {
const transfer = taskResultTransfer(parent, target, task);
const resultRunId = transfer?.sourcePoint.runId ?? delegatedTaskRun(target, task)?.id;
const resultRunIds = resultRunId === undefined ? [] : [resultRunId];
const resultRecords = yield* threadManagement
.getThreadRecords(target.thread.id, ["messages", "turnItems"], {
messageRoles: ["assistant"],
messageRunIds: resultRunIds,
turnItemRunIds: resultRunIds,
turnItemTypes: ["assistant_message", "error"],
})
.pipe(Effect.mapError(threadManagementFailure));
if (
pageIncludesTerminalTaskResult({
parent,
page,
task,
target: { ...target, ...resultRecords },
maxChars,
})
) {
yield* readTask(
scope as McpThreadInvocationScope,
task.id,
false,
true,
"thread-read-acknowledge",
);
}
}
}
return {
Expand Down Expand Up @@ -2419,6 +2463,9 @@ const make = Effect.gen(function* () {
threadId: input.threadId,
messageId,
runId: result.run.id,
...(result.run.delegatedTaskId === undefined
? {}
: { taskId: result.run.delegatedTaskId }),
status: result.run.status,
delivery: result.delivery,
} satisfies OrchestratorMcpThreadSendResult;
Expand Down
80 changes: 75 additions & 5 deletions apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1782,6 +1782,9 @@ describe("orchestrator MCP toolkit", () => {
expect(delegatedStatus.resultContextTransferId).not.toBeNull();
expect(delegatedStatus.latestTerminalResultContextTransferId).not.toBeNull();

// Wait-mode completion already returned its result while the parent was live.
yield* expectOffersToStay(0);

const childFollowupCall = yield* invoke("t3_thread_send", {
threadId: delegated.childThreadId,
message: "Confirm the delegated API boundary remains inspected.",
Expand Down Expand Up @@ -1924,10 +1927,14 @@ describe("orchestrator MCP toolkit", () => {
latestTerminalStatus: "interrupted",
});

// A wait-mode child (completionWake settled_only) that completes
// while the parent run is live does not offer a wake: the
// blocking delegate_task call above already returned the result.
yield* expectOffersToStay(0);
// Later requested work has its own delivery, including an interrupted follow-up.
expect(childFollowup.taskId).toBeDefined();
expect(activeChildFollowup.taskId).toBeDefined();
yield* waitForContinuationOffers(2);
yield* expectOffersToStay(2);
yield* invoke("task_status", { taskId: childFollowup.taskId });
yield* invoke("task_status", { taskId: activeChildFollowup.taskId });
yield* Ref.set(continuationOffers, []);

const repeatedDelegatedCall = yield* invoke("delegate_task", {
task: delegatedPrompt,
Expand Down Expand Up @@ -4007,6 +4014,18 @@ describe("orchestrator MCP toolkit", () => {
const pendingProjection = yield* orchestrator.getThreadProjection(
delegated.childThreadId,
);
const followupTasks = (yield* orchestrator.getThreadProjection(
parentThreadId,
)).subagents.filter(
(task) =>
task.childThreadId === delegated.childThreadId && task.id !== delegated.taskId,
);
expect(followupTasks).toHaveLength(2);
expect(followupTasks.every((task) => task.result === null)).toBe(true);
expect(followupTasks.map((task) => task.id)).toEqual([
runningFollowup.taskId,
queuedFollowup.taskId,
]);
expect(
pendingProjection.runs.find((run) => run.id === delegated.childRunId)?.status,
).toBe("completed");
Expand Down Expand Up @@ -4082,6 +4101,47 @@ describe("orchestrator MCP toolkit", () => {
expect(finalProjection.runs.find((run) => run.id === queuedFollowup.runId)?.status).toBe(
"completed",
);
yield* orchestrator
.streamStoredEventsFrom({
threadId: parentThreadId,
afterSequence: finalSequence,
})
.pipe(
Stream.filter(
(stored) =>
stored.event.type === "subagent.updated" &&
stored.event.payload.id === followupTasks[1]!.id &&
stored.event.payload.status === "completed",
),
Stream.runHead,
Effect.flatMap(
Option.match({
onNone: () => Effect.die("Follow-up task did not settle."),
onSome: () => Effect.void,
}),
),
);
const finishedParent = yield* orchestrator.getThreadProjection(parentThreadId);
expect(
finishedParent.subagents.find((task) => task.id === followupTasks[0]!.id)?.status,
).toBe("interrupted");
expect(
finishedParent.subagents.find((task) => task.id === followupTasks[1]!.id),
).toMatchObject({
status: "completed",
result: queuedFollowupResult,
});
expect(
finishedParent.contextTransfers.filter(
(transfer) =>
transfer.type === "subagent_result" &&
transfer.sourceThreadId === delegated.childThreadId,
),
).toHaveLength(3);
expect(
finishedParent.subagents.find((task) => task.id === followupTasks[1]!.id)
?.completionDelivery?.state,
).toBe("claimed");
const finalStatusCall = yield* invoke("task_status", {
taskId: delegated.taskId,
});
Expand All @@ -4097,8 +4157,18 @@ describe("orchestrator MCP toolkit", () => {
latestTerminalRunId: queuedFollowup.runId,
latestTerminalStatus: "completed",
latestTerminalSummary: queuedFollowupResult,
latestTerminalResultContextTransferId: null,
});
expect(finalStatus.latestTerminalResultContextTransferId).not.toBeNull();
const read = yield* invoke("t3_thread_read", {
threadId: delegated.childThreadId,
limit: 100,
});
expect(read.isError).not.toBe(true);
const afterRead = yield* orchestrator.getThreadProjection(parentThreadId);
expect(
afterRead.subagents.find((task) => task.id === queuedFollowup.taskId)
?.completionDelivery?.state,
).toBe("acknowledged");
}).pipe(Effect.provide(layerTest));
}),
),
Expand Down
2 changes: 1 addition & 1 deletion apps/server/src/mcp/toolkits/orchestrator/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ describe("orchestrator MCP tool guidance", () => {
assert.include(DelegateTaskTool.description ?? "", "childThreadId is backing storage");
assert.include(
OrchestratorToolkit.tools.t3_thread_send.description ?? "",
"Do not use a delegated task's childThreadId to start another review round",
"a requested follow-up creates a fresh task",
);
assert.include(
OrchestratorToolkit.tools.task_cancel.description ?? "",
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 @@ -219,7 +219,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. Do not use a delegated task's childThreadId to start another review round here; use delegate_task with the full review context and a new clientRequestId for that round. Thread messages do not create a new delegated task or reopen a completed task. 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; 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.",
parameters: OrchestratorMcpThreadSendInput,
success: OrchestratorMcpThreadSendResult,
failure: OrchestratorMcpFailure,
Expand Down
Loading
Loading