Skip to content

Commit 2d26647

Browse files
feat(server): t3_thread_import, conversation only
A thread moving here from a linked environment needs its conversation. t3_thread_import creates the thread with that history as runless, imported messages, so its first run gets it as a text context handoff whatever the provider, then sends continuationPrompt as that run's message. The thread id is a hash of the source environment, thread and handoff id, and the continuation's command id derives from the thread, so a retried import returns the thread the first created and starts nothing new. The tool goes through startsThreads: modes are capped by the caller's, and a link's import carries its linkOrigin. worktreePath, when given, must be one of the project's worktrees. The imported-history header no longer names "the previous T3 Code orchestrator", since history now also arrives from other environments. The capturing Codex adapter from the V1 cutover test moves to a testkit. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
1 parent 2b63f76 commit 2d26647

16 files changed

Lines changed: 769 additions & 162 deletions

‎apps/server/src/environment/ServerEnvironment.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -267,6 +267,7 @@ export const make = Effect.gen(function* () {
267267
peerLinks: true,
268268
mcpModeLimitHeader: true,
269269
linkFence: true,
270+
threadImport: true,
270271
},
271272
};
272273

‎apps/server/src/mcp/linkOrigin.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import * as PeerForwarding from "../peer/PeerForwarding.ts";
4545
import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";
4646
import * as PeerLinkRequests from "../peer/PeerLinkRequests.ts";
4747
import * as PeerLinks from "../peer/PeerLinks.ts";
48+
import * as ThreadImportService from "../orchestration-v2/ThreadImportService.ts";
4849

4950
// A linked environment's session drives this one's real orchestrator through
5051
// its real T3 tools. What the link starts carries its origin, and the link
@@ -159,6 +160,7 @@ const layerTools = Layer.mergeAll(
159160
Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
160161
Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
161162
Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
163+
Layer.provide(Layer.mock(ThreadImportService.ThreadImportService)({})),
162164
Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
163165
Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
164166
Layer.provide(

‎apps/server/src/mcp/toolkits/project/handlers.ts‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import * as Effect from "effect/Effect";
99
import * as FileSystem from "effect/FileSystem";
1010
import * as Option from "effect/Option";
1111
import * as ThreadLaunch from "../../../orchestration-v2/ThreadLaunchService.ts";
12+
import * as ThreadImportService from "../../../orchestration-v2/ThreadImportService.ts";
1213
import * as ThreadMessageIntake from "../../../orchestration-v2/ThreadMessageIntake.ts";
1314
import * as Claims from "../../../orchestration-v2/AttachmentClaims.ts";
1415
import * as Project from "../../../project/ProjectService.ts";
@@ -203,6 +204,40 @@ export const layer = McpToolAccess.toLayer(ProjectToolkit, {
203204
// The launched thread carries the caller's link, if it has one.
204205
"stamped",
205206
),
207+
t3_thread_import: McpToolAccess.startsThreads(
208+
(input) => input,
209+
(input, { runtimeMode, interactionMode, linkOrigin }) =>
210+
Effect.gen(function* () {
211+
const projects = yield* access;
212+
const project = yield* projects
213+
.getById(input.projectId)
214+
.pipe(Effect.mapError(unavailable), Effect.map(Option.getOrUndefined));
215+
if (project === undefined)
216+
return yield* new OrchestratorMcpFailure({
217+
code: "invalid_request",
218+
message: "The project was not found.",
219+
});
220+
if (input.worktreePath !== undefined)
221+
yield* assertProjectWorktree(project.workspaceRoot, input.worktreePath);
222+
const imports = yield* ThreadImportService.ThreadImportService;
223+
return yield* imports
224+
.importThread({ ...input, runtimeMode, interactionMode, linkOrigin })
225+
.pipe(
226+
Effect.mapError(
227+
(error) =>
228+
new OrchestratorMcpFailure({
229+
code:
230+
error._tag === "ThreadImportProjectMismatchError"
231+
? "invalid_request"
232+
: "orchestration_error",
233+
message: error.message,
234+
}),
235+
),
236+
);
237+
}),
238+
// A moved thread carries the link that moved it, like a launch.
239+
"stamped",
240+
),
206241
t3_project_list: McpToolAccess.reads((input) =>
207242
Effect.gen(function* () {
208243
const projects = yield* access;

‎apps/server/src/mcp/toolkits/project/tools.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,15 @@ import {
1717
OrchestratorMcpClientRequestId,
1818
OrchestratorMcpEnvironmentTarget,
1919
OrchestratorMcpFailure,
20+
OrchestratorMcpThreadImportInput,
21+
OrchestratorMcpThreadImportResult,
2022
SourceControlCloneRepositoryInput,
2123
SourceControlCloneRepositoryResult,
2224
} from "@t3tools/contracts";
2325
import * as FileSystem from "effect/FileSystem";
2426
import * as ServerConfig from "../../../config.ts";
2527
import * as ServerSettings from "../../../serverSettings.ts";
28+
import * as ThreadImportService from "../../../orchestration-v2/ThreadImportService.ts";
2629
import * as ThreadLaunchService from "../../../orchestration-v2/ThreadLaunchService.ts";
2730
import * as Crypto from "effect/Crypto";
2831
import * as Schema from "effect/Schema";
@@ -174,8 +177,25 @@ const ThreadLaunchTool = Tool.make("t3_thread_launch", {
174177
.annotate(Tool.Destructive, true)
175178
.annotate(Tool.OpenWorld, true);
176179

180+
const ThreadImportTool = Tool.make("t3_thread_import", {
181+
...shared,
182+
description:
183+
"Create a thread here that continues a conversation from another T3 Code environment, as when a thread is handed off to this one. The messages become the thread's history and its first run gets them as context; continuationPrompt, if given, starts that run. Retrying with the same source returns the thread the first call created. It runs within the caller's modes.",
184+
parameters: OrchestratorMcpThreadImportInput,
185+
success: OrchestratorMcpThreadImportResult,
186+
dependencies: [
187+
...shared.dependencies,
188+
ThreadImportService.ThreadImportService,
189+
GitVcsDriver.GitVcsDriver,
190+
FileSystem.FileSystem,
191+
],
192+
})
193+
.annotate(Tool.Destructive, true)
194+
.annotate(Tool.Idempotent, true);
195+
177196
export const ProjectToolkit = Toolkit.make(
178197
ThreadLaunchTool,
198+
ThreadImportTool,
179199
ProjectListTool,
180200
ProjectReadTool,
181201
ProjectCreateTool,

‎apps/server/src/mcp/toolkits/worktree/registration.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import * as PreviewBrowser from "../../../preview/PreviewBrowser.ts";
3838
import * as PeerForwarding from "../../../peer/PeerForwarding.ts";
3939
import * as PeerLinkRequests from "../../../peer/PeerLinkRequests.ts";
4040
import * as PeerLinks from "../../../peer/PeerLinks.ts";
41+
import * as ThreadImportService from "../../../orchestration-v2/ThreadImportService.ts";
4142

4243
const layerStubServices = Layer.mergeAll(
4344
Layer.mock(Orchestrator.OrchestratorV2)({}),
@@ -50,6 +51,7 @@ const layerStubServices = Layer.mergeAll(
5051
Layer.mock(PeerForwarding.PeerForwarding)({}),
5152
Layer.mock(PeerLinkRequests.PeerLinkRequests)({}),
5253
Layer.mock(PeerLinks.PeerLinks)({}),
54+
Layer.mock(ThreadImportService.ThreadImportService)({}),
5355
Layer.mock(SecretRequests.SecretRequests)({}),
5456
Layer.mock(RemoteDelegation.RemoteDelegation)({}),
5557
Layer.mock(ProjectService.ProjectService)({}),

‎apps/server/src/orchestration-v2/ContextHandoffService.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,7 @@ function makeLegacyImportSummary(items: ReadonlyArray<OrchestrationV2TurnItem>):
153153
}
154154
});
155155
const header =
156-
"Imported conversation history from the previous T3 Code orchestrator. Use it as context; do not repeat it unless the user asks.";
156+
"Imported conversation history from before this thread ran here. Use it as context; do not repeat it unless the user asks.";
157157
const maxChars = 32_000;
158158
const selected: Array<string> = [];
159159
let remaining = maxChars - header.length - 2;
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
2+
import { assert, it } from "@effect/vitest";
3+
import {
4+
EnvironmentId,
5+
ProjectId,
6+
ProviderInstanceId,
7+
ThreadId,
8+
type OrchestrationV2ThreadProjection,
9+
} from "@t3tools/contracts";
10+
import * as Effect from "effect/Effect";
11+
import * as Layer from "effect/Layer";
12+
import * as Ref from "effect/Ref";
13+
14+
import * as SqlitePersistence from "../persistence/Sqlite.ts";
15+
import * as ServerSettings from "../serverSettings.ts";
16+
import * as CommandReceiptStore from "./CommandReceiptStore.ts";
17+
import * as EventSink from "./EventSink.ts";
18+
import * as EventStore from "./EventStore.ts";
19+
import * as Orchestrator from "./Orchestrator.ts";
20+
import * as ProjectionStore from "./ProjectionStore.ts";
21+
import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
22+
import * as ThreadImportService from "./ThreadImportService.ts";
23+
import * as ThreadManagement from "./ThreadManagementService.ts";
24+
import { type CapturedTurn, makeCapturingCodexAdapter } from "./testkit/CapturingCodexAdapter.ts";
25+
import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
26+
import { checkpointWorkspace } from "@t3tools/provider-testing/replayWorkspace";
27+
28+
// A thread moved here from another environment arrives with its conversation.
29+
// Its first run here gets that history as a context handoff, so the agent
30+
// picks up where it left off, and a retried import changes nothing.
31+
32+
const modelSelection = { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" };
33+
const source = {
34+
environmentId: EnvironmentId.make("environment:laptop"),
35+
threadId: ThreadId.make("thread:on-the-laptop"),
36+
handoffId: "handoff-1",
37+
};
38+
const history = [
39+
{
40+
role: "user" as const,
41+
text: "Find why the login test flakes.",
42+
createdAt: "2026-10-06T10:00:00.000Z",
43+
},
44+
{
45+
role: "assistant" as const,
46+
text: "EARLIER_FINDING: the session cookie races the redirect.",
47+
createdAt: "2026-10-06T10:01:00.000Z",
48+
},
49+
];
50+
const continuation = "Carry on from where you stopped on the laptop.";
51+
52+
const layerFor = (workspace: string, captured: Ref.Ref<ReadonlyArray<CapturedTurn>>) => {
53+
const layerDatabase = SqlitePersistence.layerMemory;
54+
const layerStores = Layer.mergeAll(
55+
layerDatabase,
56+
EventStore.layer.pipe(Layer.provideMerge(layerDatabase)),
57+
ProjectionStore.layer.pipe(Layer.provideMerge(layerDatabase)),
58+
);
59+
const layerOrchestrator = ProviderReplayHarness.layerWithRegistry(
60+
{
61+
name: "thread-import",
62+
runtimePolicyOverride: {
63+
cwd: workspace,
64+
approvalPolicy: "never",
65+
sandboxPolicy: { type: "readOnly", access: { type: "fullAccess" }, networkAccess: false },
66+
},
67+
},
68+
ProviderAdapterRegistry.layerSingle(
69+
makeCapturingCodexAdapter(captured, { response: "Picked it up.", modelSelection }),
70+
),
71+
{ databaseLayer: layerDatabase },
72+
);
73+
const layerThreads = ThreadManagement.layer.pipe(
74+
Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
75+
Layer.provide(ServerSettings.layerTest()),
76+
Layer.provideMerge(layerOrchestrator),
77+
);
78+
return ThreadImportService.layer.pipe(
79+
Layer.provideMerge(layerThreads),
80+
Layer.provideMerge(EventSink.layer.pipe(Layer.provide(layerStores))),
81+
Layer.provideMerge(layerStores),
82+
Layer.provide(NodeCrypto.layer),
83+
);
84+
};
85+
86+
const waitForIdle = (threadId: ThreadId) =>
87+
Effect.gen(function* () {
88+
const orchestrator = yield* Orchestrator.OrchestratorV2;
89+
for (let attempt = 0; attempt < 1_000; attempt += 1) {
90+
const projection: OrchestrationV2ThreadProjection =
91+
yield* orchestrator.getThreadProjection(threadId);
92+
if (
93+
projection.runs.length > 0 &&
94+
projection.runs.every(
95+
(run) => !["queued", "starting", "running", "waiting"].includes(run.status),
96+
)
97+
) {
98+
return projection;
99+
}
100+
yield* Effect.sleep("5 millis");
101+
}
102+
return yield* Effect.die(new Error("The imported thread never went idle."));
103+
});
104+
105+
it.live(
106+
"an imported thread's first run continues with its history, and re-importing is a no-op",
107+
() =>
108+
Effect.scoped(
109+
Effect.gen(function* () {
110+
const workspace = yield* checkpointWorkspace("thread-import");
111+
const captured = yield* Ref.make<ReadonlyArray<CapturedTurn>>([]);
112+
yield* Effect.gen(function* () {
113+
const imports = yield* ThreadImportService.ThreadImportService;
114+
const request = {
115+
source,
116+
projectId: ProjectId.make("project:box"),
117+
title: "Flaky login test",
118+
modelSelection,
119+
runtimeMode: "full-access" as const,
120+
interactionMode: "default" as const,
121+
linkOrigin: { sessionId: "session-laptop", label: "T3 Code · Laptop" },
122+
messages: history,
123+
continuationPrompt: continuation,
124+
};
125+
const first = yield* imports.importThread(request);
126+
assert.isTrue(first.created);
127+
const projection = yield* waitForIdle(first.threadId);
128+
129+
// The history is there, before the first run, and marked as imported.
130+
assert.equal(projection.thread.historyOrigin, "v1_import");
131+
assert.deepEqual(projection.thread.linkOrigin, request.linkOrigin);
132+
const runless = projection.messages.filter((message) => message.runId === null);
133+
assert.deepEqual(
134+
runless.map((message) => [message.role, message.text]),
135+
history.map((message) => [message.role, message.text]),
136+
);
137+
// The first run got the history as context, then the continuation.
138+
assert.equal(projection.contextHandoffs.length, 1);
139+
const turns = yield* Ref.get(captured);
140+
assert.equal(turns.length, 1);
141+
assert.include(turns[0]!.text, "Context handoff (manual_context):");
142+
assert.include(turns[0]!.text, "EARLIER_FINDING");
143+
assert.include(turns[0]!.text, `User message:\n${continuation}`);
144+
145+
// Retrying the same import creates nothing and runs nothing new.
146+
const retried = yield* imports.importThread(request);
147+
assert.deepEqual(retried, { ...first, created: false });
148+
const after = yield* Orchestrator.OrchestratorV2.pipe(
149+
Effect.flatMap((orchestrator) => orchestrator.getThreadProjection(first.threadId)),
150+
);
151+
assert.equal(after.messages.length, projection.messages.length);
152+
assert.equal(after.runs.length, 1);
153+
assert.equal((yield* Ref.get(captured)).length, 1);
154+
155+
// A different handoff of the same source thread is a new thread.
156+
const again = yield* imports.importThread({
157+
...request,
158+
source: { ...source, handoffId: "handoff-2" },
159+
continuationPrompt: undefined,
160+
});
161+
assert.notEqual(again.threadId, first.threadId);
162+
assert.isNull(again.runId);
163+
}).pipe(Effect.provide(layerFor(workspace, captured)));
164+
}),
165+
),
166+
);

0 commit comments

Comments
 (0)