import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; import { clampFileAttachmentUploadBytes, fileAttachmentTooLargeMessage, } from "@t3tools/client-runtime/state/attachments"; import type { EnvironmentShellStatus } from "@t3tools/client-runtime/state/shell"; import type { ThreadFamilyRead } from "@t3tools/client-runtime/state/threads"; import { CommandId, EnvironmentId, IsoDateTime, MessageId, ModelSelection, OrchestrationMessageContext, ProjectId, ProviderInteractionMode, RuntimeMode, ThreadId, type ModelSelection as ModelSelectionType, type ProjectId as ProjectIdType, type ProviderInteractionMode as ProviderInteractionModeType, type RuntimeMode as RuntimeModeType, type ServerProvider, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; import { DraftComposerAttachmentSchema } from "../lib/composer-image-schema"; import type { ComposerDispatchMode } from "@t3tools/client-runtime/state/composer-dispatch"; import type { DraftComposerAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; import { resolveProviderInteractionMode } from "./legacy-plan-mode"; // Keep current writes until a compatible native baseline includes the v4 reader. const THREAD_OUTBOX_SCHEMA_VERSION = 3; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; const QueuedThreadCreationSchema = Schema.Struct({ projectId: ProjectId, projectTitle: Schema.optional(Schema.String), projectCwd: Schema.optional(Schema.String), workspaceMode: Schema.Literals(["local", "worktree"]), branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), startFromOrigin: Schema.optional(Schema.Boolean), pullRequestReference: Schema.optional(Schema.String), }); export const QueuedThreadMessageSchema = Schema.Struct({ schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION, 4]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, commandId: CommandId, text: Schema.String, context: Schema.optional(OrchestrationMessageContext), attachments: Schema.Array(DraftComposerAttachmentSchema), modelSelection: Schema.optional(ModelSelection), // Written with every stored model since follow-ups carry only the user's pick. Entries without it // come from builds that stored this client's cached thread model on every follow-up. modelPicked: Schema.optional(Schema.Literal(true)), dispatchMode: Schema.optional(Schema.Literals(["auto", "queue", "steer", "restart"])), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), creation: Schema.optional(QueuedThreadCreationSchema), createdAt: IsoDateTime, }); const decodeStoredQueuedThreadMessage = Schema.decodeUnknownSync(QueuedThreadMessageSchema); const encodeStoredQueuedThreadMessage = Schema.encodeUnknownSync(QueuedThreadMessageSchema); export interface QueuedThreadCreation { readonly projectId: ProjectIdType; readonly projectTitle?: string; readonly projectCwd?: string; readonly workspaceMode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; readonly startFromOrigin?: boolean; readonly pullRequestReference?: string; } export interface QueuedThreadMessage { readonly environmentId: EnvironmentId; readonly threadId: ThreadId; readonly messageId: MessageId; readonly commandId: CommandId; readonly text: string; readonly context?: OrchestrationMessageContext; readonly attachments: ReadonlyArray; readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; /** Captured at enqueue because draining may occur long after the tap. */ readonly dispatchMode?: ComposerDispatchMode; readonly creation?: QueuedThreadCreation; readonly createdAt: string; } export interface ThreadSettingsSnapshot { readonly modelSelection: ModelSelectionType; readonly runtimeMode: RuntimeModeType; readonly interactionMode: ProviderInteractionModeType; } export function resolveQueuedThreadSettings( message: QueuedThreadMessage, thread: ThreadSettingsSnapshot, providers: ReadonlyArray> = [], ): ThreadSettingsSnapshot { const modelSelection = message.modelSelection ?? thread.modelSelection; const provider = providers.find( (candidate) => candidate.instanceId === modelSelection.instanceId, ); return { modelSelection, runtimeMode: message.runtimeMode ?? thread.runtimeMode, interactionMode: resolveProviderInteractionMode( provider, message.interactionMode ?? thread.interactionMode, ), }; } export function modelSelectionsEqual(left: ModelSelectionType, right: ModelSelectionType): boolean { return ( left.instanceId === right.instanceId && left.model === right.model && JSON.stringify(left.options ?? null) === JSON.stringify(right.options ?? null) ); } export function encodeQueuedThreadMessage(message: QueuedThreadMessage): unknown { return encodeStoredQueuedThreadMessage({ schemaVersion: THREAD_OUTBOX_SCHEMA_VERSION, ...message, ...(message.modelSelection === undefined ? {} : { modelPicked: true as const }), }); } export function decodeQueuedThreadMessage(value: unknown): QueuedThreadMessage { const { schemaVersion: _, modelPicked, ...message } = decodeStoredQueuedThreadMessage(value); if (message.creation !== undefined || modelPicked === true) return message; // Possibly a stale cached model rather than a pick: let the server use the thread's own. const { modelSelection: _unconfirmed, ...followUp } = message; return followUp; } export function groupQueuedThreadMessages( messages: ReadonlyArray, ): Record> { const deduplicated = new Map(); for (const message of messages) { deduplicated.set(message.messageId, message); } const grouped: Record> = {}; for (const message of deduplicated.values()) { const threadKey = scopedThreadKey(message.environmentId, message.threadId); (grouped[threadKey] ??= []).push(message); } for (const queue of Object.values(grouped)) { queue.sort((left, right) => left.createdAt.localeCompare(right.createdAt)); } return grouped; } export function flattenQueuedThreadMessages( queues: Record>, ): ReadonlyArray { return Object.values(queues).flat(); } export function threadOutboxRetryDelayMs(attempt: number): number { return Math.min(1_000 * 2 ** Math.max(0, attempt - 1), THREAD_OUTBOX_MAX_RETRY_DELAY_MS); } export type ThreadOutboxDeliveryAction = "wait" | "remove" | "send"; export function resolveThreadOutboxDeliveryAction(input: { readonly isCreation: boolean; readonly threadExists: boolean; readonly shellStatus: EnvironmentShellStatus; readonly environmentConnected: boolean; /** * Set when the shell can leave idle subagent children out: absence alone is not deletion there, * only a settled family read without the row is. */ readonly familyRead?: ThreadFamilyRead; }): ThreadOutboxDeliveryAction { if (input.isCreation) { if (input.threadExists) { return "remove"; } // Wait for live shells; an unsynchronized list can hide delivered creations and cause duplicate re-issuance. return input.environmentConnected && input.shellStatus === "live" ? "send" : "wait"; } if (!input.threadExists) { return input.shellStatus === "live" && (input.familyRead === undefined || input.familyRead === "settled") ? "remove" : "wait"; } return input.environmentConnected ? "send" : "wait"; } export type ThreadOutboxDispatchStep = | { readonly step: "wait" } | { readonly step: "remove" } | { readonly step: "retry" } | { readonly step: "restore"; readonly reason: string } | { readonly step: "send" }; export function resolveThreadOutboxDispatchStep(input: { readonly deliveryAction: ThreadOutboxDeliveryAction; readonly fileAttachments: ReadonlyArray<{ readonly name: string; readonly sizeBytes: number }>; readonly serverConfig: { readonly maxFileUploadBytes: number | undefined } | null; }): ThreadOutboxDispatchStep { if (input.deliveryAction !== "send") { return { step: input.deliveryAction }; } if (input.serverConfig === null) { return { step: "retry" }; } if (input.fileAttachments.length === 0) { return { step: "send" }; } const maxBytes = input.serverConfig.maxFileUploadBytes; if (maxBytes === undefined) { return { step: "restore", reason: "This server does not support file attachments." }; } const effectiveMaxBytes = clampFileAttachmentUploadBytes(maxBytes); const oversized = input.fileAttachments.find( (attachment) => attachment.sizeBytes > effectiveMaxBytes, ); return oversized ? { step: "restore", reason: fileAttachmentTooLargeMessage(oversized.name, effectiveMaxBytes) } : { step: "send" }; } export function isQueuedThreadCreationSendable(message: QueuedThreadMessage): boolean { if (!message.creation) { return false; } const hasContent = message.text.trim().length > 0 || message.attachments.length > 0; if (!hasContent || message.modelSelection === undefined) { return false; } return message.creation.workspaceMode !== "worktree" || Boolean(message.creation.branch); } function errorMessage(error: unknown): string | null { if (error instanceof Error) { return error.message; } if (typeof error === "object" && error !== null && "message" in error) { return typeof error.message === "string" ? error.message : null; } return typeof error === "string" ? error : null; } /** Dispatch/auth rejections invalidate payloads; other typed failures may be disconnected transports and must retry. */ export function shouldRetryThreadOutboxDelivery(error: unknown): boolean { if (typeof error === "object" && error !== null && "_tag" in error) { switch (error._tag) { case "OrchestrationDispatchCommandError": case "EnvironmentAuthorizationError": return false; case "ConnectionTransientError": case "RpcClientError": case "EnvironmentRpcUnavailableError": case "EnvironmentNotRegisteredError": return true; default: break; } } return isTransportConnectionErrorMessage(errorMessage(error)); } export type ThreadOutboxCommandStage = "settings-sync" | "start-turn"; export type ThreadOutboxFailureAction = "retry" | "restore"; export function resolveThreadOutboxFailureAction(input: { readonly stage: ThreadOutboxCommandStage; readonly error: unknown; readonly interrupted: boolean; }): ThreadOutboxFailureAction { if ( input.stage === "settings-sync" || input.interrupted || shouldRetryThreadOutboxDelivery(input.error) ) { return "retry"; } return "restore"; } export type AcknowledgedThreadMessage = QueuedThreadMessage & { /** The server sequence that committed the message; older servers do not report it. */ readonly sequence?: number; };