feat(mcp): fork existing conversations#8696

closed
t3code/codex-turn-mapping
agents/mcp-conversations/fork
openedAug 29, 2026byJJulius Marminge
View on GitHubTwo-way sync enabledPR last imported 2d ago

Problem

Agents could create ordinary or delegated threads, but could not create a durable T3 conversation fork from a precise stable source or inspect its context-transfer provenance.

Change

Add current-project t3_thread_fork and bounded t3_thread_transfers tools backed by a focused service and the existing thread.fork V2 command through ThreadManagementService.

Behavior

Forks require a completed run, checkpoint, or latest stable source plus a retry key. Results return the committed transfer and receipt, inherited-permission enforcement, and truthful native-fork eligibility while leaving native or portable resolution to the first target turn. Derived target identities use a fork-specific namespace and cannot overwrite existing or soft-deleted threads.

Focused validation

  • transfer contract, service, root-schema, production MCP, and real V2/provider-adapter tests
  • retry, inherited history, caller/source race, identity collision, occupied-target, and projection-failure coverage
  • 5 focused files / 16 tests passed across the complete native stack
  • server and contracts scoped typechecks, targeted formatting, and git diff --check

Dependency

Bottom layer of native stack 8776, based on t3code/codex-turn-mapping at the pinned rollout target. #8697 consumes this transfer contract and service for merge-back.

Implemented by GPT-5.6-Sol via Codex in T3 Code.

Note

Add MCP conversation fork and transfer list tools

  • Adds ConversationTransferMcpService exposing t3_thread_transfers for bounded read-only transfer listing and t3_thread_fork for conversation mutations.
  • Orchestrator thread.fork dispatch validates caller policy ceilings (runtime and interaction modes) and checks target projection existence before issuing fork commands.
  • Replaces the single command-thread lock with recursive multi-key dispatch locks (withDispatchLocks) so forks coordinate on source, target, and caller threads.
  • Contracts package adds ConversationForkInput, ConversationForkResult, and TransferPageLimit schemas with strict validation (e.g., Unicode well-formedness for retry keys).
  • Risk: thread.fork dispatch now fails when target projection exists or source runtime/interaction mode exceeds the caller ceiling; withDispatchLocks changes dispatch lock acquisition to multiple sorted keys.

Macroscope summarized 1ad23c2.

Synced from GitHub

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f1504bd4-3f53-49c1-822c-a7e1bd9ac198

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Warning

Your free Security trial is over. An organization admin can activate Security or dismiss this notice.


Comment @coderabbitai help to get the list of available commands.

apps/server/src/mcp/ConversationTransferMcpService.tslines 31–45Outdated · b268728Open thread
……@@ -0,0 +1,292 @@
42+export class ConversationTransferMcpService extends Context.Service<
43+ ConversationTransferMcpService,
44+ ConversationTransferMcpServiceShape
45+>()("t3/mcp/ConversationTransferMcpService") {}
MmacroscopeappBoton lines 31–45Aug 29, 2026

mcp/ConversationTransferMcpService.ts:45

New service modules should declare their interface inline in the Context.Service call rather than keeping a standalone ...ServiceShape type. Nothing else references ConversationTransferMcpServiceShape, so it can be inlined; consumers that need the type can use ConversationTransferMcpService["Service"].

suggestion
1export class ConversationTransferMcpService extends Context.Service<
2 ConversationTransferMcpService,
3 {
4 readonly list: (
5 scope: McpInvocationScope,
6 input: ConversationTransferListInput,
7 ) => Effect.Effect<ConversationTransferListResult, OrchestratorMcpFailure>;
8 readonly fork: (
9 scope: McpInvocationScope,
10 input: ConversationForkInput,
11 ) => Effect.Effect<ConversationForkResult, OrchestratorMcpFailure>;
12 }
13>()("t3/mcp/ConversationTransferMcpService") {}

No longer relevant as of 99b0ed269816569abcf9033ab6083a44642122a5

Synced from GitHub

Thread transfer impact

⚠️ The latest CI run did not produce a thread transfer result for 1ad23c2.

This comment will update automatically after the next completed run.

apps/server/src/mcp/ConversationTransferMcpService.tsline 171Outdated · b268728Open thread
……@@ -0,0 +1,292 @@
168+ .getProjectThread({ projectId: parent.thread.projectId, threadId })
169+ .pipe(
170+ Effect.mapError((error) =>
171+ isThreadManagementError(error)
MmacroscopeappBoton line 171Aug 29, 2026

🟡 Medium mcp/ConversationTransferMcpService.ts:171

Projection/database failures from getProjectThread are returned to MCP clients as thread_not_found, so transient server errors are treated as missing threads. isThreadManagementError(error) matches both ThreadManagementThreadNotFoundError and ThreadManagementProjectionLoadError; narrow this branch to ThreadManagementThreadNotFoundError so projection failures reach the orchestration_error fallback.

✅ Resolved in 99b0ed269816569abcf9033ab6083a44642122a5

apps/server/src/mcp/ConversationTransferMcpService.tsline 206Outdated · b268728Open thread
……@@ -0,0 +1,292 @@
203+ fork: (scope, input) =>
204+ Effect.gen(function* () {
205+ const { parent, target: source } = yield* loadScoped(scope, input.sourceThreadId);
206+ if (
MmacroscopeappBoton line 206Aug 29, 2026

🟠 High mcp/ConversationTransferMcpService.ts:206

A fork can be created with a runtime or interaction mode above the caller's current ceiling if a thread.runtime-mode.set or thread.interaction-mode.set runs after these checks. threads.dispatch reloads the source projection and ThreadForkService copies that newer configuration into the target, so the earlier parent/source snapshots are a TOCTOU check; enforce the ceilings atomically during dispatch (or in dispatchThreadFork) using the current projections.

✅ Resolved in 99b0ed269816569abcf9033ab6083a44642122a5

apps/server/src/mcp/ConversationTransferMcpService.tsline 205Outdated · b268728Open thread
……@@ -0,0 +1,292 @@
202+ }),
203+ fork: (scope, input) =>
204+ Effect.gen(function* () {
205+ const { parent, target: source } = yield* loadScoped(scope, input.sourceThreadId);
MmacroscopeappBoton line 205Aug 29, 2026

🟡 Medium mcp/ConversationTransferMcpService.ts:205

Retrying a committed fork with the same required clientRequestId fails instead of returning the original receipt and transfer when the source is deleted or its runtime/interaction mode changes. loadScoped and the ceiling checks run before constructing and dispatching the stable commandId, so mutable preflight prevents idempotent replay; resolve an existing committed command by commandId before these checks, or otherwise bypass preflight for an already-committed request.

✅ Resolved in 99b0ed269816569abcf9033ab6083a44642122a5

apps/server/src/mcp/ConversationTransferMcpService.tsline 104Outdated · b268728Open thread
……@@ -0,0 +1,292 @@
101+ case "checkpoint":
102+ return projection.runs.find((run) => run.checkpointId === point.checkpointId);
103+ }
104+}

Fork source resolution diverges from orchestrator

Medium Severity

sourceRun does not match orchestrator runForSourcePoint. latest_stable accepts any completed run, while dispatch requires a completed run with a checkpointId. A later uncheckpointed run can pass the pre-check, then either fail dispatch or fork an older run while nativeEligibility is computed against the wrong one. checkpoint looks up run.checkpointId instead of projection.checkpoints, so historical checkpoints are rejected even though thread.fork accepts them.

Reviewed by Cursor Bugbot for commit b26872873df04cf9c8c7ae9cde030f86eaf5220e. Configure here.

apps/server/src/mcp/ConversationTransferMcpService.tsline 175Outdated · b268728Open thread
……@@ -0,0 +1,292 @@
172+ ? failure(
173+ "thread_not_found",
174+ `Thread ${threadId} was not found in the calling project.`,
175+ )

Thread load errors reported as missing

Low Severity

loadScoped maps every ThreadManagementError from getProjectThread to thread_not_found, including ThreadManagementProjectionLoadError. A transient projection failure is reported as a missing thread, unlike OrchestratorMcpService.threadManagementFailure, which maps load errors to orchestration_error.

Reviewed by Cursor Bugbot for commit b26872873df04cf9c8c7ae9cde030f86eaf5220e. Configure here.

Synced from GitHub

Approvability

Verdict: Not approved

Macroscope's review found this PR not approvable — This PR adds production MCP tools that create durable conversation forks and context-transfer records, with new provider-resolution behavior and shared orchestration locking and permission enforcement. Its new user-facing workflow and cross-cutting runtime changes warrant human review.

You can add or adjust custom eligibility rules. Learn more.

apps/server/src/mcp/ConversationTransferMcpService.tsline 81Outdated · 99b0ed2Open thread
……@@ -0,0 +1,371 @@
78+ return ThreadId.make(
79+ `thread:mcp:${stablePart(scope.providerSessionId)}:fork:${stablePart(requestKey)}`,
80+ );
81+}

Fork thread IDs collide with create

Medium Severity

Fork target IDs use thread:mcp:{session}:fork:{key}, which occupies the same four-segment shape as create_threads IDs (thread:mcp:{session}:{requestKey}:{index}). A create_threads call with clientRequestId fork and a later t3_thread_fork with a numeric key such as 0 resolve to the same ThreadId. The create path then dispatches thread.fork onto an existing ordinary thread, which can overwrite that thread as a fork.

Reviewed by Cursor Bugbot for commit 99b0ed269816569abcf9033ab6083a44642122a5. Configure here.

Fixed in 125913a4f2. Fork targets now use a structurally disjoint conversation-fork:target namespace, and the serialized fork decision rejects any pre-existing target before planning. Integration coverage reproduces create_threads(key=fork,index=0) plus t3_thread_fork(key=0), verifies distinct threads, and proves an occupied target remains unchanged.

apps/server/src/mcp/ConversationTransferMcpService.tslines 123–128Outdated · b3772e6Open thread
……@@ -0,0 +1,371 @@
125+ !session.capabilities.threads.canForkFromTurn
126+ ) {
127+ return "provider_does_not_support_turn_fork";
128+ }
MmacroscopeappBoton lines 123–128Aug 30, 2026

🟡 Medium mcp/ConversationTransferMcpService.ts:123

nativeEligibility returns "eligible" for a completed fork source with a strong native thread reference but no providerTurnRef, even when session.capabilities.threads.canForkFromTurn is false. The target's first turn resolves that source run as fromSpecificTurn and falls back to portable context, so the advertised native eligibility is incorrect; check canForkFromTurn for every resolvable source run.

suggestion
1 if (!session.capabilities.threads.canForkFromTurn) {
2 return "provider_does_not_support_turn_fork";
3 }
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
text
1In file @apps/server/src/mcp/ConversationTransferMcpService.ts around lines 123-128:
2
3`nativeEligibility` returns `"eligible"` for a completed fork source with a strong native thread reference but no `providerTurnRef`, even when `session.capabilities.threads.canForkFromTurn` is `false`. The target's first turn resolves that source run as `fromSpecificTurn` and falls back to portable context, so the advertised native eligibility is incorrect; check `canForkFromTurn` for every resolvable source run.

Fixed in 125913a4f2. Every resolved durable source run is now classified as a specific-turn fork, matching the first-target-turn policy decision, so canForkFromTurn is always required for native eligibility. Focused coverage includes a strong native source with no providerTurnRef and canForkFromTurn=false.

Synced from GitHub

Macroscope skipped reviewing this pull request. Per-review cost limit exceeded (workspace setting).

This review would cost an estimated $246.19, which exceeds your per-review limit of $10.00.

The top 3 files driving up this estimate:

FileDiff SizeEstimate
apps/server/src/orchestration-v2/Orchestrator.ts279.68KB$13.98
apps/server/src/orchestration-v2/Adapters/AcpAdapterV2.ts271.74KB$13.59
apps/server/src/orchestration-v2/Adapters/CodexAdapterV2.ts215.40KB$10.77

Tip

To get this pull request reviewed, you can:

  1. Comment @macroscope-app on this PR to request a manual review (monthly spend limits still apply).
  2. Exclude the file(s) above from review by adding a pattern to your .macroscope/ignore.md — note that creating this file replaces Macroscope's built-in default ignores rather than extending them.
  3. Raise your cost limit in your workspace billing settings.

Turn off this reminder going forward

MmacroscopeappBotreviewed · 1 inline threadAug 30, 2026
Synced from GitHub

One convention issue found: a new single-tag Effect.catchTag in Orchestrator.ts. The rest of the new ConversationTransferMcpService module (inline Context.Service interface, environment-acquired ThreadManagementService, exported make/layer, namespace imports of Effect modules) matches the conventions.

Posted via Macroscope — Effect Service Conventions

apps/server/src/orchestration-v2/Orchestrator.tsline 2072Outdated · d55ea91Open thread
……@@ -2048,6 +2065,65 @@ const makeOrchestrator = Effect.fn("orchestrationV2.Orchestrator.layer")(functio
2069+ .getThreadProjection(command.targetThreadId)
2070+ .pipe(
2071+ Effect.as(true),
2072+ Effect.catchTag("ProjectionStoreThreadNotFoundError", () => Effect.succeed(false)),
MmacroscopeappBoton line 2072Aug 30, 2026

orchestration-v2/Orchestrator.ts:2072

Prefer Effect.catchTags({ ... }) for statically known tagged failures, even when handling a single tag — catchTag isn't the convention here (the rest of the repo uses catchTags, e.g. GitVcsDriverCore.ts).

suggestion
1 Effect.catchTags({ ProjectionStoreThreadNotFoundError: () => Effect.succeed(false) }),

No longer relevant as of ef0aa127e9a5c23370bd00ef99ef0f71b0217524

packages/contracts/src/conversationTransferMcp.tsline 39Outdated · 7577ec0Open thread
……@@ -0,0 +1,112 @@
36+ hasWellFormedUnicode(value) ||
37+ "The request key must not contain unpaired Unicode surrogates.",
38+ ),
39+).annotate({ description: "Stable idempotency key to reuse when retrying this mutation." });

Shared MCP key schema has description

Medium Severity

WellFormedRequestKey is a reusable MCP input schema that carries its own description plus filter/isMaxLength annotations. Used as clientRequestId on t3_thread_fork, that composition can emit multiple described allOf members and cause some providers to reject the tool schema on every turn.

Additional Locations (1)

Triggered by learned rule: MCP tool input schemas: only one description per allOf branch in Effect Schema compositions

Reviewed by Cursor Bugbot for commit 7577ec0bb0925bc1aa823f98b433f7a7d0fc7a8c. Configure here.

Verified against the exact production serializer used by McpHttpServer: Tool.getJsonSchema for t3_thread_fork emits clientRequestId as { "type": "string", "allOf": [{ "maxLength": 256 }] }. It contains zero description members, and the overall input remains a root object. The source annotation is not duplicated into described allOf branches by this Effect version, so the reported provider-rejection shape is not present. No schema change is warranted.

apps/server/src/mcp/ConversationTransferMcpService.tsline 303Outdated · 7577ec0Open thread
……@@ -0,0 +1,367 @@
300+ }
301+
302+ const { parent, target: source } = yield* loadScoped(scope, input.sourceThreadId);
303+ if (

Retry probe swallows projection errors

Medium Severity

Effect.option treats every getThreadProjection failure as a missing target, so a retry can miss an existing fork transfer and fall through into the first-create permission checks. After the caller ceiling has been lowered, that path denies a retry that should have replayed the original receipt.

Reviewed by Cursor Bugbot for commit 7577ec0bb0925bc1aa823f98b433f7a7d0fc7a8c. Configure here.

Fixed in 9fb708321bbe4717656aba85f98a93e43bfde159. The target probe now treats only an underlying ProjectionStoreThreadNotFoundError as absence; other projection failures return the safe, retryable orchestration_error before source planning, policy checks, or dispatch. The focused regression injects an operational projection failure and asserts that dispatch is never called. Accepted-retry behavior remains unchanged when projection observation is available.

Ccursor[bot]reviewed · 1 inline threadSep 5, 2026
Synced from GitHub

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

Bugbot Autofix is ON, but a cloud agent failed to start.

Reviewed by Cursor Bugbot for commit 6f3e62696d120c88207290321ec42dde4d75a488. Configure here.

apps/server/src/mcp/ConversationTransferMcpService.tsline 79Outdated · 6f3e626Open thread
……@@ -0,0 +1,388 @@
76+}
77+
78+function interactionModeRank(mode: ProviderInteractionMode): number {
79+ return mode === "plan" ? 0 : 1;

Triplicated rank functions risk inconsistent permission checks

Medium Severity

runtimeModeRank and interactionModeRank now exist in three separate files: ConversationTransferMcpService.ts, OrchestratorMcpService.ts, and Orchestrator.ts. Two of these three copies are newly added in this PR. These identical functions drive permission-ceiling enforcement at different layers — a future mode addition or ranking change updated in only one or two copies would silently produce inconsistent escalation checks. Extracting them into a shared utility would prevent that drift.

Additional Locations (1)

Reviewed by Cursor Bugbot for commit 6f3e62696d120c88207290321ec42dde4d75a488. Configure here.

Verified against 6f3e62696d120c88207290321ec42dde4d75a488: all three rank tables are currently identical and exhaustive for the closed RuntimeMode and ProviderInteractionMode unions, so there is no inconsistent permission decision in this head. The copies sit at separate MCP and durable command boundaries intentionally; extracting a new cross-layer utility here would be speculative architecture churn rather than a correctness fix. We will keep this scoped and update all boundaries together if either contract gains a mode.

Superseded as the active MCP proposal by #10558 in the rebuilt MCP stack, following the maintainer request to review thin service wrappers separately from service hardening.

This is not a full feature-parity replacement. Each invocation generates a new command. No additional clientRequestId protocol, transfer journal or provider implementation. Merge targets remain in the calling project.

Closing this mixed proposal without merging. The remote branch agents/mcp-conversations/fork and exact head 1ad23c2032b0aa1ce9a55e4d50ce73cd5f6fc20a are retained so the service changes can be extracted for separate review later.