From 2c7378eccefd0a807a776e246ffd3e7e920d0918 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 7 Oct 2026 16:40:48 -0400 Subject: [PATCH 1/4] feat(server): delegated, created and forked threads come with a link #16782 gave thread list, read and launch results a ready Markdown link to the thread. delegate_task, task_status, create_threads and t3_thread_fork still returned only a bare thread ID, so agents mentioned those threads as dead text. They now return the same link (childThreadLink for tasks). Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/OrchestratorMcpService.ts | 10 ++++ ...OrchestratorMcpToolkit.integration.test.ts | 6 +++ apps/server/src/mcp/toolkits/core.test.ts | 52 +++++++++++++++++++ .../src/mcp/toolkits/orchestrator/tools.ts | 4 +- .../src/mcp/toolkits/thread/handlers.ts | 14 ++++- apps/server/src/mcp/toolkits/thread/tools.ts | 9 +++- .../orchestrator-mcp-server.md | 7 +-- .../contracts/src/orchestratorMcp.test.ts | 1 + packages/contracts/src/orchestratorMcp.ts | 4 ++ 9 files changed, 98 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.ts b/apps/server/src/mcp/OrchestratorMcpService.ts index dccda1b49519..fb5a48024875 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.ts @@ -1325,6 +1325,11 @@ const make = Effect.gen(function* () { const response = { taskId: task.id, childThreadId: task.childThreadId, + childThreadLink: formatThreadLink({ + environmentId: scope.environmentId, + threadId: task.childThreadId, + title: childControls.thread.title, + }), childRunId: childRun?.id ?? null, childNodeId: task.id, status, @@ -2239,6 +2244,11 @@ const make = Effect.gen(function* () { ); return { threadId, + link: formatThreadLink({ + environmentId: scope.environmentId, + threadId, + title: projection.thread.title, + }), runId: run?.id ?? null, status: run?.status ?? "idle", title: projection.thread.title, diff --git a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts index 673830dd66d3..8705f7542134 100644 --- a/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpToolkit.integration.test.ts @@ -1695,6 +1695,9 @@ describe("orchestrator MCP toolkit", () => { expect(delegatedSource.messages[0]).toMatchObject({ senderThreadId: parentThreadId, }); + expect(delegated.childThreadLink).toBe( + `[${delegatedSource.thread.title}](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(delegated.childThreadId)})`, + ); expect( delegatedSource.turnItems.find((item) => item.type === "user_message"), ).toMatchObject({ @@ -2089,6 +2092,9 @@ describe("orchestrator MCP toolkit", () => { expect(createdSource.messages[0]).toMatchObject({ senderThreadId: parentThreadId, }); + expect(promptedThread.link).toBe( + `[${createdSource.thread.title}](t3-thread://v1/environment%3Amcp-orchestrator/${encodeURIComponent(promptedThread.threadId)})`, + ); expect( createdSource.turnItems.find((item) => item.type === "user_message"), ).toMatchObject({ diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 634133b62e9e..9ef112957451 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -474,6 +474,58 @@ it.effect("a client caller targets any thread within its ceiling and cannot act ), ); +it.effect("a fork result links to the new fork, not its source", () => + Effect.gen(function* () { + const server = yield* McpServer.McpServer; + const forked = yield* server + .callTool({ + name: "t3_thread_fork", + arguments: { threadId: "source-thread", sourcePoint: { type: "latest_stable" } }, + }) + .pipe( + Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), + Effect.provideService(McpSchema.McpServerClient, client), + ); + expect(forked.isError).toBe(false); + const { targetThreadId, link } = forked.structuredContent as { + targetThreadId: string; + link: string; + }; + expect(targetThreadId).not.toBe("source-thread"); + expect(link).toBe( + `[Fork title](t3-thread://v1/mcp-core-environment/${encodeURIComponent(targetThreadId)})`, + ); + }).pipe( + Effect.provide( + McpHttpServer.layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => + Effect.succeed({ + ...McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" }), + ...(id === "source-thread" ? {} : { title: "Fork title" }), + }), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("source-thread"), + projectId: "project-a", + title: "Source title", + runtimeMode: "auto", + interactionMode: "default", + deletedAt: null, + }, + } as never), + dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }), + }), + ), + ), + ), + ), +); + it.effect("a read-only client reads threads and is refused every write before it runs", () => Effect.gen(function* () { const server = yield* McpServer.McpServer; diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 256b08638ac0..846c2b7adcb7 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -63,7 +63,7 @@ const OrchestratorCapabilitiesTool = Tool.make("orchestrator_capabilities", { export const DelegateTaskTool = Tool.make("delegate_task", { description: - "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", + "Needs an agent running inside a T3 thread. Delegate one task to a T3-owned child agent/subagent of THIS thread and run it with only the supplied task prompt, without copying parent conversation history. Choose providers and models from orchestrator_capabilities, which uses the same live catalog as the composer. Prefer native subagent tools for same-provider work only when they support the chosen model. Use this for any model missing from the native tool, including same-provider work, for cross-provider work, or for explicitly T3-owned child tasks. For every T3 delegated review round, call delegate_task again with the original brief, prior findings, responses, and unresolved objections in the task prompt. Track each round by its own taskId and use a distinct clientRequestId per round, stable across retries of that round. The childThreadId is backing storage, not the target for starting another delegated review round through t3_thread_send. Paste childThreadLink when you mention the child thread so the user can open it. Provider, model, model options (see orchestrator_capabilities), runtime mode, and interaction mode inherit unless target overrides them. Prefer mode='async' for long work; mode='wait' blocks until completion or timeout. timeoutMs on mode=wait is only the parent's wait budget and does not cancel the child. waitTimedOut on that wait call means the timeout fired; keep that taskId and read status on later task_status. An async child's completion wakes this thread through a notification, steered into active turns where supported or queued otherwise, so end the turn instead of polling or spawning watchers; use task_status only when the result is needed mid-turn.", parameters: OrchestratorMcpDelegateTaskInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, @@ -165,7 +165,7 @@ const RequestSecretTool = Tool.make("request_secret", { export const CreateThreadsTool = Tool.make("create_threads", { description: - "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt.", + "Needs an agent running inside a T3 thread. Create one or more ORDINARY TOP-LEVEL T3 conversations. This is not delegation and does not create child agents/subagents. For delegated work, choose models from orchestrator_capabilities. Prefer native subagents only when they support the chosen model; otherwise call delegate_task, including for same-provider work. Use create_threads for a batch of separate top-level threads sharing this checkout. Prefer t3_thread_launch for a single thread. Both require the user to request separate/new/top-level threads or conversations. Each entry may override provider, model, options, runtime mode, and interaction mode; omitted settings inherit. Project, branch, and worktree always inherit and cannot be overridden here. For independent implementation or a PR stack in its own worktree, use t3_thread_launch with workspaceStrategy instead of asking the agent to create a worktree in its prompt. Paste each returned link when you mention a thread.", parameters: OrchestratorMcpCreateThreadsInput, success: OrchestratorMcpCreateThreadsResult, failure: OrchestratorMcpFailure, diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index 2a06c58c1b43..ca8edb33d613 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import { modelSelectionCommandType } from "@t3tools/shared/model"; +import { formatThreadLink } from "@t3tools/shared/threadLinks"; import * as McpToolAccess from "../../McpToolAccess.ts"; import { @@ -116,7 +117,7 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { ), t3_thread_fork: writesThread((input) => Effect.gen(function* () { - const { threads, projection } = yield* readThread(input.threadId); + const { scope, threads, projection } = yield* readThread(input.threadId); const commandId = yield* newCommandId(); const targetThreadId = ThreadId.make(`${commandId}:fork`); const result = yield* threads @@ -131,7 +132,16 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { creationSource: "mcp", }) .pipe(Effect.mapError(dispatchFailure)); - return { sequence: result.sequence, targetThreadId }; + const fork = yield* threads.getThreadShell(targetThreadId).pipe(Effect.mapError(unavailable)); + return { + sequence: result.sequence, + targetThreadId, + link: formatThreadLink({ + environmentId: scope.environmentId, + threadId: targetThreadId, + title: fork?.title ?? input.title ?? projection.thread.title, + }), + }; }), ), t3_thread_merge_back: McpToolAccess.writesThreads( diff --git a/apps/server/src/mcp/toolkits/thread/tools.ts b/apps/server/src/mcp/toolkits/thread/tools.ts index 0958e6f3add9..fcb227570bae 100644 --- a/apps/server/src/mcp/toolkits/thread/tools.ts +++ b/apps/server/src/mcp/toolkits/thread/tools.ts @@ -196,16 +196,21 @@ const ThreadConfigureTool = Tool.make("t3_thread_configure", { }).annotate(Tool.Destructive, true); const transferResult = Schema.Struct({ sequence: NonNegativeInt, targetThreadId: ThreadId }); +const forkResult = Schema.Struct({ + ...transferResult.fields, + /** Paste this whenever you mention the fork, so the user can click to open it. */ + link: Schema.String, +}); const ThreadForkTool = Tool.make("t3_thread_fork", { ...commandTool, description: - "Fork a thread from a stable run or checkpoint using the existing fork command. Omit threadId to fork this thread. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed.", + "Fork a thread from a stable run or checkpoint using the existing fork command. Omit threadId to fork this thread. The fork inherits the source configuration. Acceptance does not mean a provider turn has completed. Paste the returned link when you mention the fork.", parameters: Schema.Struct({ threadId: Schema.optional(ThreadId), sourcePoint: OrchestrationV2ThreadForkSourcePoint, title: Schema.optional(TrimmedNonEmptyString), }), - success: transferResult, + success: forkResult, }).annotate(Tool.Destructive, true); const ThreadMergeBackTool = Tool.make("t3_thread_merge_back", { ...commandTool, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 08c2f19bdabf..63b7d182b263 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -352,9 +352,10 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -List, read, and launch results include `link`, a Markdown link of the form -`[title](t3-thread://v1//)` that clients open as the -thread. List and read results also report `snoozed` and `snoozedUntil`, and +Every result that names a thread for the agent to mention includes a Markdown +link of the form `[title](t3-thread://v1//)` that +clients open as the thread: `link` on list, read, launch, `create_threads`, +and fork results, and `childThreadLink` on `delegate_task` and `task_status`. List and read results also report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early when it has a pending request, fails, or completes after the snooze. diff --git a/packages/contracts/src/orchestratorMcp.test.ts b/packages/contracts/src/orchestratorMcp.test.ts index 617442ecea9d..fc9bf3c49cd7 100644 --- a/packages/contracts/src/orchestratorMcp.test.ts +++ b/packages/contracts/src/orchestratorMcp.test.ts @@ -38,6 +38,7 @@ describe("orchestrator MCP contracts", () => { const result = decodeDelegateTaskResult({ taskId: "node-task-1", childThreadId: "thread-child-1", + childThreadLink: "[Child](t3-thread://v1/environment-1/thread-child-1)", childRunId: "run-child-1", childNodeId: "node-task-1", status: "completed", diff --git a/packages/contracts/src/orchestratorMcp.ts b/packages/contracts/src/orchestratorMcp.ts index d13cf419f19f..da7be612c181 100644 --- a/packages/contracts/src/orchestratorMcp.ts +++ b/packages/contracts/src/orchestratorMcp.ts @@ -193,6 +193,8 @@ export type OrchestratorMcpDelegateTaskInput = typeof OrchestratorMcpDelegateTas export const OrchestratorMcpDelegateTaskResult = Schema.Struct({ taskId: NodeId, childThreadId: ThreadId, + /** Paste this whenever you mention the child thread, so the user can click to open it. */ + childThreadLink: Schema.String, childRunId: Schema.NullOr(RunId), childNodeId: NodeId, status: OrchestratorMcpDelegatedTaskStatus, @@ -260,6 +262,8 @@ export type OrchestratorMcpCreatedThreadStatus = typeof OrchestratorMcpCreatedTh export const OrchestratorMcpCreatedThread = Schema.Struct({ threadId: ThreadId, + /** Paste this whenever you mention the thread, so the user can click to open it. */ + link: Schema.String, runId: Schema.NullOr(RunId), status: OrchestratorMcpCreatedThreadStatus, title: Schema.String, From 8cb621b06b78add50c629190c13527f641982d3e Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 7 Oct 2026 16:57:13 -0400 Subject: [PATCH 2/4] fix(server): task_status tells agents to paste the child thread link Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/toolkits/orchestrator/tools.ts | 2 +- docs/orchestration-v2/orchestrator-mcp-server.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/server/src/mcp/toolkits/orchestrator/tools.ts b/apps/server/src/mcp/toolkits/orchestrator/tools.ts index 846c2b7adcb7..13156198bff7 100644 --- a/apps/server/src/mcp/toolkits/orchestrator/tools.ts +++ b/apps/server/src/mcp/toolkits/orchestrator/tools.ts @@ -76,7 +76,7 @@ export const DelegateTaskTool = Tool.make("delegate_task", { const TaskStatusTool = Tool.make("task_status", { description: - "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery.", + "Needs an agent running inside a T3 thread. Read a T3-owned delegated task created by this parent thread. childRunId identifies the original delegated run. workState distinguishes working, waiting_for_children, and result_available; a completed turn with live nested work is not a completed task. summary is the final task result, including provider errors on failure, and remains stable after publication. hasPendingChildRuns reports later queued or executing turns in the backing child thread, even after the task is terminal; it does not reopen the task, and task_cancel stops those turns too. latestTerminal* provides later non-monitor turn results. Reading a terminal result acknowledges its automatic parent delivery. Paste childThreadLink when you mention the child thread so the user can open it.", parameters: OrchestratorMcpTaskStatusInput, success: OrchestratorMcpDelegateTaskResult, failure: OrchestratorMcpFailure, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 63b7d182b263..48282c5fb217 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -352,10 +352,10 @@ and `creationSource: "mcp"`; provider output uses `creationSource: "provider"`. Actor and ingress are separate so agent-authored user-role messages remain distinguishable from human-authored messages. -Every result that names a thread for the agent to mention includes a Markdown +List, read, launch, `create_threads`, and fork results include `link`, and +`delegate_task` and `task_status` results include `childThreadLink`: a Markdown link of the form `[title](t3-thread://v1//)` that -clients open as the thread: `link` on list, read, launch, `create_threads`, -and fork results, and `childThreadLink` on `delegate_task` and `task_status`. List and read results also report `snoozed` and `snoozedUntil`, and +clients open as the thread. List and read results also report `snoozed` and `snoozedUntil`, and `t3_thread_list` filters on `snoozed`. The server's `isSnoozed` follows the client's `effectiveSnoozed`, so agents and the sidebar agree: a snoozed thread wakes early when it has a pending request, fails, or completes after the snooze. From a103172a106dc1cf51c9a3fe6ac1529f054a1658 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 7 Oct 2026 17:05:13 -0400 Subject: [PATCH 3/4] test(server): delegated task fixtures carry the child thread's title Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/mcp/OrchestratorMcpService.test.ts | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/apps/server/src/mcp/OrchestratorMcpService.test.ts b/apps/server/src/mcp/OrchestratorMcpService.test.ts index 18b444b85177..c4eff7904fb9 100644 --- a/apps/server/src/mcp/OrchestratorMcpService.test.ts +++ b/apps/server/src/mcp/OrchestratorMcpService.test.ts @@ -67,7 +67,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [ { id: childRunId, @@ -199,7 +199,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [{ id: RunId.make("run:mcp-restart-child"), ordinal: 1, status: "cancelled" }], contextTransfers: [], messages: [], @@ -283,7 +283,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [], contextTransfers: [], messages: [], @@ -359,7 +359,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -438,7 +438,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -524,7 +524,7 @@ describe("OrchestratorMcpService", () => { ], } as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [{ id: childRunId, status: "running" }], contextTransfers: [], messages: [], @@ -624,7 +624,7 @@ describe("OrchestratorMcpService", () => { [ childThreadId, { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [{ id: RunId.make("run:mcp-cancel-grandchild-child"), status: "running" }], contextTransfers: [], messages: [], @@ -637,7 +637,7 @@ describe("OrchestratorMcpService", () => { [ grandchildThreadId, { - thread: { id: grandchildThreadId }, + thread: { id: grandchildThreadId, title: "Grandchild task" }, runs: [], contextTransfers: [], messages: [], @@ -851,7 +851,7 @@ describe("OrchestratorMcpService provider resolution", () => { }) as unknown as OrchestrationV2ThreadProjection; const childProjection = { - thread: { id: childThreadId }, + thread: { id: childThreadId, title: "Child task" }, runs: [], contextTransfers: [], messages: [], From c3bd0442ee13fd6247875c4340ab1f78a45cd3c1 Mon Sep 17 00:00:00 2001 From: Dara Adedeji Date: Wed, 7 Oct 2026 17:27:05 -0400 Subject: [PATCH 4/4] fix(server): a fork that committed never reports failure over its title Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/server/src/mcp/toolkits/core.test.ts | 87 ++++++++++++------- .../src/mcp/toolkits/thread/handlers.ts | 5 +- .../orchestrator-mcp-server.md | 1 + 3 files changed, 62 insertions(+), 31 deletions(-) diff --git a/apps/server/src/mcp/toolkits/core.test.ts b/apps/server/src/mcp/toolkits/core.test.ts index 9ef112957451..a644fb441290 100644 --- a/apps/server/src/mcp/toolkits/core.test.ts +++ b/apps/server/src/mcp/toolkits/core.test.ts @@ -474,53 +474,80 @@ it.effect("a client caller targets any thread within its ceiling and cannot act ), ); -it.effect("a fork result links to the new fork, not its source", () => +// Serves "source-thread" as the fork source; `forkShell` answers the fork's own shell read. +const forkToolkitLayer = ( + forkShell: ThreadManagement.ThreadManagementService["Service"]["getThreadShell"], +) => + McpHttpServer.layerThreadToolkit.pipe( + Layer.provideMerge(McpServer.McpServer.layer), + Layer.provide(NodeCrypto.layer), + Layer.provide( + Layer.mock(ThreadManagement.ThreadManagementService)({ + getThreadShell: (id) => + id === "source-thread" + ? Effect.succeed(McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" })) + : forkShell(id), + getProjectThreadRecords: () => + Effect.succeed({ + thread: { + id: ThreadId.make("source-thread"), + projectId: "project-a", + title: "Source title", + runtimeMode: "auto", + interactionMode: "default", + deletedAt: null, + }, + } as never), + dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }), + }), + ), + ); + +const forkSourceThread = (args: Record = {}) => Effect.gen(function* () { const server = yield* McpServer.McpServer; const forked = yield* server .callTool({ name: "t3_thread_fork", - arguments: { threadId: "source-thread", sourcePoint: { type: "latest_stable" } }, + arguments: { threadId: "source-thread", sourcePoint: { type: "latest_stable" }, ...args }, }) .pipe( Effect.provideService(McpInvocationContext.McpInvocationContext, clientScope("auto")), Effect.provideService(McpSchema.McpServerClient, client), ); expect(forked.isError).toBe(false); - const { targetThreadId, link } = forked.structuredContent as { - targetThreadId: string; - link: string; - }; + return forked.structuredContent as { targetThreadId: string; link: string }; + }); + +it.effect("a fork result links to the new fork, not its source", () => + Effect.gen(function* () { + const { targetThreadId, link } = yield* forkSourceThread(); expect(targetThreadId).not.toBe("source-thread"); expect(link).toBe( `[Fork title](t3-thread://v1/mcp-core-environment/${encodeURIComponent(targetThreadId)})`, ); }).pipe( Effect.provide( - McpHttpServer.layerThreadToolkit.pipe( - Layer.provideMerge(McpServer.McpServer.layer), - Layer.provide(NodeCrypto.layer), - Layer.provide( - Layer.mock(ThreadManagement.ThreadManagementService)({ - getThreadShell: (id) => - Effect.succeed({ - ...McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" }), - ...(id === "source-thread" ? {} : { title: "Fork title" }), - }), - getProjectThreadRecords: () => - Effect.succeed({ - thread: { - id: ThreadId.make("source-thread"), - projectId: "project-a", - title: "Source title", - runtimeMode: "auto", - interactionMode: "default", - deletedAt: null, - }, - } as never), - dispatch: () => Effect.succeed({ sequence: 7, storedEvents: [] }), - }), - ), + forkToolkitLayer((id) => + Effect.succeed({ + ...McpToolAccessTestkit.liveThreadShell(id, { runtimeMode: "auto" }), + title: "Fork title", + }), + ), + ), + ), +); + +it.effect("a committed fork still returns its link when reading its title fails", () => + Effect.gen(function* () { + const { targetThreadId, link } = yield* forkSourceThread({ title: "Requested title" }); + expect(link).toBe( + `[Requested title](t3-thread://v1/mcp-core-environment/${encodeURIComponent(targetThreadId)})`, + ); + }).pipe( + Effect.provide( + forkToolkitLayer((threadId) => + Effect.fail(new OrchestratorProjectionError({ threadId, cause: "read failed" })), ), ), ), diff --git a/apps/server/src/mcp/toolkits/thread/handlers.ts b/apps/server/src/mcp/toolkits/thread/handlers.ts index ca8edb33d613..95348c69b6c4 100644 --- a/apps/server/src/mcp/toolkits/thread/handlers.ts +++ b/apps/server/src/mcp/toolkits/thread/handlers.ts @@ -132,7 +132,10 @@ export const layer = McpToolAccess.toLayer(ThreadToolkit, { creationSource: "mcp", }) .pipe(Effect.mapError(dispatchFailure)); - const fork = yield* threads.getThreadShell(targetThreadId).pipe(Effect.mapError(unavailable)); + // The fork is committed, so a failed title read must not fail the call and invite a retry. + const fork = yield* threads + .getThreadShell(targetThreadId) + .pipe(Effect.orElseSucceed(() => null)); return { sequence: result.sequence, targetThreadId, diff --git a/docs/orchestration-v2/orchestrator-mcp-server.md b/docs/orchestration-v2/orchestrator-mcp-server.md index 48282c5fb217..50e49958a749 100644 --- a/docs/orchestration-v2/orchestrator-mcp-server.md +++ b/docs/orchestration-v2/orchestrator-mcp-server.md @@ -249,6 +249,7 @@ the timeout expires. A wait timeout does not cancel the child; the result sets type DelegateTaskResult = { taskId: string; childThreadId: string; + childThreadLink: string; childRunId: string | null; childNodeId: string; status: "queued" | "running" | "waiting" | "completed" | "failed" | "cancelled" | "interrupted";