Skip to content
Source
Sign inSign up

OrchestratorMcpService.activity.test.ts

1,042 lines38.5 KB

1import {
2 EnvironmentId,
3 NodeId,
4 type OrchestrationV2ThreadProjection,
5 type OrchestrationV2ThreadShell,
6 ProjectId,
7 ProviderDriverKind,
8 ProviderInstanceId,
9 RunId,
10 ScheduledTaskId,
11 SECRET_REQUEST_UNSAVED_NOTE,
12 ThreadId,
13 TurnItemId,
14} from "@t3tools/contracts";
15import * as DateTime from "effect/DateTime";
16import * as Effect from "effect/Effect";
17import * as Fiber from "effect/Fiber";
18import * as Layer from "effect/Layer";
19import * as Metric from "effect/Metric";
20import * as TestClock from "effect/testing/TestClock";
21import * as NodeCrypto from "@effect/platform-node/NodeCrypto";
22import { expect, it } from "vite-plus/test";
23โ€‹
24import * as ProviderAdapterRegistry from "../orchestration-v2/ProviderAdapterRegistry.ts";
25import * as ProviderRegistry from "../provider/ProviderRegistry.ts";
26import * as ProjectService from "../project/ProjectService.ts";
27import * as ScheduledTaskService from "../scheduledTasks/ScheduledTaskService.ts";
28import * as ServerSettings from "../serverSettings.ts";
29import * as SecretRequests from "../secrets/SecretRequests.ts";
30import * as ThreadLaunchService from "../orchestration-v2/ThreadLaunchService.ts";
31import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
32import * as RemoteDelegation from "../peer/RemoteDelegation.ts";
33import * as Orchestrator from "../orchestration-v2/Orchestrator.ts";
34import * as ThreadManagementService from "../orchestration-v2/ThreadManagementService.ts";
35import type * as McpInvocationContext from "./McpInvocationContext.ts";
36import * as OrchestratorMcpService from "./OrchestratorMcpService.ts";
37import { liveThreadShell } from "./McpToolAccess.testkit.ts";
38โ€‹
39const layerOrchestratorService = OrchestratorMcpService.layer.pipe(
40 Layer.provide(Layer.mock(ThreadLaunchService.ThreadLaunchService)({})),
41 Layer.provide(Layer.mock(GitVcsDriver.GitVcsDriver)({})),
42);
43โ€‹
44const environmentId = EnvironmentId.make("environment-mcp-orchestrator-detail");
45const projectId = ProjectId.make("project-mcp-orchestrator-detail");
46const parentThreadId = ThreadId.make("thread-mcp-orchestrator-parent");
47const childThreadId = ThreadId.make("thread-mcp-orchestrator-child");
48const activeRunId = RunId.make("run-mcp-active");
49const cancelledRunId = RunId.make("run-mcp-cancelled");
50const childRunId = RunId.make("run-mcp-child");
51const taskId = NodeId.make("node-mcp-task-1");
52const now = DateTime.makeUnsafe("2026-08-04T12:00:00.000Z");
53const codexDriver = ProviderDriverKind.make("codex");
54โ€‹
55const customCodexInstanceId = ProviderInstanceId.make("codex-custom-workspace");
56const parentInstanceId = ProviderInstanceId.make("codex");
57โ€‹
58const makeScope = (): McpInvocationContext.McpInvocationScope => ({
59 environmentId,
60 requestNamespace: "provider-session-mcp-orchestrator-detail",
61 thread: {
62 threadId: parentThreadId,
63 providerSessionId: "provider-session-mcp-orchestrator-detail",
64 providerInstanceId: parentInstanceId,
65 },
66 client: undefined,
67 capabilities: new Set(["orchestration"]),
68 issuedAt: 1,
69});
70โ€‹
71function baseThread(input: {
72 readonly threadId: ThreadId;
73 readonly title: string;
74 readonly instanceId: ProviderInstanceId;
75 readonly model: string;
76}) {
77 return {
78 id: input.threadId,
79 projectId,
80 title: input.title,
81 createdBy: "user" as const,
82 creationSource: "mcp" as const,
83 modelSelection: {
84 instanceId: input.instanceId,
85 model: input.model,
86 },
87 runtimeMode: "full-access" as const,
88 interactionMode: "default" as const,
89 branch: null,
90 worktreePath: null,
91 lineage: {
92 parentThreadId: null,
93 relationshipToParent: null,
94 rootThreadId: input.threadId,
95 },
96 archivedAt: null,
97 deletedAt: null,
98 providerInstanceId: input.instanceId,
99 createdAt: now,
100 updatedAt: now,
101 };
103โ€‹
104function makeRun(input: {
105 readonly id: RunId;
106 readonly ordinal: number;
107 readonly status: "running" | "waiting" | "cancelled" | "queued" | "completed";
108 readonly instanceId?: ProviderInstanceId;
109}) {
110 return {
111 id: input.id,
112 ordinal: input.ordinal,
113 status: input.status,
114 modelSelection: {
115 instanceId: input.instanceId ?? parentInstanceId,
116 model: "gpt-5.4",
117 },
118 providerInstanceId: input.instanceId ?? parentInstanceId,
119 requestedAt: now,
120 startedAt: input.status === "cancelled" || input.status === "queued" ? null : now,
121 completedAt: input.status === "cancelled" || input.status === "completed" ? now : null,
122 };
124โ€‹
125it("readThread preserves caller access errors and prefers activity-run status over a newer cancelled queued run", async () => {
126 let callerUnavailable = false;
127 const pullRequestLink = (number: number, state: "open" | "merged", watched: boolean) => ({
128 host: "github.com",
129 repository: "acme/web",
130 number,
131 url: `https://github.com/acme/web/pull/${number}`,
132 source: "agent",
133 linkedAt: "2026-08-04T11:00:00.000Z",
134 snapshot: { state },
135 stack: null,
136 ...(watched
137 ? {
138 watch: {
139 startedAt: "2026-08-04T11:30:00.000Z",
140 headSha: null,
141 failedChecks: [],
142 passed: false,
143 passedChecks: [],
144 remarksThrough: "2026-08-04T11:30:00.000Z",
145 remarkIds: [],
146 conflicting: false,
147 wakes: 0,
148 },
149 }
150 : {}),
151 });
152 const projection = {
153 thread: {
154 ...baseThread({
155 threadId: parentThreadId,
156 title: "Parent",
157 instanceId: parentInstanceId,
158 model: "gpt-5.4",
159 }),
160 pullRequests: [pullRequestLink(1, "merged", false), pullRequestLink(2, "open", true)],
161 },
162 runs: [
163 makeRun({ id: activeRunId, ordinal: 1, status: "running" }),
164 makeRun({ id: cancelledRunId, ordinal: 2, status: "cancelled" }),
165 ],
166 visibleTurnItems: [],
167 providerTurns: [],
168 runtimeRequests: [],
169 messages: [],
170 contextTransfers: [],
171 subagents: [],
172 updatedAt: now,
173 } as unknown as OrchestrationV2ThreadProjection;
174โ€‹
175 const layer = layerOrchestratorService.pipe(
176 Layer.provide(
177 Layer.mergeAll(
178 Layer.mock(ThreadManagementService.ThreadManagementService)({
179 getTimelinePage: () =>
180 Effect.succeed({ items: [], totalItems: 0, hasMore: false, hasEarlier: false }),
181 getThreadRecords: (threadId) =>
182 callerUnavailable
183 ? Effect.fail(
184 new Orchestrator.OrchestratorProjectionError({
185 threadId,
186 cause: "Caller unavailable",
187 }),
188 )
189 : threadId === parentThreadId
190 ? Effect.succeed(projection)
191 : Effect.die(`unexpected thread ${threadId}`),
192 getThreadShell: (threadId) =>
193 callerUnavailable
194 ? Effect.die("A failed caller read must not reach the target")
195 : Effect.succeed(
196 threadId === parentThreadId
197 ? ({
198 ...projection.thread,
199 blockedOnThreadWait: true,
200 pendingBackgroundTasks: [
201 {
202 taskId: "task-subagent",
203 kind: "subagent",
204 parentTaskId: "task-parent",
205 providerInstanceId: parentInstanceId,
206 childThreadId,
207 },
208 ],
209 } as unknown as OrchestrationV2ThreadShell)
210 : null,
211 ),
212 getProjectThreadRecords: (input) =>
213 input.threadId === parentThreadId
214 ? Effect.succeed(projection)
215 : Effect.die(`unexpected thread ${input.threadId}`),
216 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
217 ServerSettings.layerTest(),
218 Layer.mock(ProviderRegistry.ProviderRegistry)({
219 getProviders: Effect.succeed([]),
220 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
221 Layer.mock(ProjectService.ProjectService)({}),
222 Layer.mock(SecretRequests.SecretRequests)({}),
223 Layer.mock(RemoteDelegation.RemoteDelegation)({}),
224 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
225 list: () => Effect.succeed({ tasks: [] }),
226 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
227 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
228 list: () => Effect.succeed([]),
229 } satisfies Partial<ProviderAdapterRegistry.ProviderAdapterRegistryV2["Service"]>),
230 NodeCrypto.layer,
231 ),
232 ),
233 );
234โ€‹
235 await Effect.gen(function* () {
236 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
237 const result = yield* service.readThread(makeScope(), { threadId: parentThreadId });
238 expect(result.thread.status).toBe("running");
239 expect(result.thread.latestRunId).toBe(cancelledRunId);
240 expect(result.thread.activeRunId).toBe(activeRunId);
241 expect(result.thread.blockedOnThreadWait).toBe(true);
242 expect(result.thread.pendingBackgroundTasks).toEqual([
243 { taskId: "task-subagent", kind: "subagent", childThreadId },
244 ]);
245 // Every page read must not repeat each linked PR, and an open PR shows whether it is watched.
246 const listed = result.thread.pullRequests ?? [];
247 expect(listed.map((link) => link.number)).not.toContain(1);
248 expect(listed.find((link) => link.number === 2)).toMatchObject({
249 watching: true,
250 watchStartedAt: "2026-08-04T11:30:00.000Z",
251 });
252 const laterPage = yield* service.readThread(makeScope(), {
253 threadId: parentThreadId,
254 afterPosition: 0,
255 });
256 expect(laterPage.thread.pullRequests).toBeUndefined();
257 expect(laterPage.thread.pullRequestCount).toBe(2);
258โ€‹
259 callerUnavailable = true;
260 const denied = yield* service
261 .readThread({ ...makeScope(), capabilities: new Set() }, { threadId: parentThreadId })
262 .pipe(Effect.flip);
263 expect(denied.code).toBe("capability_denied");
264 const missingCaller = yield* service
265 .readThread(makeScope(), { threadId: parentThreadId })
266 .pipe(Effect.flip);
267 expect(missingCaller.code).toBe("orchestration_error");
268 expect(missingCaller.message).toContain(`Unable to read thread ${parentThreadId}:`);
269 }).pipe(Effect.provide(layer), Effect.runPromise);
270});
271โ€‹
272it("readThread pages cut-off text without splitting an emoji across pages", async () => {
273 const text = "a๐Ÿ˜€bc";
274 const itemId = TurnItemId.make("turn-item-mcp-reasoning");
275 const projection = {
276 thread: baseThread({
277 threadId: parentThreadId,
278 title: "Parent",
279 instanceId: parentInstanceId,
280 model: "gpt-5.4",
281 }),
282 runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })],
283 visibleTurnItems: [],
284 providerTurns: [],
285 runtimeRequests: [],
286 messages: [],
287 contextTransfers: [],
288 subagents: [],
289 updatedAt: now,
290 } as unknown as OrchestrationV2ThreadProjection;
291 const row = {
292 position: 0,
293 visibility: "visible",
294 sourceThreadId: parentThreadId,
295 sourceItemId: itemId,
296 item: {
297 type: "reasoning",
298 runId: activeRunId,
299 status: "completed",
300 title: null,
301 text,
302 updatedAt: now,
303 },
304 };
305 const rows = [
306 row,
307 ...Array.from({ length: 11 }, (_, index) => ({
308 ...row,
309 position: index + 1,
310 sourceItemId: TurnItemId.make(`turn-item-mcp-${index}`),
311 item: { ...row.item, text: "x".repeat(600) },
312 })),
313 ];
314โ€‹
315 const layer = layerOrchestratorService.pipe(
316 Layer.provide(
317 Layer.mergeAll(
318 Layer.mock(ThreadManagementService.ThreadManagementService)({
319 getTimelinePage: (_threadId, options) => {
320 const remaining = rows.filter(
321 (candidate) =>
322 candidate.position > (options.afterPosition ?? -1) &&
323 (options.itemId === undefined || candidate.sourceItemId === options.itemId),
324 );
325 return Effect.succeed({
326 items: remaining.slice(
327 0,
328 options.limit,
329 ) as unknown as OrchestrationV2ThreadProjection["visibleTurnItems"],
330 totalItems: rows.length,
331 hasMore: remaining.length > options.limit,
332 hasEarlier: false,
333 });
334 },
335 getThreadRecords: () => Effect.succeed(projection),
336 getProjectThreadRecords: () => Effect.succeed(projection),
337 getThreadShell: () =>
338 Effect.succeed(projection.thread as unknown as OrchestrationV2ThreadShell),
339 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
340 ServerSettings.layerTest(),
341 Layer.mock(ProviderRegistry.ProviderRegistry)({
342 getProviders: Effect.succeed([]),
343 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
344 Layer.mock(ProjectService.ProjectService)({}),
345 Layer.mock(SecretRequests.SecretRequests)({}),
346 Layer.mock(RemoteDelegation.RemoteDelegation)({}),
347 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
348 list: () => Effect.succeed({ tasks: [] }),
349 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
350 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
351 list: () => Effect.succeed([]),
352 } satisfies Partial<ProviderAdapterRegistry.ProviderAdapterRegistryV2["Service"]>),
353 NodeCrypto.layer,
354 ),
355 ),
356 );
357โ€‹
358 await Effect.gen(function* () {
359 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
360 for (const [maxCharsPerItem, expected] of [
361 [2, ["a", "๐Ÿ˜€", "bc"]],
362 [1, ["a", "๐Ÿ˜€", "b", "c"]],
363 ] as const) {
364 let textOffset = 0;
365 for (const [index, text] of expected.entries()) {
366 const result = yield* service.readThread(makeScope(), {
367 threadId: parentThreadId,
368 view: "activity",
369 itemId,
370 textOffset,
371 maxCharsPerItem,
372 });
373 textOffset += text.length;
374 expect(result.items[0]).toMatchObject({
375 text,
376 textTruncated: index < expected.length - 1,
377 nextTextOffset: index < expected.length - 1 ? textOffset : null,
378 });
379 }
380 }
381 const first = yield* service.readThread(makeScope(), {
382 threadId: parentThreadId,
383 view: "activity",
384 });
385 expect(first.items[1]).toMatchObject({
386 text: "x".repeat(500),
387 textTruncated: true,
388 nextTextOffset: 500,
389 });
390 expect(first.items).toHaveLength(10);
391 expect(first).toMatchObject({ nextPosition: 9, hasMore: true });
392 }).pipe(Effect.provide(layer), Effect.runPromise);
393});
394โ€‹
395it("readThread returns the newest items that fit the response budget", async () => {
396 const projection = {
397 thread: baseThread({
398 threadId: parentThreadId,
399 title: "Parent",
400 instanceId: parentInstanceId,
401 model: "gpt-5.4",
402 }),
403 runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })],
404 visibleTurnItems: [],
405 providerTurns: [],
406 runtimeRequests: [],
407 messages: [],
408 contextTransfers: [],
409 subagents: [],
410 updatedAt: now,
411 } as unknown as OrchestrationV2ThreadProjection;
412 // A long thread's newest 100 activity items, the read agents make most.
413 const rows = Array.from({ length: 100 }, (_, position) => ({
414 position,
415 visibility: "local",
416 sourceThreadId: parentThreadId,
417 sourceItemId: TurnItemId.make(`turn-item-mcp-long-${position}`),
418 item: {
419 type: "reasoning",
420 runId: activeRunId,
421 status: "completed",
422 title: null,
423 text: "x".repeat(1_500),
424 updatedAt: now,
425 },
426 }));
427 const pageOptions: Array<unknown> = [];
428โ€‹
429 const layer = layerOrchestratorService.pipe(
430 Layer.provide(
431 Layer.mergeAll(
432 Layer.mock(ThreadManagementService.ThreadManagementService)({
433 getTimelinePage: (_threadId, options) => {
434 pageOptions.push(options);
435 return Effect.succeed({
436 items: rows as unknown as OrchestrationV2ThreadProjection["visibleTurnItems"],
437 totalItems: 3_000,
438 hasMore: false,
439 hasEarlier: true,
440 });
441 },
442 getThreadRecords: () => Effect.succeed(projection),
443 getProjectThreadRecords: () => Effect.succeed(projection),
444 getThreadShell: () =>
445 Effect.succeed(projection.thread as unknown as OrchestrationV2ThreadShell),
446 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
447 ServerSettings.layerTest(),
448 Layer.mock(ProviderRegistry.ProviderRegistry)({
449 getProviders: Effect.succeed([]),
450 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
451 Layer.mock(ProjectService.ProjectService)({}),
452 Layer.mock(SecretRequests.SecretRequests)({}),
453 Layer.mock(RemoteDelegation.RemoteDelegation)({}),
454 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
455 list: () => Effect.succeed({ tasks: [] }),
456 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
457 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
458 list: () => Effect.succeed([]),
459 } satisfies Partial<ProviderAdapterRegistry.ProviderAdapterRegistryV2["Service"]>),
460 NodeCrypto.layer,
461 ),
462 ),
463 );
464โ€‹
465 await Effect.gen(function* () {
466 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
467 const read = {
468 threadId: parentThreadId,
469 view: "activity" as const,
470 limit: 100,
471 maxCharsPerItem: 1_500,
472 };
473 const result = yield* service.readThread(makeScope(), read);
474 // Linked environments and the CLI read as clients without a cursor and follow only hasMore.
475 const first = yield* service.readThread({ ...makeScope(), thread: undefined }, read);
476 const fromStart = yield* service.readThread(makeScope(), { ...read, afterPosition: -1 });
477โ€‹
478 expect(pageOptions).toEqual([
479 { beforePosition: Number.POSITIVE_INFINITY, limit: 100, view: "activity" },
480 { limit: 100, view: "activity" },
481 { afterPosition: -1, limit: 100, view: "activity" },
482 ]);
483 // Claude Code saves results over ~50k chars of this JSON to a file instead of showing them.
484 expect(JSON.stringify(result).length).toBeLessThan(31_000);
485 expect(result.items.length).toBeLessThan(100);
486 expect(result.items.at(-1)?.position).toBe(99);
487 expect(result.earlierPosition).toBe(result.items[0]?.position);
488 expect(result.hasMore).toBe(false);
489 expect(first.items[0]?.position).toBe(0);
490 expect(first.hasMore).toBe(true);
491 expect(first.nextPosition).toBe(first.items.at(-1)?.position);
492 expect(fromStart.items[0]?.position).toBe(0);
493 }).pipe(Effect.provide(layer), Effect.runPromise);
494});
495โ€‹
496it("taskStatus returns task.providerInstanceId rather than the driver kind", async () => {
497 const parentProjection = {
498 thread: baseThread({
499 threadId: parentThreadId,
500 title: "Parent",
501 instanceId: parentInstanceId,
502 model: "gpt-5.4",
503 }),
504 runs: [makeRun({ id: activeRunId, ordinal: 1, status: "running" })],
505 visibleTurnItems: [],
506 providerTurns: [],
507 runtimeRequests: [],
508 messages: [],
509 contextTransfers: [],
510 subagents: [
511 {
512 id: taskId,
513 threadId: parentThreadId,
514 runId: activeRunId,
515 parentNodeId: NodeId.make("node-parent"),
516 origin: "app_owned",
517 createdBy: "agent",
518 driver: codexDriver,
519 providerInstanceId: customCodexInstanceId,
520 providerThreadId: null,
521 childThreadId,
522 nativeTaskRef: null,
523 prompt: "Inspect the custom instance.",
524 title: null,
525 model: "gpt-5.4",
526 status: "running",
527 result: null,
528 startedAt: now,
529 completedAt: null,
530 updatedAt: now,
531 },
532 ],
533 updatedAt: now,
534 } as unknown as OrchestrationV2ThreadProjection;
535โ€‹
536 const childProjection = {
537 thread: {
538 ...baseThread({
539 threadId: childThreadId,
540 title: "Child",
541 instanceId: customCodexInstanceId,
542 model: "gpt-5.4",
543 }),
544 lineage: {
545 parentThreadId,
546 relationshipToParent: "subagent",
547 rootThreadId: parentThreadId,
548 },
549 createdBy: "agent",
550 },
551 runs: [
552 makeRun({
553 id: childRunId,
554 ordinal: 1,
555 status: "running",
556 instanceId: customCodexInstanceId,
557 }),
558 ],
559 visibleTurnItems: [],
560 providerTurns: [],
561 runtimeRequests: [],
562 messages: [],
563 contextTransfers: [
564 {
565 type: "subagent_spawn",
566 sourceThreadId: parentThreadId,
567 targetThreadId: childThreadId,
568 targetRunId: childRunId,
569 },
570 ],
571 subagents: [],
572 providerThreads: [],
573 updatedAt: now,
574 } as unknown as OrchestrationV2ThreadProjection;
575โ€‹
576 const layer = layerOrchestratorService.pipe(
577 Layer.provide(
578 Layer.mergeAll(
579 Layer.mock(ThreadManagementService.ThreadManagementService)({
580 getTimelinePage: () =>
581 Effect.succeed({ items: [], totalItems: 0, hasMore: false, hasEarlier: false }),
582 getThreadRecords: (threadId) => {
583 if (threadId === parentThreadId) return Effect.succeed(parentProjection);
584 if (threadId === childThreadId) return Effect.succeed(childProjection);
585 return Effect.die(`unexpected thread ${threadId}`);
586 },
587 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
588 ServerSettings.layerTest(),
589 Layer.mock(ProviderRegistry.ProviderRegistry)({
590 getProviders: Effect.succeed([]),
591 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
592 Layer.mock(ProjectService.ProjectService)({}),
593 Layer.mock(SecretRequests.SecretRequests)({}),
594 Layer.mock(RemoteDelegation.RemoteDelegation)({}),
595 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
596 list: () => Effect.succeed({ tasks: [] }),
597 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
598 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
599 list: () => Effect.succeed([]),
600 } satisfies Partial<ProviderAdapterRegistry.ProviderAdapterRegistryV2["Service"]>),
601 NodeCrypto.layer,
602 ),
603 ),
604 );
605โ€‹
606 await Effect.gen(function* () {
607 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
608 const result = yield* service.taskStatus(makeScope(), taskId);
609 expect(result.providerInstanceId).toBe(customCodexInstanceId);
610 expect(result.providerInstanceId).not.toBe(ProviderInstanceId.make(String(codexDriver)));
611 expect(result.status).toBe("running");
612 expect(result.taskId).toBe(taskId);
613 expect(result.childThreadId).toBe(childThreadId);
614 }).pipe(Effect.provide(layer), Effect.runPromise);
615});
616โ€‹
617it("readThread and sendToThread reach threads in other projects", async () => {
618 let parentShell: OrchestrationV2ThreadShell | null = null;
619 let parentRuns: ReadonlyArray<unknown> = [
620 makeRun({ id: RunId.make("run-parent-live"), ordinal: 1, status: "running" }),
621 ];
622 const foreignProjectId = ProjectId.make("project-mcp-orchestrator-foreign");
623 const foreignThreadId = ThreadId.make("thread-mcp-orchestrator-foreign");
624 const parentProjection = {
625 thread: baseThread({
626 threadId: parentThreadId,
627 title: "Parent",
628 instanceId: parentInstanceId,
629 model: "gpt-5.4",
630 }),
631 get runs() {
632 return parentRuns;
633 },
634 visibleTurnItems: [],
635 providerTurns: [],
636 runtimeRequests: [],
637 messages: [],
638 contextTransfers: [],
639 subagents: [],
640 updatedAt: now,
641 } as unknown as OrchestrationV2ThreadProjection;
642 const foreignProjection = (threadId: ThreadId) =>
643 ({
644 thread: {
645 ...baseThread({
646 threadId,
647 title: "Foreign",
648 instanceId: parentInstanceId,
649 model: "gpt-5.4",
650 }),
651 projectId: foreignProjectId,
652 },
653 runs: [],
654 visibleTurnItems: [
655 {
656 position: 0,
657 visibility: "inherited",
658 sourceThreadId: threadId,
659 sourceItemId: "item-1",
660 item: {
661 id: "item-1",
662 type: "assistant_message",
663 messageId: "assistant-1",
664 status: "completed",
665 title: null,
666 text: "Foreign thread said hello",
667 createdAt: now,
668 updatedAt: now,
669 },
670 },
671 ],
672 providerTurns: [],
673 runtimeRequests: [],
674 messages: [],
675 contextTransfers: [],
676 subagents: [],
677 updatedAt: now,
678 }) as unknown as OrchestrationV2ThreadProjection;
679โ€‹
680 const layer = layerOrchestratorService.pipe(
681 Layer.provide(
682 Layer.mergeAll(
683 Layer.mock(ThreadManagementService.ThreadManagementService)({
684 getThreadRecords: (threadId) => {
685 if (threadId === parentThreadId) return Effect.succeed(parentProjection);
686 if (threadId === foreignThreadId) return Effect.succeed(foreignProjection(threadId));
687 return Effect.die(`unexpected thread ${threadId}`);
688 },
689 getThreadShell: (threadId) =>
690 Effect.succeed(
691 threadId === foreignThreadId
692 ? (foreignProjection(threadId).thread as unknown as OrchestrationV2ThreadShell)
693 : threadId === parentThreadId
694 ? parentShell
695 : null,
696 ),
697 getTimelinePage: (threadId) =>
698 Effect.succeed({
699 items: foreignProjection(threadId).visibleTurnItems,
700 totalItems: 1,
701 hasMore: false,
702 hasEarlier: false,
703 }),
704 getProjectThreadRecords: (input) =>
705 input.threadId === foreignThreadId && input.projectId === foreignProjectId
706 ? Effect.succeed(foreignProjection(input.threadId))
707 : Effect.fail(
708 new ThreadManagementService.ThreadManagementThreadNotFoundError({
709 projectId: input.projectId,
710 threadId: input.threadId,
711 }),
712 ),
713 sendToThread: () =>
714 Effect.succeed({
715 run: { id: "run-foreign", status: "queued" },
716 delivery: "started",
717 } as unknown as ThreadManagementService.ThreadManagementSendResult),
718 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
719 ServerSettings.layerTest(),
720 Layer.mock(ProviderRegistry.ProviderRegistry)({
721 getProviders: Effect.succeed([]),
722 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
723 Layer.mock(ProjectService.ProjectService)({}),
724 Layer.mock(SecretRequests.SecretRequests)({}),
725 Layer.mock(RemoteDelegation.RemoteDelegation)({}),
726 Layer.mock(ScheduledTaskService.ScheduledTaskService)({
727 list: () =>
728 Effect.succeed({
729 tasks: [
730 {
731 id: "task-foreign",
732 projectId: foreignProjectId,
733 runtimeMode: "approval-required",
734 interactionMode: "default",
735 } as never,
736 ],
737 }),
738 } satisfies Partial<ScheduledTaskService.ScheduledTaskService["Service"]>),
739 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({
740 list: () => Effect.succeed([]),
741 } satisfies Partial<ProviderAdapterRegistry.ProviderAdapterRegistryV2["Service"]>),
742 NodeCrypto.layer,
743 ),
744 ),
745 );
746โ€‹
747 await Effect.gen(function* () {
748 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
749 const foreign = yield* service.readThread(makeScope(), { threadId: foreignThreadId });
750 expect(foreign.thread.threadId).toBe(foreignThreadId);
751 expect(foreign.thread.projectId).toBe(foreignProjectId);
752 expect(foreign.items.map((item) => item.text)).toEqual(["Foreign thread said hello"]);
753โ€‹
754 const sent = yield* service.sendToThread(makeScope(), {
755 threadId: foreignThreadId,
756 message: "hi",
757 });
758 expect(sent.threadId).toBe(foreignThreadId);
759โ€‹
760 parentRuns = [];
761 yield* service.readThread(makeScope(), { threadId: foreignThreadId });
762 const stale = yield* service
763 .sendToThread(makeScope(), { threadId: foreignThreadId, message: "hi again" })
764 .pipe(Effect.flip);
765 expect(stale.code).toBe("parent_not_active");
766 const staleInterrupt = yield* service
767 .interruptThread(makeScope(), { threadId: foreignThreadId })
768 .pipe(Effect.flip);
769 expect(staleInterrupt.code).toBe("parent_not_active");
770โ€‹
771 const staleSchedule = yield* service
772 .scheduleTask(makeScope(), {
773 projectId: foreignProjectId,
774 prompt: "check in later",
775 schedule: { type: "interval", everyMs: 3_600_000 },
776 })
777 .pipe(Effect.flip);
778 expect(staleSchedule.code).toBe("parent_not_active");
779 const staleUpdate = yield* service
780 .updateScheduledTask(makeScope(), {
781 scheduledTaskId: ScheduledTaskId.make("task-foreign"),
782 enabled: false,
783 })
784 .pipe(Effect.flip);
785 expect(staleUpdate.code).toBe("parent_not_active");
786 const staleDelete = yield* service
787 .deleteScheduledTask(makeScope(), { scheduledTaskId: ScheduledTaskId.make("task-foreign") })
788 .pipe(Effect.flip);
789 expect(staleDelete.code).toBe("parent_not_active");
790โ€‹
791 const backgroundTasks = [
792 {
793 taskId: "background-subagent",
794 kind: "subagent" as const,
795 providerInstanceId: parentInstanceId,
796 },
797 ];
798 parentShell = {
799 ...liveThreadShell(parentThreadId, { activeRunId: null }),
800 pendingBackgroundTasks: backgroundTasks,
801 };
802 const backgroundSent = yield* service.sendToThread(makeScope(), {
803 threadId: foreignThreadId,
804 message: "background",
805 });
806 expect(backgroundSent.threadId).toBe(foreignThreadId);
807 const delegate = yield* service
808 .delegateTask(makeScope(), { task: "requires a run" })
809 .pipe(Effect.flip);
810 expect(delegate.code).toBe("parent_not_active");
811 const create = yield* service
812 .createThreads(makeScope(), { threads: [{ prompt: "requires a run" }] })
813 .pipe(Effect.flip);
814 expect(create.code).toBe("parent_not_active");
815 const secret = yield* service
816 .requestSecret(makeScope(), { label: "Secret", reason: "requires a run" })
817 .pipe(Effect.flip);
818 expect(secret.code).toBe("parent_not_active");
819โ€‹
820 parentShell = { ...parentShell, providerInstanceId: customCodexInstanceId };
821 const switchedScope = {
822 ...makeScope(),
823 thread: { ...makeScope().thread!, providerInstanceId: customCodexInstanceId },
824 };
825 const switched = yield* service
826 .sendToThread(switchedScope, { threadId: foreignThreadId, message: "wrong owner" })
827 .pipe(Effect.flip);
828 expect(switched.code).toBe("parent_not_active");
829 const mismatched = yield* service
830 .sendToThread(makeScope(), { threadId: foreignThreadId, message: "stale provider" })
831 .pipe(Effect.flip);
832 expect(mismatched.code).toBe("parent_not_active");
833โ€‹
834 parentShell = liveThreadShell(parentThreadId);
835 const restarted = yield* service.sendToThread(makeScope(), {
836 threadId: foreignThreadId,
837 message: "new run",
838 });
839 expect(restarted.threadId).toBe(foreignThreadId);
840โ€‹
841 parentShell = {
842 ...parentShell,
843 activeRunId: null,
844 pendingBackgroundTasks: backgroundTasks,
845 archivedAt: now,
846 };
847 const archived = yield* service
848 .sendToThread(makeScope(), { threadId: foreignThreadId, message: "archived" })
849 .pipe(Effect.flip);
850 expect(archived.code).toBe("parent_not_active");
851โ€‹
852 parentShell = { ...parentShell, archivedAt: null, pendingBackgroundTasks: [] };
853 const stopped = yield* service
854 .sendToThread(makeScope(), { threadId: foreignThreadId, message: "after stop" })
855 .pipe(Effect.flip);
856 expect(stopped.code).toBe("parent_not_active");
857 }).pipe(Effect.provide(layer), Effect.runPromise);
858});
859โ€‹
860it("requestSecret saves a pasted value to a file without opening a card", async () => {
861 const projection = {
862 thread: baseThread({
863 threadId: parentThreadId,
864 title: "Parent",
865 instanceId: parentInstanceId,
866 model: "gpt-5.4",
867 }),
868 runs: [{ ...makeRun({ id: activeRunId, ordinal: 1, status: "running" }), rootNodeId: taskId }],
869 visibleTurnItems: [],
870 providerTurns: [],
871 runtimeRequests: [],
872 messages: [],
873 contextTransfers: [],
874 subagents: [],
875 updatedAt: now,
876 } as unknown as OrchestrationV2ThreadProjection;
877 const dispatched: Array<unknown> = [];
878 const saved: Array<{
879 readonly requestNamespace: string;
880 readonly requestKey: string;
881 readonly value: string;
882 }> = [];
883 const file = "/home/user/.t3/userdata/secrets/files/0123456789abcdef0123456789abcdef";
884โ€‹
885 const layer = layerOrchestratorService.pipe(
886 Layer.provide(
887 Layer.mergeAll(
888 Layer.mock(ThreadManagementService.ThreadManagementService)({
889 getThreadRecords: (threadId) =>
890 threadId === parentThreadId
891 ? Effect.succeed(projection)
892 : Effect.die(`unexpected thread ${threadId}`),
893 getThreadShell: (threadId) =>
894 Effect.succeed(
895 threadId === parentThreadId
896 ? (projection.thread as unknown as OrchestrationV2ThreadShell)
897 : null,
898 ),
899 dispatch: (command) => Effect.sync(() => void dispatched.push(command) as never),
900 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
901 ServerSettings.layerTest(),
902 Layer.mock(ProviderRegistry.ProviderRegistry)({
903 getProviders: Effect.succeed([]),
904 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
905 Layer.mock(ProjectService.ProjectService)({}),
906 Layer.mock(SecretRequests.SecretRequests)({
907 savePasted: (input) =>
908 Effect.sync(() => {
909 saved.push({
910 requestNamespace: input.requestNamespace,
911 requestKey: input.requestKey,
912 value: input.value,
913 });
914 return file;
915 }),
916 }),
917 Layer.mock(RemoteDelegation.RemoteDelegation)({}),
918 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
919 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
920 NodeCrypto.layer,
921 ),
922 ),
923 );
924 const counted = Metric.snapshot.pipe(
925 Effect.map((snapshots) => {
926 const found = snapshots.find(
927 (snapshot) =>
928 snapshot.id === "t3_secret_requests_total" &&
929 snapshot.attributes?.status === "saved_pasted",
930 );
931 return found?.type === "Counter" ? Number(found.state.count) : 0;
932 }),
933 );
934โ€‹
935 await Effect.gen(function* () {
936 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
937 const before = yield* counted;
938 const result = yield* service.requestSecret(makeScope(), {
939 label: "Pixel PIN",
940 reason: "Unlocks the test phone.",
941 value: "482916",
942 clientRequestId: "pin-1",
943 });
944 expect(result).toEqual({ status: "saved", file });
945 expect(JSON.stringify(result)).not.toContain("482916");
946 expect(saved).toEqual([
947 {
948 requestNamespace: "provider-session-mcp-orchestrator-detail",
949 requestKey: "pin-1",
950 value: "482916",
951 },
952 ]);
953 expect(dispatched).toEqual([]);
954 expect((yield* counted) - before).toBe(1);
955โ€‹
956 const asRef = yield* service
957 .requestSecret(makeScope(), {
958 label: "Pixel PIN",
959 reason: "Unlocks the test phone.",
960 value: "482916",
961 delivery: "secretRef",
962 })
963 .pipe(Effect.flip);
964 expect(asRef.code).toBe("invalid_request");
965 expect(asRef.message).not.toContain("482916");
966 expect(saved).toHaveLength(1);
967 expect(dispatched).toEqual([]);
968 }).pipe(Effect.provide(layer), Effect.runPromise);
969});
970โ€‹
971it("requestSecret returns the unsaved note for a declined, cancelled or timed-out card", async () => {
972 const outcomes = ["declined", "cancelled", "pending"] as const;
973 const results = await Effect.forEach(outcomes, (outcome) => {
974 const dispatched: Array<{ readonly type: string; readonly turnItemId?: string }> = [];
975 const projection = () => {
976 const card = dispatched.find((command) => command.type === "secret_request.record");
977 return {
978 thread: baseThread({
979 threadId: parentThreadId,
980 title: "Parent",
981 instanceId: parentInstanceId,
982 model: "gpt-5.4",
983 }),
984 runs: [
985 { ...makeRun({ id: activeRunId, ordinal: 1, status: "running" }), rootNodeId: taskId },
986 ],
987 visibleTurnItems: [],
988 turnItems:
989 card === undefined
990 ? []
991 : [{ type: "secret_request", id: card.turnItemId, secretStatus: outcome }],
992 providerTurns: [],
993 runtimeRequests: [],
994 messages: [],
995 contextTransfers: [],
996 subagents: [],
997 updatedAt: now,
998 } as unknown as OrchestrationV2ThreadProjection;
999 };
1000 const layer = layerOrchestratorService.pipe(
1001 Layer.provide(
1002 Layer.mergeAll(
1003 Layer.mock(ThreadManagementService.ThreadManagementService)({
1004 getThreadRecords: () => Effect.sync(projection),
1005 getThreadShell: () =>
1006 Effect.sync(() => projection().thread as unknown as OrchestrationV2ThreadShell),
1007 dispatch: (command) => Effect.sync(() => void dispatched.push(command) as never),
1008 } satisfies Partial<ThreadManagementService.ThreadManagementService["Service"]>),
1009 ServerSettings.layerTest(),
1010 Layer.mock(ProviderRegistry.ProviderRegistry)({
1011 getProviders: Effect.succeed([]),
1012 } satisfies Partial<ProviderRegistry.ProviderRegistry["Service"]>),
1013 Layer.mock(ProjectService.ProjectService)({}),
1014 Layer.mock(SecretRequests.SecretRequests)({}),
1015 Layer.mock(RemoteDelegation.RemoteDelegation)({}),
1016 Layer.mock(ScheduledTaskService.ScheduledTaskService)({}),
1017 Layer.mock(ProviderAdapterRegistry.ProviderAdapterRegistryV2)({}),
1018 NodeCrypto.layer,
1019 ),
1020 ),
1021 );
1022 return Effect.gen(function* () {
1023 const service = yield* OrchestratorMcpService.OrchestratorMcpService;
1024 const request = yield* service
1025 .requestSecret(makeScope(), {
1026 label: "Staging password",
1027 reason: "Signs in the QA account.",
1028 delivery: "file",
1029 timeoutMs: 1_000,
1030 })
1031 .pipe(Effect.forkChild({ startImmediately: true }));
1032 yield* TestClock.adjust("2 seconds");
1033 return yield* Fiber.join(request);
1034 }).pipe(Effect.provide(Layer.merge(layer, TestClock.layer())));
1035 }).pipe(Effect.runPromise);
1036โ€‹
1037 expect(results).toEqual([
1038 { status: "declined", note: SECRET_REQUEST_UNSAVED_NOTE.declined },
1039 { status: "cancelled", note: SECRET_REQUEST_UNSAVED_NOTE.cancelled },
1040 { status: "timed_out", note: SECRET_REQUEST_UNSAVED_NOTE.timed_out },
1041 ]);
1042});