1import { remapComposerContextAttachments } from "@t3tools/shared/composerContextReferences";
2import {
3 type ThreadLinkedPullRequest,
4 CommandId,
5 CheckpointId,
6 CheckpointScopeId,
7 HubOrganizationCommand,
8 ORCHESTRATION_V2_WS_METHODS,
9 OrchestrationV2CheckpointUnavailableError,
10 WS_METHODS,
11 type ChatAttachment,
12 type MessageId,
13 type ModelSelection,
14 type NodeId,
15 type OrchestrationV2Command,
16 type OrchestrationV2CreationSource,
17 type PlanId,
18 type ProjectId,
19 type ProjectIconOverride,
20 type ProjectScript,
21 type ProviderApprovalDecision,
22 type ProviderInteractionMode,
23 type ProviderUserInputAnswers,
24 type RunId,
25 type RuntimeMode,
26 type RuntimeRequestId,
27 type ThreadId,
28 type ThreadEnvMode,
29 type UploadChatAttachment,
30} from "@t3tools/contracts";
31import { modelSelectionCommandType } from "@t3tools/shared/model";
32import { derivePendingBackgroundWork } from "@t3tools/shared/orchestrationV2PendingBackgroundWork";
33import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";
34import * as Crypto from "effect/Crypto";
35import * as Effect from "effect/Effect";
36import * as Option from "effect/Option";
37import * as Schema from "effect/Schema";
38
39import * as Hub from "../connection/hub.ts";
40import * as EnvironmentSupervisor from "../connection/supervisor.ts";
41import { getInitialServerConfig, request } from "../rpc/client.ts";
42
43const isHubOrganizationCommand = Schema.is(HubOrganizationCommand);
44
45interface CommandMetadata {
46 readonly commandId?: CommandId;
47 readonly createdAt?: string;
48 readonly creationSource?: OrchestrationV2CreationSource;
49}
50
51export interface CreateProjectInput extends CommandMetadata {
52 readonly projectId: ProjectId;
53 readonly title: string;
54 readonly workspaceRoot: string;
55 readonly createWorkspaceRootIfMissing?: boolean;
56 readonly defaultModelSelection?: ModelSelection | null;
57 readonly scripts?: ReadonlyArray<ProjectScript>;
58}
59
60export interface UpdateProjectInput extends CommandMetadata {
61 readonly projectId: ProjectId;
62 readonly title?: string;
63 readonly workspaceRoot?: string;
64 readonly defaultModelSelection?: ModelSelection | null;
65 readonly autoPull?: boolean;
66 readonly projectIcon?: ProjectIconOverride | null;
67 readonly faviconPath?: string | null;
68 readonly defaultThreadEnvMode?: ThreadEnvMode | null;
69 readonly scripts?: ReadonlyArray<ProjectScript>;
70}
71
72export interface DeleteProjectInput extends CommandMetadata {
73 readonly projectId: ProjectId;
74 readonly force?: boolean;
75}
76
77export interface CreateThreadInput extends CommandMetadata {
78 readonly threadId: ThreadId;
79 readonly projectId: ProjectId;
80 readonly title: string;
81 readonly modelSelection: ModelSelection;
82 readonly runtimeMode: RuntimeMode;
83 readonly interactionMode: ProviderInteractionMode;
84 readonly branch: string | null;
85 readonly worktreePath: string | null;
86}
87
88export interface ThreadCommandInput extends CommandMetadata {
89 readonly threadId: ThreadId;
90}
91
92export type DeleteThreadInput = ThreadCommandInput;
93export interface ArchiveThreadInput extends ThreadCommandInput {
94 readonly snapshotSequence?: number;
95}
96export type UnarchiveThreadInput = ThreadCommandInput;
97export type SettleThreadInput = ThreadCommandInput;
98
99export interface UnsettleThreadInput extends ThreadCommandInput {
100 readonly reason: "user";
101}
102
103export interface PinThreadInput extends ThreadCommandInput {
104 /** Initial slot in the user-arranged pinned order; omit to pin keyless. */
105 readonly orderKey?: string;
106}
107export type UnpinThreadInput = ThreadCommandInput;
108
109export interface ReorderPinnedThreadInput extends ThreadCommandInput {
110 /** Fractional-index key that sorts between the drop position's neighbors. */
111 readonly orderKey: string;
112}
113
114export interface ReorderActiveThreadInput extends ThreadCommandInput {
115 /** Fractional-index key that sorts between the drop position's neighbors. */
116 readonly orderKey: string;
117}
118
119export interface SnoozeThreadInput extends ThreadCommandInput {
120 readonly snoozedUntil: string;
121}
122
123export interface UnsnoozeThreadInput extends ThreadCommandInput {
124 readonly reason: "user";
125}
126
127export interface VisitThreadInput extends ThreadCommandInput {
128 /** Watermark of the thread state the viewer has seen (ISO timestamp). */
129 readonly visitedAt: string;
130}
131
132export type MarkThreadUnreadInput = ThreadCommandInput;
133
134export interface UpdateThreadMetadataInput extends ThreadCommandInput {
135 readonly limitRecovery?: import("@t3tools/contracts").OrchestrationV2LimitRecoveryUpdate | null;
136 readonly title?: string;
137 readonly modelSelection?: ModelSelection;
138 readonly branch?: string | null;
139 readonly worktreePath?: string | null;
140 /** Kick off an async title regeneration for the thread. */
141 readonly regenerateTitle?: boolean;
142 /** Link (object) or unlink (null) a pull request (#8160). */
143 readonly linkedPullRequest?: ThreadLinkedPullRequest | null;
144 /** Apply only while no message or run has landed on the thread. */
145 readonly expectedEmpty?: boolean;
146}
147
148export interface SetThreadRuntimeModeInput extends ThreadCommandInput {
149 readonly runtimeMode: RuntimeMode;
150}
151
152export interface SetThreadInteractionModeInput extends ThreadCommandInput {
153 readonly interactionMode: ProviderInteractionMode;
154}
155
156interface StartThreadBootstrap {
157 readonly createThread?: {
158 readonly projectId: ProjectId;
159 readonly title: string;
160 readonly modelSelection: ModelSelection;
161 readonly runtimeMode: RuntimeMode;
162 readonly interactionMode: ProviderInteractionMode;
163 readonly branch: string | null;
164 readonly worktreePath: string | null;
165 readonly createdAt: string;
166 };
167 readonly prepareWorktree?: {
168 /** V2 worktree launches always fail rather than falling back to the project checkout. */
169 readonly requireWorktree?: boolean;
170 readonly projectCwd: string;
171 readonly baseBranch: string;
172 readonly branch?: string;
173 readonly startFromOrigin?: boolean;
174 };
175 readonly runSetupScript?: boolean;
176}
177
178export interface StartThreadTurnInput extends ThreadCommandInput {
179 readonly manualContinuationOfRunId?: RunId;
180 readonly message: {
181 readonly messageId: MessageId;
182 readonly role: "user";
183 readonly text: string;
184 readonly attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
185 readonly context?: import("@t3tools/contracts").OrchestrationMessageContext;
186 };
187 readonly modelSelection?: ModelSelection;
188 readonly titleSeed?: string;
189 readonly runtimeMode: RuntimeMode;
190 readonly interactionMode: ProviderInteractionMode;
191 readonly bootstrap?: StartThreadBootstrap;
192 readonly sourceProposedPlan?: { readonly threadId: ThreadId; readonly planId: PlanId };
193 readonly dispatchMode?: "auto" | "queue" | "steer" | "restart" | "start";
194}
195
196export interface InterruptThreadTurnInput extends ThreadCommandInput {
197 readonly runId?: RunId;
198 readonly subagentId?: NodeId;
199 /** Temporary caller compatibility while UI naming moves from turns to runs. */
200 readonly turnId?: string;
201}
202
203export interface RespondToThreadApprovalInput extends ThreadCommandInput {
204 readonly requestId: RuntimeRequestId;
205 readonly decision: ProviderApprovalDecision;
206}
207
208export interface RespondToThreadUserInputInput extends ThreadCommandInput {
209 readonly requestId: RuntimeRequestId;
210 readonly answers: ProviderUserInputAnswers;
211 readonly attachmentsByQuestionId?: import("@t3tools/contracts").UserInputAttachments;
212}
213
214export interface DismissThreadUserInputInput extends ThreadCommandInput {
215 readonly requestId: RuntimeRequestId;
216}
217
218export interface RevertThreadCheckpointInput extends ThreadCommandInput {
219 readonly restoreFiles?: boolean;
220 readonly checkpointId?: string;
221 readonly scopeId?: string;
222 readonly turnCount?: number;
223}
224
225export type StopThreadSessionInput = ThreadCommandInput;
226
227export interface ForkThreadFromRunInput extends CommandMetadata {
228 readonly sourceThreadId: ThreadId;
229 readonly targetThreadId: ThreadId;
230 readonly runId: RunId;
231 readonly title?: string;
232}
233
234export interface MergeThreadBackInput extends CommandMetadata {
235 readonly sourceThreadId: ThreadId;
236 readonly targetThreadId: ThreadId;
237 readonly runId: RunId;
238}
239
240export interface ReorderQueuedRunInput extends ThreadCommandInput {
241 readonly runId: RunId;
242 readonly beforeRunId: RunId | null;
243}
244
245export interface PromoteQueuedRunInput extends ThreadCommandInput {
246 readonly queuedRunId: RunId;
247 readonly targetRunId: RunId;
248}
249
250export interface CancelQueuedRunInput extends ThreadCommandInput {
251 readonly runId: RunId;
252}
253
254export interface RetryWorkspacePreparationInput extends ThreadCommandInput {
255 readonly runId: RunId;
256}
257
258export interface EditQueuedRunInput extends ThreadCommandInput {
259 readonly runId: RunId;
260 readonly text: string;
261 /**
262 * Full replacement attachment list for the queued message. Omitted =
263 * text-only edit that leaves attachments untouched. `dataUrl` entries are
264 * persisted against `messageId` (the queued run's user message) before
265 * dispatch, so `messageId` is required whenever attachments are present.
266 */
267 readonly edit?: {
268 readonly messageId: MessageId;
269 readonly attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>;
270 readonly context?: import("@t3tools/contracts").OrchestrationMessageContext;
271 };
272}
273
274const allocateCommandId = Effect.fn("EnvironmentCommands.allocateCommandId")(function* (
275 input: CommandMetadata,
276) {
277 if (input.commandId !== undefined) return input.commandId;
278 const crypto = yield* Crypto.Crypto;
279 return CommandId.make(yield* crypto.randomUUIDv4().pipe(Effect.orDie));
280});
281
282const dispatch = Effect.fn("EnvironmentCommands.dispatch")(function* (
283 command: OrchestrationV2Command,
284) {
285 if (isHubOrganizationCommand(command)) {
286 const hub = yield* Effect.serviceOption(Hub.HubConnection);
287 if (Option.isSome(hub)) {
288 const supervisor = yield* EnvironmentSupervisor.EnvironmentSupervisor;
289 const result = yield* hub.value.organize({
290 environmentId: supervisor.target.environmentId,
291 command,
292 });
293 if (Option.isSome(result)) return result.value;
294 }
295 }
296 return yield* request(ORCHESTRATION_V2_WS_METHODS.dispatchCommand, command);
297});
298
299const getProjection = (threadId: ThreadId) =>
300 request(ORCHESTRATION_V2_WS_METHODS.getThreadProjection, { threadId });
301
302const supportsServerResolvedCommandContext = Effect.fn(
303 "EnvironmentCommands.supportsServerResolvedCommandContext",
304)(function* () {
305 const config = yield* getInitialServerConfig();
306 return config.environment.capabilities.serverResolvedCommandContext === true;
307});
308
309const persistAttachments = Effect.fn("EnvironmentCommands.persistAttachments")(function* (
310 threadId: ThreadId,
311 messageId: MessageId,
312 attachments: ReadonlyArray<ChatAttachment | UploadChatAttachment>,
313) {
314 const stored = attachments.filter(
315 (attachment): attachment is ChatAttachment => !("dataUrl" in attachment),
316 );
317 const uploads = attachments.filter(
318 (attachment): attachment is UploadChatAttachment => "dataUrl" in attachment,
319 );
320 if (uploads.length === 0) return stored;
321 const result = yield* request(WS_METHODS.assetsPersistChatAttachments, {
322 threadId,
323 messageId,
324 attachments: uploads,
325 });
326 if (stored.length === 0) return result.attachments;
327 const byUpload = new Map(
328 uploads.map((attachment, index) => [attachment, result.attachments[index]]),
329 );
330 return attachments.flatMap((attachment) => {
331 if (!("dataUrl" in attachment)) return [attachment];
332 const persisted = byUpload.get(attachment);
333 return persisted === undefined ? [] : [persisted];
334 });
335});
336
337const mutateProject = Effect.fn("EnvironmentCommands.mutateProject")(function* (
338 mutation:
339 | {
340 readonly type: "project.create";
341 readonly commandId: CommandId;
342 readonly projectId: ProjectId;
343 readonly title: string;
344 readonly workspaceRoot: string;
345 readonly createWorkspaceRootIfMissing?: boolean;
346 readonly defaultModelSelection?: ModelSelection | null;
347 readonly scripts?: ReadonlyArray<ProjectScript>;
348 }
349 | {
350 readonly type: "project.update";
351 readonly commandId: CommandId;
352 readonly projectId: ProjectId;
353 readonly title?: string;
354 readonly workspaceRoot?: string;
355 readonly defaultModelSelection?: ModelSelection | null;
356 readonly scripts?: ReadonlyArray<ProjectScript>;
357 }
358 | {
359 readonly type: "project.delete";
360 readonly commandId: CommandId;
361 readonly projectId: ProjectId;
362 readonly force?: boolean;
363 },
364) {
365 return yield* request(WS_METHODS.projectsMutate, mutation);
366});
367
368export const createProject = Effect.fn("EnvironmentCommands.createProject")(function* (
369 input: CreateProjectInput,
370) {
371 return yield* mutateProject({
372 type: "project.create",
373 commandId: yield* allocateCommandId(input),
374 projectId: input.projectId,
375 title: input.title,
376 workspaceRoot: input.workspaceRoot,
377 ...(input.createWorkspaceRootIfMissing === undefined
378 ? {}
379 : { createWorkspaceRootIfMissing: input.createWorkspaceRootIfMissing }),
380 ...(input.defaultModelSelection === undefined
381 ? {}
382 : { defaultModelSelection: input.defaultModelSelection }),
383 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
384 });
385});
386
387export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(function* (
388 input: UpdateProjectInput,
389) {
390 return yield* mutateProject({
391 type: "project.update",
392 commandId: yield* allocateCommandId(input),
393 projectId: input.projectId,
394 ...(input.title === undefined ? {} : { title: input.title }),
395 ...(input.workspaceRoot === undefined ? {} : { workspaceRoot: input.workspaceRoot }),
396 ...(input.defaultModelSelection === undefined
397 ? {}
398 : { defaultModelSelection: input.defaultModelSelection }),
399 ...(input.autoPull === undefined ? {} : { autoPull: input.autoPull }),
400 ...(input.projectIcon === undefined ? {} : { projectIcon: input.projectIcon }),
401 ...(input.faviconPath === undefined ? {} : { faviconPath: input.faviconPath }),
402 ...(input.defaultThreadEnvMode === undefined
403 ? {}
404 : { defaultThreadEnvMode: input.defaultThreadEnvMode }),
405 ...(input.scripts === undefined ? {} : { scripts: input.scripts }),
406 });
407});
408
409export const deleteProject = Effect.fn("EnvironmentCommands.deleteProject")(function* (
410 input: DeleteProjectInput,
411) {
412 return yield* mutateProject({
413 type: "project.delete",
414 commandId: yield* allocateCommandId(input),
415 projectId: input.projectId,
416 ...(input.force === undefined ? {} : { force: input.force }),
417 });
418});
419
420export const createThread = Effect.fn("EnvironmentCommands.createThread")(function* (
421 input: CreateThreadInput,
422) {
423 return yield* dispatch({
424 type: "thread.create",
425 commandId: yield* allocateCommandId(input),
426 createdBy: "user",
427 creationSource: input.creationSource ?? "web",
428 threadId: input.threadId,
429 projectId: input.projectId,
430 title: input.title,
431 modelSelection: input.modelSelection,
432 runtimeMode: input.runtimeMode,
433 interactionMode: input.interactionMode,
434 branch: input.branch,
435 worktreePath: input.worktreePath,
436 });
437});
438
439function simpleThreadCommand(
440 type:
441 | "thread.delete"
442 | "thread.archive"
443 | "thread.unarchive"
444 | "thread.settle"
445 | "thread.pin"
446 | "thread.unpin",
447 input: ThreadCommandInput,
448) {
449 return allocateCommandId(input).pipe(
450 Effect.flatMap((commandId) => dispatch({ type, commandId, threadId: input.threadId })),
451 );
452}
453
454export const deleteThread = Effect.fn("EnvironmentCommands.deleteThread")(function* (
455 input: DeleteThreadInput,
456) {
457 return yield* simpleThreadCommand("thread.delete", input);
458});
459
460export const archiveThread = Effect.fn("EnvironmentCommands.archiveThread")(function* (
461 input: ArchiveThreadInput,
462) {
463 return yield* dispatch({
464 type: "thread.archive",
465 commandId: yield* allocateCommandId(input),
466 threadId: input.threadId,
467 ...(input.snapshotSequence === undefined ? {} : { snapshotSequence: input.snapshotSequence }),
468 });
469});
470
471export const unarchiveThread = Effect.fn("EnvironmentCommands.unarchiveThread")(function* (
472 input: UnarchiveThreadInput,
473) {
474 return yield* simpleThreadCommand("thread.unarchive", input);
475});
476
477export const settleThread = Effect.fn("EnvironmentCommands.settleThread")(function* (
478 input: SettleThreadInput,
479) {
480 return yield* simpleThreadCommand("thread.settle", input);
481});
482
483export const pinThread = Effect.fn("EnvironmentCommands.pinThread")(function* (
484 input: PinThreadInput,
485) {
486 const commandId = yield* allocateCommandId(input);
487 return yield* dispatch({
488 type: "thread.pin",
489 commandId,
490 threadId: input.threadId,
491 ...(input.orderKey === undefined ? {} : { orderKey: input.orderKey }),
492 });
493});
494
495export interface SetThreadAutoSettleInput extends ThreadCommandInput {
496 readonly enabled: boolean;
497}
498export const setThreadAutoSettle = Effect.fn("EnvironmentCommands.setThreadAutoSettle")(function* (
499 input: SetThreadAutoSettleInput,
500) {
501 return yield* dispatch({
502 type: "thread.auto-settle.set",
503 commandId: yield* allocateCommandId(input),
504 threadId: input.threadId,
505 enabled: input.enabled,
506 });
507});
508
509export const reorderPinnedThread = Effect.fn("EnvironmentCommands.reorderPinnedThread")(function* (
510 input: ReorderPinnedThreadInput,
511) {
512 const commandId = yield* allocateCommandId(input);
513 return yield* dispatch({
514 type: "thread.pin.reorder",
515 commandId,
516 threadId: input.threadId,
517 orderKey: input.orderKey,
518 });
519});
520
521export const reorderActiveThread = Effect.fn("EnvironmentCommands.reorderActiveThread")(function* (
522 input: ReorderActiveThreadInput,
523) {
524 return yield* dispatch({
525 type: "thread.active.reorder",
526 commandId: yield* allocateCommandId(input),
527 threadId: input.threadId,
528 orderKey: input.orderKey,
529 });
530});
531
532export const unpinThread = Effect.fn("EnvironmentCommands.unpinThread")(function* (
533 input: UnpinThreadInput,
534) {
535 return yield* simpleThreadCommand("thread.unpin", input);
536});
537
538export const unsettleThread = Effect.fn("EnvironmentCommands.unsettleThread")(function* (
539 input: UnsettleThreadInput,
540) {
541 return yield* dispatch({
542 type: "thread.unsettle",
543 commandId: yield* allocateCommandId(input),
544 threadId: input.threadId,
545 reason: input.reason,
546 });
547});
548
549export const snoozeThread = Effect.fn("EnvironmentCommands.snoozeThread")(function* (
550 input: SnoozeThreadInput,
551) {
552 return yield* dispatch({
553 type: "thread.snooze",
554 commandId: yield* allocateCommandId(input),
555 threadId: input.threadId,
556 snoozedUntil: input.snoozedUntil,
557 });
558});
559
560export const unsnoozeThread = Effect.fn("EnvironmentCommands.unsnoozeThread")(function* (
561 input: UnsnoozeThreadInput,
562) {
563 return yield* dispatch({
564 type: "thread.unsnooze",
565 commandId: yield* allocateCommandId(input),
566 threadId: input.threadId,
567 reason: input.reason,
568 });
569});
570
571export const visitThread = Effect.fn("EnvironmentCommands.visitThread")(function* (
572 input: VisitThreadInput,
573) {
574 return yield* dispatch({
575 type: "thread.visit",
576 commandId: yield* allocateCommandId(input),
577 threadId: input.threadId,
578 visitedAt: input.visitedAt,
579 });
580});
581
582export const markThreadUnread = Effect.fn("EnvironmentCommands.markThreadUnread")(function* (
583 input: MarkThreadUnreadInput,
584) {
585 return yield* dispatch({
586 type: "thread.mark-unread",
587 commandId: yield* allocateCommandId(input),
588 threadId: input.threadId,
589 });
590});
591
592export const updateThreadMetadata = Effect.fn("EnvironmentCommands.updateThreadMetadata")(
593 function* (input: UpdateThreadMetadataInput) {
594 const commandId = yield* allocateCommandId(input);
595 let result = null;
596 if (
597 input.title !== undefined ||
598 input.branch !== undefined ||
599 input.worktreePath !== undefined ||
600 input.regenerateTitle !== undefined ||
601 input.linkedPullRequest !== undefined ||
602 input.limitRecovery !== undefined
603 ) {
604 result = yield* dispatch({
605 type: "thread.metadata.update",
606 ...(input.limitRecovery === undefined ? {} : { limitRecovery: input.limitRecovery }),
607 commandId,
608 threadId: input.threadId,
609 ...(input.title === undefined ? {} : { title: input.title }),
610 ...(input.branch === undefined ? {} : { branch: input.branch }),
611 ...(input.worktreePath === undefined ? {} : { worktreePath: input.worktreePath }),
612 ...(input.expectedEmpty === true ? { expectedEmpty: true } : {}),
613 ...(input.regenerateTitle === undefined ? {} : { regenerateTitle: input.regenerateTitle }),
614 ...(input.linkedPullRequest === undefined
615 ? {}
616 : { linkedPullRequest: input.linkedPullRequest }),
617 });
618 }
619 if (input.modelSelection !== undefined) {
620 const type = (yield* supportsServerResolvedCommandContext())
621 ? ("thread.model-selection.set" as const)
622 : modelSelectionCommandType(
623 (yield* getProjection(input.threadId)).thread.providerInstanceId,
624 input.modelSelection,
625 );
626 result = yield* dispatch({
627 type,
628 commandId: result === null ? commandId : CommandId.make(`${commandId}:model-selection`),
629 threadId: input.threadId,
630 modelSelection: input.modelSelection,
631 });
632 }
633 return result ?? { sequence: 0 };
634 },
635);
636
637export const setThreadRuntimeMode = Effect.fn("EnvironmentCommands.setThreadRuntimeMode")(
638 function* (input: SetThreadRuntimeModeInput) {
639 return yield* dispatch({
640 type: "thread.runtime-mode.set",
641 commandId: yield* allocateCommandId(input),
642 threadId: input.threadId,
643 runtimeMode: input.runtimeMode,
644 });
645 },
646);
647
648export const setThreadInteractionMode = Effect.fn("EnvironmentCommands.setThreadInteractionMode")(
649 function* (input: SetThreadInteractionModeInput) {
650 return yield* dispatch({
651 type: "thread.interaction-mode.set",
652 commandId: yield* allocateCommandId(input),
653 threadId: input.threadId,
654 interactionMode: input.interactionMode,
655 });
656 },
657);
658
659export const startThreadTurn = Effect.fn("EnvironmentCommands.startThreadTurn")(function* (
660 input: StartThreadTurnInput,
661) {
662 const commandId = yield* allocateCommandId(input);
663 const attachments = yield* persistAttachments(
664 input.threadId,
665 input.message.messageId,
666 input.message.attachments,
667 );
668 const context = remapComposerContextAttachments(
669 input.message.context,
670 input.message.attachments,
671 attachments,
672 );
673 const bootstrap = input.bootstrap?.createThread;
674 const prepareWorktree = input.bootstrap?.prepareWorktree;
675 if (bootstrap !== undefined || prepareWorktree !== undefined) {
676 const existingProjection =
677 bootstrap === undefined ? yield* getProjection(input.threadId) : null;
678 const thread = bootstrap ?? existingProjection!.thread;
679 const workspaceStrategy =
680 prepareWorktree !== undefined
681 ? {
682 type: "worktree" as const,
683 baseRef: prepareWorktree.baseBranch,
684 ...(prepareWorktree.branch === undefined ? {} : { branch: prepareWorktree.branch }),
685 ...(prepareWorktree.startFromOrigin === undefined
686 ? {}
687 : { startFromOrigin: prepareWorktree.startFromOrigin }),
688 }
689 : bootstrap?.worktreePath
690 ? {
691 type: "existing_worktree" as const,
692 worktreePath: bootstrap.worktreePath,
693 ...(bootstrap.branch === null ? {} : { branch: bootstrap.branch }),
694 }
695 : {
696 type: "root" as const,
697 ...(bootstrap?.branch === null || bootstrap?.branch === undefined
698 ? {}
699 : { branch: bootstrap.branch }),
700 };
701 return yield* request(ORCHESTRATION_V2_WS_METHODS.launchThread, {
702 commandId,
703 creationSource: input.creationSource ?? "web",
704 threadId: input.threadId,
705 ...(bootstrap === undefined ? { reuseExistingThread: true } : {}),
706 projectId: thread.projectId,
707 title: input.titleSeed ?? thread.title,
708 generateTitle: input.titleSeed !== undefined,
709 modelSelection: input.modelSelection ?? thread.modelSelection,
710 runtimeMode: input.runtimeMode,
711 interactionMode: input.interactionMode,
712 workspaceStrategy,
713 initialMessage: {
714 messageId: input.message.messageId,
715 text: input.message.text,
716 ...(context ? { context } : {}),
717 attachments,
718 },
719 });
720 }
721
722 const requestedMode = input.dispatchMode ?? "auto";
723 if (requestedMode === "start") {
724 return yield* dispatch({
725 type: "message.dispatch",
726 commandId,
727 createdBy: "user",
728 creationSource: input.creationSource ?? "web",
729 threadId: input.threadId,
730 messageId: input.message.messageId,
731 ...(input.manualContinuationOfRunId === undefined
732 ? {}
733 : { manualContinuationOfRunId: input.manualContinuationOfRunId }),
734 text: input.message.text,
735 ...(context ? { context } : {}),
736 attachments,
737 ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }),
738 ...(input.sourceProposedPlan === undefined
739 ? {}
740 : { sourcePlanRef: input.sourceProposedPlan }),
741 dispatchMode: { type: "start_immediately" },
742 });
743 }
744
745 const serverResolvesCommandContext = yield* supportsServerResolvedCommandContext();
746 const projection = serverResolvesCommandContext ? null : yield* getProjection(input.threadId);
747 const activeRun = projection?.runs.findLast(
748 (run) =>
749 run.status === "preparing" ||
750 run.status === "starting" ||
751 run.status === "running" ||
752 run.status === "waiting",
753 );
754 const activeProviderThread =
755 activeRun === undefined
756 ? undefined
757 : projection?.providerThreads.find((thread) => thread.id === activeRun.providerThreadId);
758 const activeProviderSession =
759 activeProviderThread?.providerSessionId === null ||
760 activeProviderThread?.providerSessionId === undefined
761 ? undefined
762 : projection?.providerSessions.find(
763 (session) => session.id === activeProviderThread.providerSessionId,
764 );
765 const turnCapabilities = activeProviderSession?.capabilities.turns;
766 const dispatchMode = serverResolvesCommandContext
767 ? requestedMode === "queue"
768 ? ({ type: "queue_after_active" } as const)
769 : ({ type: "start_immediately" } as const)
770 : activeRun === undefined
771 ? ({ type: "start_immediately" } as const)
772 : requestedMode === "steer"
773 ? ({ type: "steer_active", targetRunId: activeRun.id } as const)
774 : requestedMode === "restart"
775 ? ({ type: "restart_active", targetRunId: activeRun.id } as const)
776 : requestedMode === "queue"
777 ? ({ type: "queue_after_active" } as const)
778 : turnCapabilities?.supportsActiveSteering === true
779 ? ({ type: "steer_active", targetRunId: activeRun.id } as const)
780 : turnCapabilities?.supportsQueuedMessages === true
781 ? ({ type: "queue_after_active" } as const)
782 : turnCapabilities?.supportsSteeringByInterruptRestart === true
783 ? ({ type: "restart_active", targetRunId: activeRun.id } as const)
784 : ({ type: "queue_after_active" } as const);
785 const shouldSendTitleSeed =
786 input.titleSeed !== undefined &&
787 (serverResolvesCommandContext || projection?.messages.length === 0);
788 return yield* dispatch({
789 type: "message.dispatch",
790 commandId,
791 createdBy: "user",
792 creationSource: input.creationSource ?? "web",
793 threadId: input.threadId,
794 messageId: input.message.messageId,
795 text: input.message.text,
796 ...(context ? { context } : {}),
797 attachments,
798 ...(shouldSendTitleSeed ? { titleSeed: input.titleSeed } : {}),
799 ...(input.modelSelection === undefined ? {} : { modelSelection: input.modelSelection }),
800 ...(input.sourceProposedPlan === undefined ? {} : { sourcePlanRef: input.sourceProposedPlan }),
801 ...(serverResolvesCommandContext && requestedMode !== "queue"
802 ? { deliveryIntent: requestedMode }
803 : {}),
804 dispatchMode,
805 });
806});
807
808/**
809 * Stop for the thread's latest work: interrupts the active run, or the settled run whose
810 * background work still runs. With no run to stop, it ends the thread's pull request
811 * watches, the only background work that has no run.
812 */
813export const interruptThreadTurn = Effect.fn("EnvironmentCommands.interruptThreadTurn")(function* (
814 input: InterruptThreadTurnInput,
815) {
816 if (input.subagentId !== undefined) {
817 const projection = yield* getProjection(input.threadId);
818 const subagent = projection.subagents.find((agent) => agent.id === input.subagentId);
819 if (subagent?.runId == null) return { sequence: 0 };
820 return yield* dispatch({
821 type: "subagent.stop",
822 commandId: yield* allocateCommandId(input),
823 threadId: input.threadId,
824 runId: subagent.runId,
825 subagentId: subagent.id,
826 });
827 }
828 let runId = input.runId ?? (input.turnId as RunId | undefined);
829 if (runId === undefined) {
830 const projection = yield* getProjection(input.threadId);
831 runId = projection.runs.findLast(
832 (run) =>
833 run.status === "preparing" ||
834 run.status === "starting" ||
835 run.status === "running" ||
836 run.status === "waiting",
837 )?.id;
838 if (runId === undefined) {
839 const latestRun = projection.runs.at(-1);
840 if (
841 derivePendingBackgroundWork({
842 latestRun,
843 providerThreads: projection.providerThreads,
844 turnItems: projection.turnItems,
845 activeProviderThreadId: projection.thread.activeProviderThreadId,
846 runs: projection.runs,
847 }).length > 0
848 ) {
849 runId = latestRun?.id;
850 }
851 }
852 if (runId === undefined) {
853 let result = { sequence: 0 };
854 for (const link of visibleThreadPullRequests(projection.thread.pullRequests ?? [])) {
855 if (link.watch === undefined) continue;
856 result = yield* dispatch({
857 type: "thread.pull-request.watch",
858 commandId: yield* allocateCommandId(result.sequence === 0 ? input : {}),
859 threadId: input.threadId,
860 host: link.host,
861 repository: link.repository,
862 number: link.number,
863 watching: false,
864 });
865 }
866 return result;
867 }
868 }
869 if (runId === undefined) return { sequence: 0 };
870 return yield* dispatch({
871 type: "run.interrupt",
872 commandId: yield* allocateCommandId(input),
873 threadId: input.threadId,
874 runId,
875 holdQueue: true,
876 });
877});
878
879export const respondToThreadApproval = Effect.fn("EnvironmentCommands.respondToThreadApproval")(
880 function* (input: RespondToThreadApprovalInput) {
881 return yield* dispatch({
882 type: "runtime-request.respond",
883 commandId: yield* allocateCommandId(input),
884 threadId: input.threadId,
885 requestId: input.requestId,
886 decision: input.decision,
887 });
888 },
889);
890
891export const respondToThreadUserInput = Effect.fn("EnvironmentCommands.respondToThreadUserInput")(
892 function* (input: RespondToThreadUserInputInput) {
893 return yield* dispatch({
894 type: "runtime-request.respond",
895 commandId: yield* allocateCommandId(input),
896 threadId: input.threadId,
897 requestId: input.requestId,
898 answers: input.answers,
899 ...(input.attachmentsByQuestionId === undefined
900 ? {}
901 : { attachmentsByQuestionId: input.attachmentsByQuestionId }),
902 });
903 },
904);
905
906export const dismissThreadUserInput = Effect.fn("EnvironmentCommands.dismissThreadUserInput")(
907 function* (input: DismissThreadUserInputInput) {
908 return yield* dispatch({
909 type: "thread.user-input.dismiss",
910 commandId: yield* allocateCommandId(input),
911 threadId: input.threadId,
912 requestId: input.requestId,
913 });
914 },
915);
916
917export const revertThreadCheckpoint = Effect.fn("EnvironmentCommands.revertThreadCheckpoint")(
918 function* (input: RevertThreadCheckpointInput) {
919 if (
920 input.checkpointId !== undefined &&
921 input.scopeId !== undefined &&
922 (yield* supportsServerResolvedCommandContext())
923 ) {
924 return yield* dispatch({
925 type: "checkpoint.rollback",
926 ...(input.restoreFiles === undefined ? {} : { restoreFiles: input.restoreFiles }),
927 commandId: yield* allocateCommandId(input),
928 threadId: input.threadId,
929 scopeId: CheckpointScopeId.make(input.scopeId),
930 checkpointId: CheckpointId.make(input.checkpointId),
931 });
932 }
933 const projection = yield* getProjection(input.threadId);
934 const checkpoint =
935 projection.checkpoints.find(
936 (candidate) => candidate.id === input.checkpointId && candidate.scopeId === input.scopeId,
937 ) ??
938 projection.checkpoints.findLast((candidate) =>
939 input.turnCount === 0
940 ? candidate.ordinalWithinScope === 0 && candidate.appRunOrdinal === null
941 : candidate.appRunOrdinal === input.turnCount,
942 );
943 if (checkpoint === undefined || checkpoint.status !== "ready") {
944 const target =
945 input.checkpointId === undefined
946 ? `run ordinal ${input.turnCount ?? "unknown"}`
947 : `checkpoint ${input.checkpointId}`;
948 return yield* new OrchestrationV2CheckpointUnavailableError({
949 threadId: input.threadId,
950 target,
951 });
952 }
953 return yield* dispatch({
954 type: "checkpoint.rollback",
955 ...(input.restoreFiles === undefined ? {} : { restoreFiles: input.restoreFiles }),
956 commandId: yield* allocateCommandId(input),
957 threadId: input.threadId,
958 scopeId: checkpoint.scopeId,
959 checkpointId: checkpoint.id,
960 });
961 },
962);
963
964export const stopThreadSession = Effect.fn("EnvironmentCommands.stopThreadSession")(function* (
965 input: StopThreadSessionInput,
966) {
967 const projection = yield* getProjection(input.threadId);
968 const commandId = yield* allocateCommandId(input);
969 let result = { sequence: 0 };
970 for (const session of projection.providerSessions) {
971 result = yield* dispatch({
972 type: "provider-session.detach",
973 commandId: CommandId.make(`${commandId}:detach:${session.id}`),
974 threadId: input.threadId,
975 providerSessionId: session.id,
976 reason: "client-requested",
977 });
978 }
979 return result;
980});
981
982export const forkThreadFromRun = Effect.fn("EnvironmentCommands.forkThreadFromRun")(function* (
983 input: ForkThreadFromRunInput,
984) {
985 return yield* dispatch({
986 type: "thread.fork",
987 commandId: yield* allocateCommandId(input),
988 createdBy: "user",
989 creationSource: input.creationSource ?? "web",
990 sourceThreadId: input.sourceThreadId,
991 targetThreadId: input.targetThreadId,
992 sourcePoint: { type: "run", runId: input.runId },
993 ...(input.title === undefined ? {} : { title: input.title }),
994 });
995});
996
997export const mergeThreadBack = Effect.fn("EnvironmentCommands.mergeThreadBack")(function* (
998 input: MergeThreadBackInput,
999) {
1000 return yield* dispatch({
1001 type: "thread.merge_back",
1002 commandId: yield* allocateCommandId(input),
1003 createdBy: "user",
1004 creationSource: input.creationSource ?? "web",
1005 sourceThreadId: input.sourceThreadId,
1006 targetThreadId: input.targetThreadId,
1007 sourcePoint: { type: "run", runId: input.runId },
1008 });
1009});
1010
1011export const resumeThreadQueue = Effect.fn("EnvironmentCommands.resumeThreadQueue")(function* (
1012 input: ThreadCommandInput,
1013) {
1014 return yield* dispatch({
1015 type: "queue.resume",
1016 commandId: yield* allocateCommandId(input),
1017 threadId: input.threadId,
1018 });
1019});
1020
1021export const reorderQueuedRun = Effect.fn("EnvironmentCommands.reorderQueuedRun")(function* (
1022 input: ReorderQueuedRunInput,
1023) {
1024 return yield* dispatch({
1025 type: "queued-run.reorder",
1026 commandId: yield* allocateCommandId(input),
1027 threadId: input.threadId,
1028 runId: input.runId,
1029 beforeRunId: input.beforeRunId,
1030 });
1031});
1032
1033export const promoteQueuedRun = Effect.fn("EnvironmentCommands.promoteQueuedRun")(function* (
1034 input: PromoteQueuedRunInput,
1035) {
1036 return yield* dispatch({
1037 type: "queued-message.promote-to-steer",
1038 commandId: yield* allocateCommandId(input),
1039 threadId: input.threadId,
1040 queuedRunId: input.queuedRunId,
1041 targetRunId: input.targetRunId,
1042 });
1043});
1044
1045export const cancelQueuedRun = Effect.fn("EnvironmentCommands.cancelQueuedRun")(function* (
1046 input: CancelQueuedRunInput,
1047) {
1048 return yield* dispatch({
1049 type: "queued-run.cancel",
1050 commandId: yield* allocateCommandId(input),
1051 threadId: input.threadId,
1052 runId: input.runId,
1053 });
1054});
1055
1056export const retryWorkspacePreparation = Effect.fn("EnvironmentCommands.retryWorkspacePreparation")(
1057 function* (input: RetryWorkspacePreparationInput) {
1058 return yield* dispatch({
1059 type: "prepared-run.retry",
1060 commandId: yield* allocateCommandId(input),
1061 threadId: input.threadId,
1062 runId: input.runId,
1063 });
1064 },
1065);
1066
1067export const editQueuedRun = Effect.fn("EnvironmentCommands.editQueuedRun")(function* (
1068 input: EditQueuedRunInput,
1069) {
1070 const attachments =
1071 input.edit === undefined
1072 ? undefined
1073 : yield* persistAttachments(input.threadId, input.edit.messageId, input.edit.attachments);
1074 return yield* dispatch({
1075 type: "queued-run.edit",
1076 commandId: yield* allocateCommandId(input),
1077 threadId: input.threadId,
1078 runId: input.runId,
1079 text: input.text,
1080 ...(attachments === undefined ? {} : { attachments }),
1081 ...(input.edit?.context && attachments
1082 ? {
1083 context: remapComposerContextAttachments(
1084 input.edit.context,
1085 input.edit.attachments,
1086 attachments,
1087 ),
1088 }
1089 : {}),
1090 });
1091});
1092
1093export type LinkThreadPullRequestInput = Omit<
1094 Extract<OrchestrationV2Command, { type: "thread.pull-request.link" }>,
1095 "type" | "commandId"
1096> &
1097 CommandMetadata;
1098export type UnlinkThreadPullRequestInput = Omit<
1099 Extract<OrchestrationV2Command, { type: "thread.pull-request.unlink" }>,
1100 "type" | "commandId"
1101> &
1102 CommandMetadata;
1103export const linkThreadPullRequest = Effect.fn("EnvironmentCommands.linkThreadPullRequest")(
1104 function* (input: LinkThreadPullRequestInput) {
1105 return yield* dispatch({
1106 ...input,
1107 type: "thread.pull-request.link",
1108 commandId: yield* allocateCommandId(input),
1109 });
1110 },
1111);
1112export type WatchThreadPullRequestInput = Omit<
1113 Extract<OrchestrationV2Command, { type: "thread.pull-request.watch" }>,
1114 "type" | "commandId"
1115> &
1116 CommandMetadata;
1117export const watchThreadPullRequest = Effect.fn("EnvironmentCommands.watchThreadPullRequest")(
1118 function* (input: WatchThreadPullRequestInput) {
1119 return yield* dispatch({
1120 ...input,
1121 type: "thread.pull-request.watch",
1122 commandId: yield* allocateCommandId(input),
1123 });
1124 },
1125);
1126export const unlinkThreadPullRequest = Effect.fn("EnvironmentCommands.unlinkThreadPullRequest")(
1127 function* (input: UnlinkThreadPullRequestInput) {
1128 return yield* dispatch({
1129 ...input,
1130 type: "thread.pull-request.unlink",
1131 commandId: yield* allocateCommandId(input),
1132 });
1133 },
1134);