Skip to content
Source
Sign inSign up

linkOrigin.test.ts

427 lines16.9 KB

1import * as DocumentText from "../provider/DocumentText.ts";
2import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
3import * as NodeServices from "@effect/platform-node/NodeServices";
4import { assert, it } from "@effect/vitest";
5import {
6 CommandId,
7 ContextTransferId,
8 EnvironmentId,
9 NodeId,
10 type OrchestrationV2Run,
11 ProjectId,
12 ProviderDriverKind,
13 ProviderInstanceId,
14 RunId,
15 ThreadId,
16} from "@t3tools/contracts";
17import * as Effect from "effect/Effect";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import { McpSchema, McpServer } from "effect/ai";
21​
22import * as ServerConfig from "../config.ts";
23import { CodexProviderCapabilitiesV2 } from "../orchestration-v2/Adapters/CodexAdapterV2.ts";
24import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
25import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts";
26import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
27import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
28import { makeSubagentChildThread } from "@t3tools/provider-core/server/subagentProjection";
29import * as ThreadForkService from "../orchestration-v2/ThreadForkService.ts";
30import * as ThreadLaunch from "../orchestration-v2/ThreadLaunchService.ts";
31import * as ThreadManagement from "../orchestration-v2/ThreadManagementService.ts";
32import * as ProviderReplayHarness from "../orchestration-v2/testkit/ProviderReplayHarness.ts";
33import * as SqlitePersistence from "../persistence/Sqlite.ts";
34import * as ManagedProjectFolders from "../project/ManagedProjectFolders.ts";
35import * as ProjectCloneTracker from "../project/ProjectCloneTracker.ts";
36import * as ProjectService from "../project/ProjectService.ts";
37import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
38import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
39import * as ServerSettings from "../serverSettings.ts";
40import * as CommandReceiptStore from "../orchestration-v2/CommandReceiptStore.ts";
41import * as SecretRequests from "../secrets/SecretRequests.ts";
42import * as RemoteDelegation from "../peer/RemoteDelegation.ts";
43import * as SourceControlRepositoryService from "../sourceControl/SourceControlRepositoryService.ts";
44import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
45import * as McpHttpServer from "./McpHttpServer.ts";
46import * as McpInvocationContext from "./McpInvocationContext.ts";
47import * as PeerForwarding from "../peer/PeerForwarding.ts";
48import * as ThreadSearch from "../orchestration-v2/ThreadSearch.ts";
49import * as PeerLinkRequests from "../peer/PeerLinkRequests.ts";
50import * as PeerLinks from "../peer/PeerLinks.ts";
51​
52// A linked environment's session drives this one's real orchestrator through
53// its real T3 tools. What the link starts carries its origin, and the link
54// changes only that.
55​
56const instanceId = ProviderInstanceId.make("codex");
57const projectId = ProjectId.make("project:link-origin");
58const adapter = {
59 instanceId,
60 driver: ProviderDriverKind.make("codex"),
61 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
62 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
63 openSession: () => Effect.die("No provider process needed for these commands"),
64} as ProviderAdapter.ProviderAdapterV2["Service"];
65const layerDatabase = SqlitePersistence.layerMemory;
66const layerOrchestrator = Layer.mergeAll(
67 layerDatabase,
68 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
69 ProviderReplayHarness.layerWithRegistry(
70 { name: "link-origin" },
71 ProviderAdapterRegistry.layerFromAdapters([adapter]),
72 { databaseLayer: layerDatabase, runEffectWorker: false },
73 ),
74);
75const layerOrchestration = ThreadManagement.layer.pipe(
76 Layer.provide(CommandReceiptStore.layer.pipe(Layer.provide(layerDatabase))),
77 Layer.provideMerge(Layer.merge(layerOrchestrator, ServerSettings.layerTest())),
78);
79​
80/** Launches dispatch the real thread.create, and nothing more: no workspace, no run. */
81const layerLaunches = Layer.effect(
82 ThreadLaunch.ThreadLaunchService,
83 Effect.gen(function* () {
84 const threads = yield* ThreadManagement.ThreadManagementService;
85 return ThreadLaunch.ThreadLaunchService.of({
86 launch: (input: ThreadLaunch.ThreadLaunchInput) =>
87 threads
88 .dispatch({
89 type: "thread.create",
90 commandId: input.commandId,
91 threadId: input.threadId!,
92 projectId: input.projectId,
93 title: input.title,
94 modelSelection: input.modelSelection,
95 runtimeMode: input.runtimeMode,
96 interactionMode: input.interactionMode,
97 branch: null,
98 worktreePath: null,
99 ...(input.linkOrigin === undefined ? {} : { linkOrigin: input.linkOrigin }),
100 ...(input.delegatedFrom === undefined ? {} : { delegatedFrom: input.delegatedFrom }),
101 createdBy: input.createdBy,
102 creationSource: input.creationSource,
103 })
104 .pipe(
105 Effect.flatMap(() => threads.getThreadProjection(input.threadId!)),
106 Effect.map((projection) => ({
107 threadId: input.threadId!,
108 projection,
109 resumed: false,
110 })),
111 Effect.orDie,
112 ),
113 } as unknown as ThreadLaunch.ThreadLaunchService["Service"]);
114 }),
115);
116​
117const layerTools = Layer.mergeAll(
118 McpHttpServer.layerOrchestratorToolkit,
119 McpHttpServer.layerThreadToolkit,
120 McpHttpServer.layerProjectRegistration,
121).pipe(
122 Layer.provide(DocumentText.layer.pipe(Layer.provideMerge(NodeServices.layer))),
123 Layer.provideMerge(McpHttpServer.layerInProcessMcpServer),
124 Layer.provide(layerLaunches),
125 Layer.provide(NodeCrypto.layer),
126 Layer.provide(
127 Layer.mock(ProviderRegistry.ProviderRegistry)({ getProviders: Effect.succeed([]) }),
128 ),
129 Layer.provide(
130 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
131 list: () => Effect.succeed([]),
132 }),
133 ),
134 Layer.provide(Layer.mock(ScheduledTaskService.ScheduledTaskService)({})),
135 Layer.provide(
136 Layer.mock(ProjectService.ProjectService)({
137 getById: (id) =>
138 Effect.succeed(
139 id === projectId
140 ? Option.some({
141 id,
142 title: "t3code",
143 workspaceRoot: "/p/t3code",
144 defaultModelSelection: null,
145 scripts: [],
146 createdAt: "2026-10-18T00:00:00.000Z",
147 updatedAt: "2026-10-18T00:00:00.000Z",
148 deletedAt: null,
149 })
150 : Option.none(),
151 ),
152 create: (input) =>
153 Effect.succeed({
154 id: input.projectId,
155 title: input.title,
156 workspaceRoot: input.workspaceRoot,
157 defaultModelSelection: null,
158 scripts: [],
159 createdAt: "2026-10-18T00:00:00.000Z",
160 updatedAt: "2026-10-18T00:00:00.000Z",
161 deletedAt: null,
162 }),
163 update: (input) =>
164 Effect.succeed({
165 id: input.projectId,
166 title: input.title ?? "Untitled",
167 workspaceRoot: "/p/t3code",
168 defaultModelSelection: null,
169 scripts: [],
170 createdAt: "2026-10-18T00:00:00.000Z",
171 updatedAt: "2026-10-18T00:00:00.000Z",
172 deletedAt: null,
173 }),
174 }),
175 ),
176 Layer.provide(Layer.mock(ProjectCloneTracker.ProjectCloneTracker)({})),
177 Layer.provide(Layer.mock(SecretRequests.SecretRequests)({})),
178 Layer.provide(Layer.mock(PeerForwarding.PeerForwarding)({})),
179 Layer.provide(Layer.mock(PeerLinkRequests.PeerLinkRequests)({})),
180 Layer.provide(Layer.mock(PeerLinks.PeerLinks)({})),
181 Layer.provide(Layer.mock(ThreadSearch.ThreadSearch)({})),
182 Layer.provide(Layer.mock(RemoteDelegation.RemoteDelegation)({})),
183 Layer.provide(
184 Layer.mock(ManagedProjectFolders.ManagedProjectFolders)({ namedProjectsRoot: "/p" }),
185 ),
186 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
187 Layer.provide(
188 Layer.mock(SourceControlRepositoryService.SourceControlRepositoryService)({
189 cloneRepository: (input) =>
190 Effect.succeed({
191 cwd: input.destinationPath,
192 remoteUrl: "https://github.com/pingdotgg/t3code.git",
193 repository: null,
194 }),
195 }),
196 ),
197 Layer.provide(
198 ServerConfig.layerTest(process.cwd(), { prefix: "t3-link-origin-" }).pipe(
199 Layer.provide(NodeServices.layer),
200 ),
201 ),
202 Layer.provide(NodeServices.layer),
203);
204​
205const mcpClient = McpSchema.McpServerClient.of({
206 clientId: 1,
207 protocolVersion: "2025-06-18",
208 clientCapabilities: {},
209 clientInfo: { name: "link-origin", version: "1" },
210 initializePayload: {
211 protocolVersion: "2025-06-18",
212 capabilities: {},
213 clientInfo: { name: "link-origin", version: "1" },
214 },
215 getClient: Effect.die("unused"),
216});
217​
218const clientScope = (
219 sessionId: string,
220 linked: boolean,
221 access: McpInvocationContext.McpClientCaller["access"],
222): McpInvocationContext.McpInvocationScope => ({
223 environmentId: EnvironmentId.make("environment:box"),
224 requestNamespace: `client:${sessionId}`,
225 thread: undefined,
226 client: { sessionId, label: `T3 Code · ${sessionId}`, access, linked },
227 capabilities: new Set(["orchestration", "worktree", "pull-requests"]),
228 issuedAt: 0,
229});
230const laptop = clientScope("laptop", true, "auto");
231const desk = clientScope("desk", true, "auto");
232const trusted = clientScope("trusted", true, "full-access");
233const claudeCode = clientScope("claude-code", false, "full-access");
234​
235const call = (
236 scope: McpInvocationContext.McpInvocationScope,
237 name: string,
238 args: Record<string, unknown>,
239) =>
240 McpServer.McpServer.pipe(
241 Effect.flatMap((server) => server.callTool({ name, arguments: args })),
242 Effect.provideService(McpInvocationContext.McpInvocationContext, scope),
243 Effect.provideService(McpSchema.McpServerClient, mcpClient),
244 );
245​
246const failureCode = (result: McpSchema.CallToolResult) => {
247 const text = result.content[0];
248 return result.isError === true && text?.type === "text"
249 ? (JSON.parse(text.text) as { code: string }).code
250 : undefined;
251};
252​
253const launch = (
254 scope: McpInvocationContext.McpInvocationScope,
255 title: string,
256 extra: Record<string, unknown> = {},
257) =>
258 call(scope, "t3_thread_launch", {
259 projectId,
260 title,
261 modelSelection: { instanceId, model: "gpt-5" },
262 ...extra,
263 }).pipe(
264 Effect.map((result) => {
265 assert.equal(result.isError, false, JSON.stringify(result.content));
266 return (result.structuredContent as { threadId: ThreadId }).threadId;
267 }),
268 );
269​
270const usersOwnThread = Effect.gen(function* () {
271 const orchestrator = yield* Orchestrator.OrchestratorV2;
272 const threadId = ThreadId.make("thread:users-own");
273 yield* orchestrator.dispatch({
274 type: "thread.create",
275 commandId: CommandId.make("create:users-own"),
276 threadId,
277 projectId,
278 title: "The user's own",
279 modelSelection: { instanceId, model: "gpt-5" },
280 // Within an `auto` link's modes, so only the fence decides.
281 runtimeMode: "auto",
282 interactionMode: "default",
283 branch: null,
284 worktreePath: null,
285 createdBy: "user",
286 creationSource: "web",
287 });
288 return threadId;
289});
290​
291it.layer(Layer.provideMerge(layerTools, layerOrchestration))("work a link starts", (it) => {
292 it.effect("is stamped, and so is the work it derives", () =>
293 Effect.gen(function* () {
294 const projections = yield* ProjectionStore.ProjectionStoreV2;
295 const launched = yield* launch(laptop, "Started from the laptop");
296 const shell = yield* projections.getThreadShell(launched);
297 assert.deepEqual(shell?.linkOrigin, {
298 sessionId: "laptop",
299 label: "T3 Code · laptop",
300 access: "auto",
301 });
302​
303 // A subagent of it and a fork of it are built from its thread, origin included.
304 const thread = (yield* ThreadManagement.ThreadManagementService.pipe(
305 Effect.flatMap((threads) => threads.getThreadProjection(launched)),
306 )).thread;
307 const child = makeSubagentChildThread({
308 parentThread: thread,
309 childThreadId: ThreadId.make("thread:child"),
310 parentNodeId: NodeId.make("node:child"),
311 activeProviderThreadId: null,
312 providerInstanceId: instanceId,
313 modelSelection: { instanceId, model: "gpt-5" },
314 title: "Child",
315 now: thread.createdAt,
316 createdBy: "agent",
317 creationSource: "mcp",
318 });
319 assert.deepEqual(child.linkOrigin, shell?.linkOrigin);
320 const fork = yield* ThreadForkService.ThreadForkServiceV2.pipe(
321 Effect.flatMap((forks) =>
322 forks.plan({
323 sourceProjection: { thread },
324 sourceRun: { id: RunId.make("run:source"), status: "completed" } as OrchestrationV2Run,
325 sourceProviderThread: undefined,
326 canonicalSourcePoint: { threadId: launched },
327 transferId: ContextTransferId.make("transfer:fork"),
328 targetThreadId: ThreadId.make("thread:fork"),
329 createdBy: "agent",
330 creationSource: "mcp",
331 createdAt: thread.createdAt,
332 }),
333 ),
334 Effect.provide(ThreadForkService.layer),
335 );
336 assert.deepEqual(fork.targetThread.linkOrigin, shell?.linkOrigin);
337​
338 // An ordinary outside agent's launch is not a link's.
339 const plain = yield* launch(claudeCode, "Started by Claude Code");
340 assert.equal((yield* projections.getThreadShell(plain))?.linkOrigin, undefined);
341​
342 // A link's delegated task names its parent there; only a link's launch may.
343 const delegatedFrom = {
344 environmentId: "environment-laptop",
345 threadId: "thread:laptop-parent",
346 title: "Laptop parent",
347 };
348 const task = yield* launch(laptop, "Delegated from the laptop", { delegatedFrom });
349 assert.deepEqual((yield* projections.getThreadShell(task))?.delegatedFrom, delegatedFrom);
350 const claimed = yield* launch(claudeCode, "Claims a parent", { delegatedFrom });
351 assert.equal((yield* projections.getThreadShell(claimed))?.delegatedFrom, undefined);
352 }),
353 );
354​
355 it.effect("changes only that link's threads below full access", () =>
356 Effect.gen(function* () {
357 const projections = yield* ProjectionStore.ProjectionStoreV2;
358 const users = yield* usersOwnThread;
359 const ownLaunch = yield* launch(laptop, "The laptop's");
360 const rename = (scope: McpInvocationContext.McpInvocationScope, threadId: ThreadId) =>
361 call(scope, "t3_thread_update", {
362 threadId,
363 action: "rename",
364 title: `Renamed by ${scope.client!.sessionId}`,
365 });
366​
367 // The link may change what it started.
368 assert.equal((yield* rename(laptop, ownLaunch)).isError, false);
369 assert.equal((yield* projections.getThreadShell(ownLaunch))?.title, "Renamed by laptop");
370 // Not the user's own thread, nor another link's.
371 assert.equal(failureCode(yield* rename(laptop, users)), "capability_denied");
372 assert.equal(failureCode(yield* rename(desk, ownLaunch)), "capability_denied");
373 assert.equal((yield* projections.getThreadShell(users))?.title, "The user's own");
374 // An ordinary outside agent is not fenced.
375 assert.equal((yield* rename(claudeCode, users)).isError, false);
376 // Naming this environment's own id is still this environment's thread.
377 for (const [name, args] of [
378 ["t3_thread_send", { message: "From the laptop." }],
379 ["t3_thread_interrupt", {}],
380 ] as const) {
381 const result = yield* call(laptop, name, {
382 ...args,
383 environmentId: laptop.environmentId,
384 threadId: users,
385 });
386 assert.equal(failureCode(result), "capability_denied", name);
387 }
388​
389 // Nor scheduled tasks, which belong to no thread.
390 assert.equal(
391 failureCode(yield* call(laptop, "delete_scheduled_task", { scheduledTaskId: "any" })),
392 "capability_denied",
393 );
394​
395 // Reads are not fenced.
396 const read = yield* call(laptop, "t3_thread_read", { threadId: users });
397 assert.equal(read.isError, false);
398 }),
399 );
400​
401 it.effect("is not fenced when the link has full access, but still says where it came from", () =>
402 Effect.gen(function* () {
403 const projections = yield* ProjectionStore.ProjectionStoreV2;
404 const users = yield* usersOwnThread;
405 const launched = yield* launch(trusted, "Started from the desk");
406 assert.deepEqual((yield* projections.getThreadShell(launched))?.linkOrigin, {
407 sessionId: "trusted",
408 label: "T3 Code · trusted",
409 access: "full-access",
410 });
411​
412 const renamed = yield* call(trusted, "t3_thread_update", {
413 threadId: users,
414 action: "rename",
415 title: "Renamed from the desk",
416 });
417 assert.equal(renamed.isError, false, JSON.stringify(renamed.content));
418 for (const [name, args] of [
419 ["t3_project_update", { projectId, title: "The desk's now" }],
420 ["t3_project_create", { title: "t3code", workspaceRoot: "/p/t3code" }],
421 ] as const) {
422 const changed = yield* call(trusted, name, args);
423 assert.equal(changed.isError, false, `${name}: ${JSON.stringify(changed.content)}`);
424 }
425 }),
426 );
427});