diff --git a/apps/mobile/src/features/threads/ThreadComposer.tsx b/apps/mobile/src/features/threads/ThreadComposer.tsx index 087a96ea424f..bcb8f57e277e 100644 --- a/apps/mobile/src/features/threads/ThreadComposer.tsx +++ b/apps/mobile/src/features/threads/ThreadComposer.tsx @@ -6,6 +6,7 @@ import type { ProviderInteractionMode, RuntimeMode, ServerConfig as T3ServerConfig, + ThreadTurnDeliveryMode, } from "@t3tools/contracts"; import { detectComposerTrigger, @@ -114,7 +115,7 @@ export interface ThreadComposerProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: (deliveryMode?: ThreadTurnDeliveryMode) => Promise; readonly onUpdateModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateRuntimeMode: (runtimeMode: RuntimeMode) => void; readonly onUpdateInteractionMode: (interactionMode: ProviderInteractionMode) => void; @@ -332,7 +333,9 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer props.selectedThread.session?.status === "starting"; const sendLabel = - props.connectionState !== "connected" || props.queueCount > 0 ? "Queue" : "Send"; + showStopAction || props.connectionState !== "connected" || props.queueCount > 0 + ? "Queue" + : "Send"; const currentModelSelection = props.selectedThread.modelSelection; const currentRuntimeMode = props.selectedThread.runtimeMode; const connectionStatus = composerConnectionStatus({ @@ -536,31 +539,39 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer // ── Handle command selection ────────────────────────────── const { onChangeDraftMessage, onUpdateInteractionMode, draftMessage, onSendMessage } = props; - const handleSend = useCallback(async () => { - const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); - if (inFlightThreadIdsRef.current.has(threadKey)) return; - inFlightThreadIdsRef.current.add(threadKey); - try { - await onSendMessage(); - // Sending a prompt starts agent work: arm the lock-screen card while the - // app is foregrounded and the activity token can be registered. Armed - // after the send so its preference read and native Activity start don't - // contend with the queued-message feedback on the tap frame. - armAgentAwarenessLiveActivityForLocalWork({ - environmentId: props.environmentId, - threadTitle: props.selectedThread.title, - projectTitle: props.environmentLabel ?? "T3 Code", - }); - } finally { - inFlightThreadIdsRef.current.delete(threadKey); - } - }, [ - onSendMessage, - props.environmentId, - props.environmentLabel, - props.selectedThread.id, - props.selectedThread.title, - ]); + const sendWithDeliveryMode = useCallback( + async (deliveryMode?: ThreadTurnDeliveryMode) => { + const threadKey = scopedThreadKey(props.environmentId, props.selectedThread.id); + if (inFlightThreadIdsRef.current.has(threadKey)) return; + inFlightThreadIdsRef.current.add(threadKey); + try { + await onSendMessage(deliveryMode); + // Sending a prompt starts agent work: arm the lock-screen card while the + // app is foregrounded and the activity token can be registered. Armed + // after the send so native Activity setup does not contend with the + // queued-message feedback on the tap frame. + armAgentAwarenessLiveActivityForLocalWork({ + environmentId: props.environmentId, + threadTitle: props.selectedThread.title, + projectTitle: props.environmentLabel ?? "T3 Code", + }); + } finally { + inFlightThreadIdsRef.current.delete(threadKey); + } + }, + [ + onSendMessage, + props.environmentId, + props.environmentLabel, + props.selectedThread.id, + props.selectedThread.title, + ], + ); + const handleSend = useCallback(async () => sendWithDeliveryMode(), [sendWithDeliveryMode]); + const handleSteer = useCallback( + async () => sendWithDeliveryMode("immediate"), + [sendWithDeliveryMode], + ); const handleCommandSelect = useCallback( (item: ComposerCommandItem) => { if (!composerTrigger) return; @@ -838,7 +849,17 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer {!isExpanded ? ( {showStopAction ? ( - + + + {hasContent ? ( + + ) : null} + ) : ( ) : null} - + {showStopAction ? ( + + + + + ) : ( + + )} ) : null} diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index e234838394ba..c0f1c5b71cde 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -14,6 +14,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, + ThreadTurnDeliveryMode, UserInputQuestion, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; @@ -115,7 +116,8 @@ export interface ThreadDetailScreenProps { readonly onNativePasteImages: (uris: ReadonlyArray) => Promise; readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; - readonly onSendMessage: () => Promise; + readonly onSendMessage: (deliveryMode?: ThreadTurnDeliveryMode) => Promise; + readonly onCancelQueuedMessage: (messageId: MessageId) => void; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -515,17 +517,20 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread selectedThreadKey, ]); - const handleSendMessage = useCallback(async () => { - const targetThreadKey = selectedThreadKey; - const messageId = await props.onSendMessage(); - if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { - return messageId; - } + const handleSendMessage = useCallback( + async (deliveryMode?: ThreadTurnDeliveryMode) => { + const targetThreadKey = selectedThreadKey; + const messageId = await props.onSendMessage(deliveryMode); + if (messageId === null || selectedThreadKeyRef.current !== targetThreadKey) { + return messageId; + } - setAnchorMessageId(messageId); - composerEditorRef.current?.blur(); - return messageId; - }, [props.onSendMessage, selectedThreadKey]); + setAnchorMessageId(messageId); + composerEditorRef.current?.blur(); + return messageId; + }, + [props.onSendMessage, selectedThreadKey], + ); const collapseComposer = useCallback(() => { composerEditorRef.current?.blur(); @@ -605,6 +610,7 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread onEndFollowEnabledChange={setEndFollowEnabled} skills={selectedProviderSkills} loadEarlier={props.loadEarlier ?? null} + onCancelQueuedMessage={props.onCancelQueuedMessage} /> ) : ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index f00736772766..685d0e33fa40 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -159,6 +159,7 @@ export interface ThreadFeedProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly onEndFollowEnabledChange?: (enabled: boolean) => void; + readonly onCancelQueuedMessage: (messageId: MessageId) => void; readonly skills?: ReadonlyArray; /** Non-null when older turns exist beyond the loaded window. */ readonly loadEarlier?: { @@ -805,6 +806,7 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly onCancelQueuedMessage: (messageId: MessageId) => void; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -918,6 +920,21 @@ function renderFeedEntry( })} + {message.deliveryState === "queued" ? ( + <> + Queued + props.onCancelQueuedMessage(message.id)} + > + + Cancel + + + + ) : null} {timestampLabel} @@ -1804,6 +1821,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + onCancelQueuedMessage: props.onCancelQueuedMessage, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1825,6 +1843,7 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, + props.onCancelQueuedMessage, onPressImage, onToggleTurnFold, onToggleWorkGroup, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index cad1cab8e602..ab66fe3b88e7 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -7,7 +7,7 @@ import { } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type MessageId, type ProjectScript } from "@t3tools/contracts"; import { requestOlderThreadTurns, threadHasOlderTurns, @@ -214,6 +214,10 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); + const cancelQueuedTurn = useAtomCommand( + threadEnvironment.cancelQueuedTurn, + "queued message cancellation", + ); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -497,6 +501,22 @@ function ThreadRouteContent( }); }, [interruptThreadTurn, selectedThread]); + const handleCancelQueuedMessage = useCallback( + (messageId: MessageId) => { + if (!selectedThread) { + return; + } + void cancelQueuedTurn({ + environmentId: selectedThread.environmentId, + input: { + threadId: selectedThread.id, + messageId, + }, + }); + }, + [cancelQueuedTurn, selectedThread], + ); + const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { terminalDebugLog("terminal-menu:open-existing", { @@ -799,6 +819,7 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} + onCancelQueuedMessage={handleCancelQueuedMessage} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} diff --git a/apps/mobile/src/lib/projectThreadStartTurn.ts b/apps/mobile/src/lib/projectThreadStartTurn.ts index 85523175a2f5..7cb208fea99c 100644 --- a/apps/mobile/src/lib/projectThreadStartTurn.ts +++ b/apps/mobile/src/lib/projectThreadStartTurn.ts @@ -6,6 +6,7 @@ import { type ProjectId, type ProviderInteractionMode, type RuntimeMode, + type ThreadTurnDeliveryMode, } from "@t3tools/contracts"; import { toUploadChatImageAttachments, type DraftComposerImageAttachment } from "./composerImages"; @@ -32,6 +33,7 @@ export interface ProjectThreadStartTurnSpec { readonly modelSelection: ModelSelection; readonly runtimeMode: RuntimeMode; readonly interactionMode: ProviderInteractionMode; + readonly deliveryMode?: ThreadTurnDeliveryMode; readonly workspaceMode: "local" | "worktree"; readonly branch: string | null; readonly worktreePath: string | null; @@ -61,6 +63,7 @@ export function buildProjectThreadStartTurnInput(spec: ProjectThreadStartTurnSpe titleSeed: title, runtimeMode: spec.runtimeMode, interactionMode: spec.interactionMode, + ...(spec.deliveryMode !== undefined ? { deliveryMode: spec.deliveryMode } : {}), bootstrap: { createThread: { projectId: spec.projectId, diff --git a/apps/mobile/src/lib/threadActivity.ts b/apps/mobile/src/lib/threadActivity.ts index fbcb2e1c7e2a..2ac0510d1bd9 100644 --- a/apps/mobile/src/lib/threadActivity.ts +++ b/apps/mobile/src/lib/threadActivity.ts @@ -629,6 +629,7 @@ function workEntryIcon(entry: DerivedWorkLogEntry): ThreadFeedActivity["icon"] { return "message"; } if (entry.activityKind === "runtime.warning") return "warning"; + if (entry.activityKind === "provider.turn.steer.rejected") return "warning"; if (entry.requestKind === "command") return "command"; if (entry.requestKind === "file-read") return "eye"; if (entry.requestKind === "file-change") return "edit"; diff --git a/apps/mobile/src/state/thread-outbox-model.ts b/apps/mobile/src/state/thread-outbox-model.ts index eede506976a7..6abdfc29985e 100644 --- a/apps/mobile/src/state/thread-outbox-model.ts +++ b/apps/mobile/src/state/thread-outbox-model.ts @@ -9,11 +9,13 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadTurnDeliveryMode, ThreadId, type ModelSelection as ModelSelectionType, type ProjectId as ProjectIdType, type ProviderInteractionMode as ProviderInteractionModeType, type RuntimeMode as RuntimeModeType, + type ThreadTurnDeliveryMode as ThreadTurnDeliveryModeType, } from "@t3tools/contracts"; import * as Schema from "effect/Schema"; @@ -21,7 +23,7 @@ import { DraftComposerImageAttachmentSchema } from "../lib/composer-image-schema import type { DraftComposerImageAttachment } from "../lib/composerImages"; import { scopedThreadKey } from "../lib/scopedEntities"; -const THREAD_OUTBOX_SCHEMA_VERSION = 3; +const THREAD_OUTBOX_SCHEMA_VERSION = 4; const THREAD_OUTBOX_MAX_RETRY_DELAY_MS = 16_000; const QueuedThreadCreationSchema = Schema.Struct({ @@ -37,7 +39,7 @@ const QueuedThreadCreationSchema = Schema.Struct({ }); export const QueuedThreadMessageSchema = Schema.Struct({ - schemaVersion: Schema.Literals([1, 2, THREAD_OUTBOX_SCHEMA_VERSION]), + schemaVersion: Schema.Literals([1, 2, 3, THREAD_OUTBOX_SCHEMA_VERSION]), environmentId: EnvironmentId, threadId: ThreadId, messageId: MessageId, @@ -47,6 +49,7 @@ export const QueuedThreadMessageSchema = Schema.Struct({ modelSelection: Schema.optional(ModelSelection), runtimeMode: Schema.optional(RuntimeMode), interactionMode: Schema.optional(ProviderInteractionMode), + deliveryMode: Schema.optional(ThreadTurnDeliveryMode), // Present when the queued item creates a brand-new thread (pending task) // instead of appending a turn to an existing one. creation: Schema.optional(QueuedThreadCreationSchema), @@ -76,6 +79,7 @@ export interface QueuedThreadMessage { readonly modelSelection?: ModelSelectionType; readonly runtimeMode?: RuntimeModeType; readonly interactionMode?: ProviderInteractionModeType; + readonly deliveryMode?: ThreadTurnDeliveryModeType; readonly creation?: QueuedThreadCreation; readonly createdAt: string; } diff --git a/apps/mobile/src/state/thread-outbox.test.ts b/apps/mobile/src/state/thread-outbox.test.ts index b12ad2dc5843..b656b0f362d8 100644 --- a/apps/mobile/src/state/thread-outbox.test.ts +++ b/apps/mobile/src/state/thread-outbox.test.ts @@ -92,6 +92,7 @@ describe("thread outbox", () => { }, runtimeMode: "approval-required", interactionMode: "plan", + deliveryMode: "after-current", } satisfies QueuedThreadMessage; expect(decodeQueuedThreadMessage(encodeQueuedThreadMessage(selectedMessage))).toEqual( diff --git a/apps/mobile/src/state/use-thread-composer-state.ts b/apps/mobile/src/state/use-thread-composer-state.ts index 721c82a0e38e..83b6233d60c7 100644 --- a/apps/mobile/src/state/use-thread-composer-state.ts +++ b/apps/mobile/src/state/use-thread-composer-state.ts @@ -9,9 +9,11 @@ import { type ProviderInteractionMode, type RuntimeMode, type ThreadId, + type ThreadTurnDeliveryMode, } from "@t3tools/contracts"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; import { deriveActiveWorkStartedAt } from "@t3tools/shared/orchestrationTiming"; +import { resolveComposerDeliveryMode } from "@t3tools/shared/threadTurnDelivery"; import { makeQueuedMessageMetadata } from "../lib/commandMetadata"; import { @@ -129,53 +131,62 @@ export function useThreadComposerState() { ); }, [selectedThreadDetail, selectedThreadSessionActivity, selectedThreadShell]); - const onSendMessage = useCallback(async () => { - if (!selectedThreadShell) { - return null; - } + const onSendMessage = useCallback( + async (deliveryMode?: ThreadTurnDeliveryMode) => { + if (!selectedThreadShell) { + return null; + } - const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); - const draft = getComposerDraftSnapshot(threadKey); - const thread = selectedThreadDetail ?? selectedThreadShell; - const text = draft.text.trim(); - const attachments = draft.attachments; - if (text.length === 0 && attachments.length === 0) { - return null; - } + const threadKey = scopedThreadKey(selectedThreadShell.environmentId, selectedThreadShell.id); + const draft = getComposerDraftSnapshot(threadKey); + const thread = selectedThreadDetail ?? selectedThreadShell; + const hasActiveTurn = + thread.session?.status === "running" || thread.session?.status === "starting"; + const text = draft.text.trim(); + const attachments = draft.attachments; + if (text.length === 0 && attachments.length === 0) { + return null; + } - const metadata = makeQueuedMessageMetadata(); - const messageId = MessageId.make(metadata.messageId); - // Enqueue publishes the queued atom synchronously (the durable write - // happens behind it), so clearing the draft here gives send feedback on - // the tap frame instead of after file I/O. If the write fails the message - // is rolled out of the queue and the content is merged back into the - // draft, preserving anything typed since. - const enqueuePromise = enqueueThreadOutboxMessage({ - environmentId: selectedThreadShell.environmentId, - threadId: selectedThreadShell.id, - messageId, - commandId: CommandId.make(metadata.commandId), - text, - attachments, - modelSelection: draft.modelSelection ?? thread.modelSelection, - runtimeMode: draft.runtimeMode ?? thread.runtimeMode, - interactionMode: draft.interactionMode ?? thread.interactionMode, - createdAt: metadata.createdAt, - }); - clearComposerDraftContent(threadKey); - enqueuePromise.catch((error: unknown) => { - // Restore text via merge (idempotent) but attachments via the uncapped - // append: the merge path slots existing attachments first and truncates - // at the send limit, which would silently drop this message's images if - // the user attached new ones while the write was in flight. - void mergeComposerDraftContent(threadKey, { text, attachments: [] }); - appendComposerDraftAttachments(threadKey, attachments); - setPendingConnectionError( - error instanceof Error ? error.message : "Failed to save the queued message.", - ); - }); - return messageId; - }, [selectedThreadDetail, selectedThreadShell]); + const metadata = makeQueuedMessageMetadata(); + const messageId = MessageId.make(metadata.messageId); + // Enqueue publishes the queued atom synchronously (the durable write + // happens behind it), so clearing the draft here gives send feedback on + // the tap frame instead of after file I/O. If the write fails the message + // is rolled out of the queue and the content is merged back into the + // draft, preserving anything typed since. + const enqueuePromise = enqueueThreadOutboxMessage({ + environmentId: selectedThreadShell.environmentId, + threadId: selectedThreadShell.id, + messageId, + commandId: CommandId.make(metadata.commandId), + text, + attachments, + modelSelection: draft.modelSelection ?? thread.modelSelection, + runtimeMode: draft.runtimeMode ?? thread.runtimeMode, + interactionMode: draft.interactionMode ?? thread.interactionMode, + deliveryMode: resolveComposerDeliveryMode({ + hasActiveTurn, + ...(deliveryMode ? { requested: deliveryMode } : {}), + }), + createdAt: metadata.createdAt, + }); + clearComposerDraftContent(threadKey); + enqueuePromise.catch((error: unknown) => { + // Restore text via merge (idempotent) but attachments via the uncapped + // append: the merge path slots existing attachments first and truncates + // at the send limit, which would silently drop this message's images if + // the user attached new ones while the write was in flight. + void mergeComposerDraftContent(threadKey, { text, attachments: [] }); + appendComposerDraftAttachments(threadKey, attachments); + setPendingConnectionError( + error instanceof Error ? error.message : "Failed to save the queued message.", + ); + }); + return messageId; + }, + [selectedThreadDetail, selectedThreadShell], + ); const onChangeDraftMessage = useCallback( (value: string) => { diff --git a/apps/mobile/src/state/use-thread-outbox-drain.ts b/apps/mobile/src/state/use-thread-outbox-drain.ts index 68c973ff97e3..dcb9249f56d6 100644 --- a/apps/mobile/src/state/use-thread-outbox-drain.ts +++ b/apps/mobile/src/state/use-thread-outbox-drain.ts @@ -231,6 +231,7 @@ export function useThreadOutboxDrain(): void { modelSelection: settings.modelSelection, runtimeMode: settings.runtimeMode, interactionMode: settings.interactionMode, + deliveryMode: queuedMessage.deliveryMode, createdAt: queuedMessage.createdAt, }, }); @@ -270,6 +271,7 @@ export function useThreadOutboxDrain(): void { modelSelection, runtimeMode: queuedMessage.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: queuedMessage.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + deliveryMode: queuedMessage.deliveryMode, workspaceMode: creation.workspaceMode, branch: creation.branch, worktreePath: creation.worktreePath, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 6b35f0d54e18..add667862e75 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -31,6 +31,7 @@ export const RPC_REQUIRED_SCOPES = { [ORCHESTRATION_WS_METHODS.subscribeThread]: AuthOrchestrationReadScope, [WS_METHODS.serverProbe]: AuthOrchestrationReadScope, [WS_METHODS.serverGetConfig]: AuthOrchestrationReadScope, + [WS_METHODS.serverListProviderSkills]: AuthOrchestrationReadScope, [WS_METHODS.serverRefreshProviders]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateProvider]: AuthOrchestrationOperateScope, [WS_METHODS.serverUpdateServer]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index e3b18d74a9a7..f84dd073555e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -389,6 +389,136 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta }, ); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-queue-revert-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("cancels durable queued messages when a thread is reverted", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const now = "2026-08-16T12:00:00.000Z"; + const threadId = ThreadId.make("thread-queue-revert"); + const messageId = MessageId.make("message-queue-revert"); + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("event-queue-revert-project"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-queue-revert"), + occurredAt: now, + commandId: CommandId.make("command-queue-revert-project"), + causationEventId: null, + correlationId: CorrelationId.make("command-queue-revert-project"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-queue-revert"), + title: "Queue revert", + workspaceRoot: "/tmp/queue-revert", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("event-queue-revert-thread"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("command-queue-revert-thread"), + causationEventId: null, + correlationId: CorrelationId.make("command-queue-revert-thread"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.make("project-queue-revert"), + title: "Queue revert", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.make("event-queue-revert-message"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("command-queue-revert-message"), + causationEventId: null, + correlationId: CorrelationId.make("command-queue-revert-message"), + metadata: {}, + payload: { + threadId, + messageId, + role: "user", + text: "Run this next", + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.turn-queued", + eventId: EventId.make("event-queue-revert-queued"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("command-queue-revert-queued"), + causationEventId: EventId.make("event-queue-revert-message"), + correlationId: CorrelationId.make("command-queue-revert-message"), + metadata: {}, + payload: { + threadId, + messageId, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.make("event-queue-revert-reverted"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.make("command-queue-revert-reverted"), + causationEventId: null, + correlationId: CorrelationId.make("command-queue-revert-reverted"), + metadata: {}, + payload: { threadId, turnCount: 0 }, + }); + + const messageRows = yield* sql<{ readonly messageId: string }>` + SELECT message_id AS "messageId" + FROM projection_thread_messages + WHERE thread_id = ${threadId} + `; + const queueRows = yield* sql<{ readonly messageId: string }>` + SELECT message_id AS "messageId" + FROM projection_thread_turn_queue + WHERE thread_id = ${threadId} + `; + assert.deepEqual(messageRows, []); + assert.deepEqual(queueRows, []); + }), + ); + }, +); + it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { it.effect( "passes explicit empty attachment arrays through the projection pipeline to clear attachments", diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e9a625dd91cf..dafb2c9d346b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -1,5 +1,6 @@ import { ApprovalRequestId, + CommandId, type ChatAttachment, type OrchestrationEvent, type OrchestrationSessionStatus, @@ -34,6 +35,7 @@ import { ProjectionTurnRepository, } from "../../persistence/Services/ProjectionTurns.ts"; import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; +import * as ProjectionQueuedTurns from "../../persistence/ProjectionQueuedTurns.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; import { ProjectionStateRepositoryLive } from "../../persistence/Layers/ProjectionState.ts"; @@ -63,6 +65,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threadActivities: "projection.thread-activities", threadSessions: "projection.thread-sessions", threadTurns: "projection.thread-turns", + queuedTurns: "projection.queued-turns", checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", } as const; @@ -284,7 +287,9 @@ function retainProjectionMessagesAfterRevert( } } - return messages.filter((message) => retainedMessageIds.has(message.messageId)); + return messages.filter( + (message) => message.deliveryState !== "queued" && retainedMessageIds.has(message.messageId), + ); } function retainProjectionActivitiesAfterRevert( @@ -479,6 +484,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; + const projectionQueuedTurnRepository = + yield* ProjectionQueuedTurns.ProjectionQueuedTurnRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; const fileSystem = yield* FileSystem.FileSystem; @@ -869,6 +876,21 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.queued-turn-cancelled": { + const existingRow = yield* projectionThreadRepository.getById({ + threadId: event.payload.threadId, + }); + if (Option.isNone(existingRow)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingRow.value, + updatedAt: event.occurredAt, + }); + yield* refreshThreadShellSummary(event.payload.threadId); + return; + } + case "thread.session-set": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -986,6 +1008,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti return; } + case "thread.turn-queued": { + yield* projectionThreadMessageRepository.setDeliveryState({ + messageId: event.payload.messageId, + deliveryState: "queued", + }); + return; + } + + case "thread.queued-turn-dispatched": { + yield* projectionThreadMessageRepository.setDeliveryState({ + messageId: event.payload.messageId, + deliveryState: null, + }); + return; + } + + case "thread.queued-turn-cancelled": { + yield* projectionThreadMessageRepository.deleteByMessageId({ + messageId: event.payload.messageId, + }); + const keptRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + attachmentSideEffects.prunedThreadRelativePaths.set( + event.payload.threadId, + collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows), + ); + return; + } + case "thread.reverted": { const existingRows = yield* projectionThreadMessageRepository.listByThreadId({ threadId: event.payload.threadId, @@ -1024,6 +1076,65 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const applyQueuedTurnsProjection: ProjectorDefinition["apply"] = Effect.fn( + "applyQueuedTurnsProjection", + )(function* (event, _attachmentSideEffects) { + switch (event.type) { + case "thread.turn-queued": + yield* projectionQueuedTurnRepository.upsert({ + messageId: event.payload.messageId, + threadId: event.payload.threadId, + eventId: event.eventId, + commandId: CommandId.make(`queued-turn:${event.eventId}`), + modelSelection: event.payload.modelSelection ?? null, + titleSeed: event.payload.titleSeed ?? null, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + sourceProposedPlanThreadId: event.payload.sourceProposedPlan?.threadId ?? null, + sourceProposedPlanId: event.payload.sourceProposedPlan?.planId ?? null, + queuedAt: event.payload.createdAt, + eventSequence: event.sequence, + status: "queued", + }); + return; + + case "thread.queued-turn-dispatched": + yield* projectionQueuedTurnRepository.markHandoff({ + messageId: event.payload.messageId, + }); + return; + + case "thread.queued-turn-cancelled": + yield* projectionQueuedTurnRepository.deleteByMessageId({ + messageId: event.payload.messageId, + }); + return; + + case "thread.session-set": + if ( + event.payload.session.status === "running" || + event.payload.session.status === "error" || + event.payload.session.status === "stopped" || + event.payload.session.status === "interrupted" + ) { + yield* projectionQueuedTurnRepository.deleteHandoffByThreadId({ + threadId: event.payload.threadId, + }); + } + return; + + case "thread.deleted": + case "thread.reverted": + yield* projectionQueuedTurnRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + return; + + default: + return; + } + }); + const applyThreadProposedPlansProjection: ProjectorDefinition["apply"] = Effect.fn( "applyThreadProposedPlansProjection", )(function* (event, _attachmentSideEffects) { @@ -1631,6 +1742,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, apply: applyThreadTurnsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.queuedTurns, + apply: applyQueuedTurnsProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.checkpoints, apply: applyCheckpointsProjection, @@ -1744,6 +1859,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), + Layer.provideMerge(ProjectionQueuedTurns.layer), Layer.provideMerge(ProjectionPendingApprovalRepositoryLive), Layer.provideMerge(ProjectionStateRepositoryLive), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 83ae3cfe049a..47e0c6464d3b 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -455,6 +455,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:07.000Z", }, latestUserMessageAt: "2026-02-24T00:00:04.000Z", + hasQueuedTurns: false, hasPendingApprovals: true, hasPendingUserInput: false, hasActionableProposedPlan: false, @@ -573,6 +574,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.queuedTurns}, 4, '2026-04-06T00:00:07.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') `; @@ -677,6 +679,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 4, '2026-04-06T00:00:07.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 4, '2026-04-06T00:00:07.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 4, '2026-04-06T00:00:07.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.queuedTurns}, 4, '2026-04-06T00:00:07.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 4, '2026-04-06T00:00:07.000Z') `; @@ -1426,6 +1429,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { (${ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans}, 3, '2026-04-03T00:00:40.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.threadActivities}, 3, '2026-04-03T00:00:40.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.threadSessions}, 3, '2026-04-03T00:00:40.000Z'), + (${ORCHESTRATION_PROJECTOR_NAMES.queuedTurns}, 3, '2026-04-03T00:00:40.000Z'), (${ORCHESTRATION_PROJECTOR_NAMES.checkpoints}, 3, '2026-04-03T00:00:40.000Z') `; @@ -2405,6 +2409,84 @@ projectionSnapshotLayer("ProjectionSnapshotQuery windowed thread detail", (it) = }), ); + it.effect("hydrates durable queued messages into command and shell reads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + const snapshotQuery = yield* ProjectionSnapshotQuery; + + yield* sql`DELETE FROM projection_thread_turn_queue`; + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_thread_sessions`; + yield* sql`DELETE FROM projection_thread_proposed_plans`; + yield* sql`DELETE FROM projection_turns`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_projects`; + yield* sql`DELETE FROM projection_state`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, title, workspace_root, scripts_json, created_at, updated_at, deleted_at + ) VALUES ( + 'project-queue-read', 'Queue read', '/tmp/queue-read', '[]', + '2026-08-16T12:00:00.000Z', '2026-08-16T12:00:00.000Z', NULL + ) + `; + yield* sql` + INSERT INTO projection_threads ( + thread_id, project_id, title, model_selection_json, runtime_mode, interaction_mode, + pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, + created_at, updated_at, deleted_at + ) VALUES ( + 'thread-queue-read', 'project-queue-read', 'Queue read', + '{"provider":"codex","model":"gpt-5-codex"}', 'full-access', 'default', + 0, 0, 0, '2026-08-16T12:00:00.000Z', '2026-08-16T12:01:00.000Z', NULL + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + message_id, thread_id, turn_id, role, text, is_streaming, + created_at, updated_at, delivery_state + ) VALUES + ('message-history', 'thread-queue-read', NULL, 'user', 'Already delivered', 0, + '2026-08-16T12:00:00.000Z', '2026-08-16T12:00:00.000Z', NULL), + ('message-queued', 'thread-queue-read', NULL, 'user', 'Run next', 0, + '2026-08-16T12:01:00.000Z', '2026-08-16T12:01:00.000Z', 'queued') + `; + yield* sql` + INSERT INTO projection_thread_turn_queue ( + message_id, thread_id, event_id, command_id, model_selection_json, title_seed, + runtime_mode, interaction_mode, source_proposed_plan_thread_id, + source_proposed_plan_id, queued_at, event_sequence, status + ) VALUES ( + 'message-queued', 'thread-queue-read', 'event-queue-read', 'command-queue-read', + NULL, NULL, 'full-access', 'default', NULL, NULL, + '2026-08-16T12:01:00.000Z', 1, 'queued' + ) + `; + for (const projector of Object.values(ORCHESTRATION_PROJECTOR_NAMES)) { + yield* sql` + INSERT INTO projection_state (projector, last_applied_sequence, updated_at) + VALUES ( + ${projector}, + ${projector === ORCHESTRATION_PROJECTOR_NAMES.queuedTurns ? 3 : 5}, + '2026-08-16T12:01:00.000Z' + ) + `; + } + + const commandRead = yield* snapshotQuery.getCommandReadModel(); + assert.deepEqual( + commandRead.threads[0]?.messages.map((message) => message.id), + [asMessageId("message-queued")], + ); + assert.equal(commandRead.threads[0]?.messages[0]?.deliveryState, "queued"); + + const shellRead = yield* snapshotQuery.getShellSnapshot(); + assert.equal(shellRead.snapshotSequence, 3); + assert.equal(shellRead.threads[0]?.hasQueuedTurns, true); + }), + ); + it.effect("a thread with no turns returns its content unwindowed on the first page", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c6c5ad1d7e8c..1eac11cafac7 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -79,18 +79,18 @@ const ProjectionProjectDbRowSchema = ProjectionProject.mapFields( scripts: Schema.fromJsonString(Schema.Array(ProjectScript)), }), ); -const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( - Struct.assign({ - isStreaming: Schema.Number, - attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), - }), -); +const ProjectionThreadMessageDbRowSchema = Schema.Struct({ + ...ProjectionThreadMessage.fields, + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + deliveryState: Schema.NullOr(Schema.Literal("queued")), +}); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; -const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( - Struct.assign({ - modelSelection: Schema.fromJsonString(ModelSelection), - }), -); +const ProjectionThreadDbRowSchema = Schema.Struct({ + ...ProjectionThread.fields, + modelSelection: Schema.fromJsonString(ModelSelection), + hasQueuedTurns: Schema.optional(Schema.Number), +}); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( Struct.assign({ payload: Schema.fromJsonString(Schema.Unknown), @@ -199,6 +199,7 @@ const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, ORCHESTRATION_PROJECTOR_NAMES.threadActivities, ORCHESTRATION_PROJECTOR_NAMES.threadSessions, + ORCHESTRATION_PROJECTOR_NAMES.queuedTurns, ORCHESTRATION_PROJECTOR_NAMES.checkpoints, ] as const; @@ -438,6 +439,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", + EXISTS ( + SELECT 1 + FROM projection_thread_turn_queue AS queued_turn + WHERE queued_turn.thread_id = projection_threads.thread_id + AND queued_turn.status = 'queued' + ) AS "hasQueuedTurns", deleted_at AS "deletedAt" FROM projection_threads ORDER BY created_at ASC, thread_id ASC @@ -474,6 +481,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", + EXISTS ( + SELECT 1 + FROM projection_thread_turn_queue AS queued_turn + WHERE queued_turn.thread_id = projection_threads.thread_id + AND queued_turn.status = 'queued' + ) AS "hasQueuedTurns", deleted_at AS "deletedAt" FROM projection_threads WHERE deleted_at IS NULL @@ -512,6 +525,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", + EXISTS ( + SELECT 1 + FROM projection_thread_turn_queue AS queued_turn + WHERE queued_turn.thread_id = projection_threads.thread_id + AND queued_turn.status = 'queued' + ) AS "hasQueuedTurns", deleted_at AS "deletedAt" FROM projection_threads WHERE deleted_at IS NULL @@ -533,9 +552,35 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + delivery_state AS "deliveryState", + created_at AS "createdAt", + updated_at AS "updatedAt" + FROM projection_thread_messages + ORDER BY thread_id ASC, created_at ASC, message_id ASC + `, + }); + + // Commands normally avoid hydrating message history. Durable queued turns + // are the exception: cancellation, dispatch, and settle invariants must see + // them even when the ordinary detail window no longer does. + const listQueuedThreadMessageRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadMessageDbRowSchema, + execute: () => + sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + turn_id AS "turnId", + role, + text, + attachments_json AS "attachments", + is_streaming AS "isStreaming", + delivery_state AS "deliveryState", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages + WHERE delivery_state = 'queued' ORDER BY thread_id ASC, created_at ASC, message_id ASC `, }); @@ -954,6 +999,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", + EXISTS ( + SELECT 1 + FROM projection_thread_turn_queue AS queued_turn + WHERE queued_turn.thread_id = projection_threads.thread_id + AND queued_turn.status = 'queued' + ) AS "hasQueuedTurns", deleted_at AS "deletedAt" FROM projection_threads WHERE thread_id = ${threadId} @@ -976,6 +1027,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + delivery_state AS "deliveryState", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -1219,6 +1271,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + delivery_state AS "deliveryState", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -1564,6 +1617,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { streaming: row.isStreaming === 1, createdAt: row.createdAt, updatedAt: row.updatedAt, + ...(row.deliveryState !== null ? { deliveryState: row.deliveryState } : {}), }); messagesByThread.set(row.threadId, threadMessages); } @@ -1755,6 +1809,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listQueuedThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listQueuedThreadMessages:query", + "ProjectionSnapshotQuery.getCommandReadModel:listQueuedThreadMessages:decodeRows", + ), + ), + ), listThreadProposedPlanRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1791,11 +1853,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + queuedMessageRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; + const queuedMessagesByThread = new Map>(); for (let index = 0; index < projectRows.length; index += 1) { const row = projectRows[index]; @@ -1830,6 +1901,26 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } updatedAt = maxIso(updatedAt, row.updatedAt); } + for (let index = 0; index < queuedMessageRows.length; index += 1) { + const row = queuedMessageRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + const messages = queuedMessagesByThread.get(row.threadId) ?? []; + messages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + deliveryState: "queued", + }); + queuedMessagesByThread.set(row.threadId, messages); + } for (let index = 0; index < sessionRows.length; index += 1) { const row = sessionRows[index]; if (!row) { @@ -1913,7 +2004,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pinOrderKey: row.pinOrderKey ?? null, titleRegeneration: mapTitleRegeneration(row), deletedAt: row.deletedAt, - messages: [], + messages: queuedMessagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: [], checkpoints: [], @@ -2050,6 +2141,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, + hasQueuedTurns: row.hasQueuedTurns === 1, hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, hasActionableProposedPlan: row.hasActionableProposedPlan > 0, @@ -2195,6 +2287,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { titleRegeneration: mapTitleRegeneration(row), session: sessionByThread.get(row.threadId) ?? null, latestUserMessageAt: row.latestUserMessageAt, + hasQueuedTurns: row.hasQueuedTurns === 1, hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, hasActionableProposedPlan: row.hasActionableProposedPlan > 0, @@ -2474,6 +2567,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { titleRegeneration: mapTitleRegeneration(threadRow.value), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, latestUserMessageAt: threadRow.value.latestUserMessageAt, + hasQueuedTurns: threadRow.value.hasQueuedTurns === 1, hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, @@ -2623,6 +2717,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { streaming: row.isStreaming === 1, createdAt: row.createdAt, updatedAt: row.updatedAt, + ...(row.deliveryState !== null ? { deliveryState: row.deliveryState } : {}), }; if (row.attachments !== null) { return Object.assign(message, { attachments: row.attachments }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 2b4d3771605a..5127e93d7aa5 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -150,6 +150,7 @@ describe("ProviderCommandReactor", () => { readonly requiresNewThreadForModelChange?: boolean; readonly titleRegenerationCompletionDispatchFailures?: number; readonly titleRegenerationBeforeStart?: "one" | "two"; + readonly queuedTurnHandoffBeforeStart?: boolean; readonly startSessionEffect?: ( session: ProviderSession, ) => Effect.Effect; @@ -415,6 +416,7 @@ describe("ProviderCommandReactor", () => { Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), + Layer.provide(SqlitePersistenceMemory), ); runtime = ManagedRuntime.make(layer); @@ -484,6 +486,39 @@ describe("ProviderCommandReactor", () => { }), ); } + if (input?.queuedTurnHandoffBeforeStart === true) { + const queuedAt = "2026-08-16T10:00:00.000Z"; + await Effect.runPromise( + engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-queued-before-reactor-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("message-queued-before-reactor-start"), + role: "user", + text: "Recover me", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + modelSelection, + deliveryMode: "after-current", + createdAt: queuedAt, + }), + ); + await Effect.runPromise( + engine.dispatch({ + type: "thread.queued-turn.dispatch", + commandId: CommandId.make("cmd-mark-queued-handoff-before-reactor-start"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("message-queued-before-reactor-start"), + runtimeMode: "approval-required", + interactionMode: "default", + queuedAt, + createdAt: "2026-08-16T10:00:01.000Z", + }), + ); + } scope = await Effect.runPromise(Scope.make("sequential")); await Effect.runPromise(reactor.start().pipe(Scope.provide(scope))); @@ -2382,6 +2417,164 @@ describe("ProviderCommandReactor", () => { }); }); + effectIt.effect("keeps the session healthy when the provider refuses to steer a turn", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + // A turn is running: this is the state a mid-turn send arrives in. + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-steering"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-running"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + // A turn that cannot absorb the message — a `/review`, or one whose + // settings the send asks to change. The provider declined; nothing is + // broken. + harness.sendTurn.mockImplementationOnce( + () => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "codex", + method: "turn/steer", + detail: "Codex is running a review turn, which does not accept new messages.", + }), + ) as unknown as ReturnType, + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-steer-refused"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-steer-refused"), + role: "user", + text: "one more thing", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some( + (activity) => activity.kind === "provider.turn.steer.rejected", + ) ?? false + ); + }), + ); + + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.steer.rejected"), + ).toMatchObject({ + tone: "info", + summary: "Message not sent", + payload: { detail: expect.stringContaining("does not accept new messages") }, + }); + // Reporting it as a turn-start failure would mark the session errored + // and null out its active turn, making the turn the provider is still + // working on vanish from the UI. + expect( + thread?.activities.some((activity) => activity.kind === "provider.turn.start.failed"), + ).toBe(false); + expect(thread?.session?.status).not.toBe("error"); + expect(thread?.session?.lastError ?? null).toBe(null); + }), + ); + + effectIt.effect("reports uncertain delivery when steer succeeds for an unexpected turn", () => + Effect.gen(function* () { + const harness = yield* Effect.promise(() => createHarness()); + const now = "2026-01-01T00:00:00.000Z"; + + yield* harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-session-set-steer-uncertain"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-running"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }); + + const uncertainError = Object.assign( + new ProviderAdapterRequestError({ + provider: "codex", + method: "turn/steer", + detail: "Codex steered turn turn-other instead of the expected active turn turn-running.", + }), + { reason: "turn-id-mismatch", delivery: "uncertain" as const }, + ); + harness.sendTurn.mockImplementationOnce( + () => Effect.fail(uncertainError) as unknown as ReturnType, + ); + + yield* harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-turn-start-steer-uncertain"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("user-message-steer-uncertain"), + role: "user", + text: "one more thing", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }); + + yield* Effect.promise(() => + waitFor(async () => { + const readModel = await harness.readModel(); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + return ( + thread?.activities.some( + (activity) => activity.kind === "provider.turn.steer.rejected", + ) ?? false + ); + }), + ); + + const readModel = yield* Effect.promise(() => harness.readModel()); + const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1")); + expect( + thread?.activities.find((activity) => activity.kind === "provider.turn.steer.rejected"), + ).toMatchObject({ + tone: "info", + summary: "Delivery uncertain", + payload: { detail: expect.stringContaining("unexpected active turn") }, + }); + expect(thread?.session?.status).not.toBe("error"); + }), + ); + it("rejects cross-driver provider changes after the existing thread session has stopped", async () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; @@ -2806,7 +2999,7 @@ describe("ProviderCommandReactor", () => { ), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.session.set", commandId: CommandId.make("cmd-session-set-for-user-input-error"), @@ -2824,7 +3017,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.activity.append", commandId: CommandId.make("cmd-user-input-requested"), @@ -2857,7 +3050,7 @@ describe("ProviderCommandReactor", () => { }), ); - await Effect.runPromise( + await harness.runEffect( harness.engine.dispatch({ type: "thread.user-input.respond", commandId: CommandId.make("cmd-user-input-respond-stale"), @@ -2943,4 +3136,180 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.providerInstanceId).toBe(ProviderInstanceId.make("codex_work")); expect(thread?.session?.activeTurnId).toBeNull(); }); + + it("holds after-current messages on the server until the active turn settles", async () => { + const harness = await createHarness(); + const now = "2026-08-16T12:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-queue-session-running"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("active-turn"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + await harness.drain(); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-queue-after-current"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("queued-message"), + role: "user", + text: "Run after the current turn", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + deliveryMode: "after-current", + createdAt: now, + }), + ); + await harness.drain(); + + expect(harness.sendTurn).not.toHaveBeenCalled(); + const queuedThread = (await harness.readModel()).threads[0]; + expect( + queuedThread?.messages.find((message) => message.id === "queued-message")?.deliveryState, + ).toBe("queued"); + + const settledAt = "2026-08-16T12:01:00.000Z"; + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-queue-session-ready"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: settledAt, + }, + createdAt: settledAt, + }), + ); + await harness.drain(); + + expect(harness.sendTurn).toHaveBeenCalledTimes(1); + const deliveredThread = (await harness.readModel()).threads[0]; + expect( + deliveredThread?.messages.find((message) => message.id === "queued-message")?.deliveryState, + ).toBeUndefined(); + }); + + it("does not replay an ambiguous provider handoff after reactor startup", async () => { + const harness = await createHarness({ queuedTurnHandoffBeforeStart: true }); + await harness.drain(); + + expect(harness.sendTurn).not.toHaveBeenCalled(); + const thread = (await harness.readModel()).threads[0]; + expect(thread?.session?.status).toBe("error"); + expect(thread?.session?.lastError).toContain("may already have received it"); + expect( + thread?.activities.some( + (activity) => + activity.kind === "provider.turn.start.failed" && + activity.summary === "Queued turn handoff interrupted", + ), + ).toBe(true); + }); + + it("does not dispatch a queued message after it is cancelled", async () => { + const harness = await createHarness(); + const now = "2026-08-16T13:00:00.000Z"; + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-cancel-queue-session-running"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: asTurnId("active-turn"), + lastError: null, + updatedAt: now, + }, + createdAt: now, + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-cancel-queue-start"), + threadId: ThreadId.make("thread-1"), + message: { + messageId: asMessageId("cancelled-queued-message"), + role: "user", + text: "Do not run this", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: "default", + deliveryMode: "after-current", + createdAt: now, + }), + ); + await harness.drain(); + + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.queued-turn.cancel", + commandId: CommandId.make("cmd-cancel-queued-message"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("cancelled-queued-message"), + createdAt: "2026-08-16T13:00:01.000Z", + }), + ); + await harness.runEffect( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.make("cmd-cancel-queue-session-ready"), + threadId: ThreadId.make("thread-1"), + session: { + threadId: ThreadId.make("thread-1"), + status: "ready", + providerName: "codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: "approval-required", + activeTurnId: null, + lastError: null, + updatedAt: "2026-08-16T13:01:00.000Z", + }, + createdAt: "2026-08-16T13:01:00.000Z", + }), + ); + await harness.drain(); + + expect(harness.sendTurn).not.toHaveBeenCalled(); + expect( + (await harness.readModel()).threads[0]?.messages.some( + (message) => message.id === "cancelled-queued-message", + ), + ).toBe(false); + }); }); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cfc95f2613fb..36de8f768774 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -4,6 +4,7 @@ import { EventId, type ModelSelection, type OrchestrationEvent, + type OrchestrationThreadActivityTone, ProviderDriverKind, type ProjectId, type OrchestrationSession, @@ -16,6 +17,7 @@ import { isTemporaryWorktreeBranch, WORKTREE_BRANCH_PREFIX } from "@t3tools/shar import * as Cache from "effect/Cache"; import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Equal from "effect/Equal"; @@ -46,6 +48,7 @@ import { } from "../../serverSettings.ts"; import { VcsStatusBroadcaster } from "../../vcs/VcsStatusBroadcaster.ts"; import { GitWorkflowService } from "../../git/GitWorkflowService.ts"; +import * as ProjectionQueuedTurns from "../../persistence/ProjectionQueuedTurns.ts"; const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError); const isProviderDriverKind = Schema.is(ProviderDriverKind); @@ -55,11 +58,13 @@ type ProviderIntentEvent = Extract< type: | "thread.meta-updated" | "thread.runtime-mode-set" + | "thread.turn-queued" | "thread.turn-start-requested" | "thread.turn-interrupt-requested" | "thread.approval-response-requested" | "thread.user-input-response-requested" - | "thread.session-stop-requested"; + | "thread.session-stop-requested" + | "thread.session-set"; } >; @@ -308,6 +313,8 @@ const make = Effect.gen(function* () { const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; const textGeneration = yield* TextGeneration; const serverSettingsService = yield* ServerSettingsService; + const projectionQueuedTurnRepository = + yield* ProjectionQueuedTurns.ProjectionQueuedTurnRepository; const serverCommandId = (tag: string) => crypto.randomUUIDv4.pipe(Effect.map((uuid) => CommandId.make(`server:${tag}:${uuid}`))); const serverEventId = () => crypto.randomUUIDv4.pipe(Effect.map(EventId.make)); @@ -330,6 +337,7 @@ const make = Effect.gen(function* () { readonly threadId: ThreadId; readonly kind: | "provider.turn.start.failed" + | "provider.turn.steer.rejected" | "provider.turn.interrupt.failed" | "provider.approval.respond.failed" | "provider.user-input.respond.failed" @@ -339,6 +347,8 @@ const make = Effect.gen(function* () { readonly turnId: TurnId | null; readonly createdAt: string; readonly requestId?: string; + /** Defaults to "error"; declined-but-healthy outcomes pass "info". */ + readonly tone?: OrchestrationThreadActivityTone; }) => Effect.all({ commandId: serverCommandId("provider-failure-activity"), @@ -351,7 +361,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, activity: { id: eventId, - tone: "error", + tone: input.tone ?? "error", kind: input.kind, summary: input.summary, payload: { @@ -377,6 +387,23 @@ const make = Effect.gen(function* () { return Cause.pretty(cause); }; + /** + * A refused `turn/steer` is not a broken session. The provider declined to + * fold this message into the turn that is still running — most often + * because that turn is a `/review` or `/compact`, which the protocol + * documents as never accepting same-turn steering. Treating it like a + * turn-start failure would mark the session errored and null out + * `activeTurnId`, making the running turn vanish from the UI while it is + * still working. + */ + const readSteerRejection = (cause: Cause.Cause) => { + const failReason = cause.reasons.find(Cause.isFailReason); + return isProviderAdapterRequestError(failReason?.error) && + failReason.error.method === "turn/steer" + ? failReason.error + : undefined; + }; + const setThreadSession = (input: { readonly threadId: ThreadId; readonly session: OrchestrationSession; @@ -1084,7 +1111,7 @@ const make = Effect.gen(function* () { } const isFirstUserMessageTurn = - thread.messages.filter((entry) => entry.role === "user").length === 1; + thread.messages.find((entry) => entry.role === "user")?.id === event.payload.messageId; if (isFirstUserMessageTurn) { const project = yield* resolveProject(thread.projectId); const generationCwd = @@ -1119,6 +1146,25 @@ const make = Effect.gen(function* () { return Effect.void; } const detail = formatFailureDetail(cause); + // The provider declined to fold this message into the turn it is still + // running. Nothing was sent and nothing is broken: leave the session + // and its active turn alone, and tell the user their message did not + // go out so they can send it again once the turn finishes. + const steerRejection = readSteerRejection(cause); + if (steerRejection) { + const deliveryUncertain = steerRejection.delivery === "uncertain"; + return appendProviderFailureActivity({ + threadId: event.payload.threadId, + kind: "provider.turn.steer.rejected", + tone: "info", + summary: deliveryUncertain ? "Delivery uncertain" : "Message not sent", + detail: deliveryUncertain + ? `${detail} The message may have been delivered to an unexpected active turn; do not resend it automatically.` + : detail, + turnId: null, + createdAt: event.payload.createdAt, + }).pipe(Effect.asVoid); + } return setThreadSessionErrorOnTurnStartFailure({ threadId: event.payload.threadId, detail, @@ -1173,6 +1219,84 @@ const make = Effect.gen(function* () { .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); }); + const recoverAmbiguousQueuedTurnHandoff = Effect.fn("recoverAmbiguousQueuedTurnHandoff")( + function* (row: ProjectionQueuedTurns.ProjectionQueuedTurn) { + const thread = yield* resolveThread(row.threadId); + if (thread === undefined) { + return; + } + const createdAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const detail = + "T3 Code restarted while handing this queued message to the provider. It was not sent again because the provider may already have received it. Check the provider transcript, then resend if needed."; + yield* setThreadSession({ + threadId: thread.id, + session: { + ...(thread.session ?? { + threadId: thread.id, + providerName: null, + providerInstanceId: thread.modelSelection.instanceId, + runtimeMode: thread.runtimeMode, + }), + status: "error", + activeTurnId: null, + lastError: detail, + updatedAt: createdAt, + }, + createdAt, + }); + yield* appendProviderFailureActivity({ + threadId: thread.id, + kind: "provider.turn.start.failed", + summary: "Queued turn handoff interrupted", + detail, + turnId: null, + createdAt, + }); + }, + ); + + const tryDispatchNextQueuedTurn = Effect.fn("tryDispatchNextQueuedTurn")(function* ( + threadId: ThreadId, + ) { + const rows = yield* projectionQueuedTurnRepository.listByThreadId({ threadId }); + if (rows.some((row) => row.status === "handoff")) { + return; + } + const next = rows.find((row) => row.status === "queued"); + if (next === undefined) { + return; + } + const thread = yield* resolveThread(threadId); + if (thread === undefined) { + return; + } + const status = thread.session?.status; + if (status === "starting" || status === "running" || status === "error") { + return; + } + const createdAt = yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)); + yield* orchestrationEngine.dispatch({ + type: "thread.queued-turn.dispatch", + commandId: next.commandId, + threadId: next.threadId, + messageId: next.messageId, + ...(next.modelSelection !== null ? { modelSelection: next.modelSelection } : {}), + ...(next.titleSeed !== null ? { titleSeed: next.titleSeed } : {}), + runtimeMode: next.runtimeMode, + interactionMode: next.interactionMode, + ...(next.sourceProposedPlanThreadId !== null && next.sourceProposedPlanId !== null + ? { + sourceProposedPlan: { + threadId: next.sourceProposedPlanThreadId, + planId: next.sourceProposedPlanId, + }, + } + : {}), + queuedAt: next.queuedAt, + createdAt, + }); + }); + const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( event: Extract, ) { @@ -1343,6 +1467,9 @@ const make = Effect.gen(function* () { ); return; } + case "thread.turn-queued": + yield* tryDispatchNextQueuedTurn(event.payload.threadId); + return; case "thread.turn-start-requested": yield* processTurnStartRequested(event); return; @@ -1358,6 +1485,15 @@ const make = Effect.gen(function* () { case "thread.session-stop-requested": yield* processSessionStopRequested(event); return; + case "thread.session-set": + if ( + event.payload.session.status !== "starting" && + event.payload.session.status !== "running" && + event.payload.session.status !== "error" + ) { + yield* tryDispatchNextQueuedTurn(event.payload.threadId); + } + return; } }); @@ -1392,11 +1528,13 @@ const make = Effect.gen(function* () { if ( (event.type === "thread.meta-updated" && event.payload.regenerateTitle === true) || event.type === "thread.runtime-mode-set" || + event.type === "thread.turn-queued" || event.type === "thread.turn-start-requested" || event.type === "thread.turn-interrupt-requested" || event.type === "thread.approval-response-requested" || event.type === "thread.user-input-response-requested" || - event.type === "thread.session-stop-requested" + event.type === "thread.session-stop-requested" || + event.type === "thread.session-set" ) { return yield* worker.enqueue(event); } @@ -1404,9 +1542,36 @@ const make = Effect.gen(function* () { yield* forkParked(Stream.runForEach(orchestrationEngine.streamDomainEvents, processEvent)); - // The domain event stream is hot, so work pending before this reactor - // starts cannot be resumed. Correlated completions only clear the request - // captured here, leaving any newer request untouched. + yield* Effect.gen(function* () { + const persistedQueuedTurns = yield* projectionQueuedTurnRepository.listAll; + yield* Effect.forEach( + persistedQueuedTurns.filter((row) => row.status === "handoff"), + recoverAmbiguousQueuedTurnHandoff, + { concurrency: 1, discard: true }, + ); + yield* Effect.forEach( + new Set(persistedQueuedTurns.map((row) => row.threadId)), + tryDispatchNextQueuedTurn, + { concurrency: 1, discard: true }, + ); + }).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.interrupt; + } + return Effect.logWarning("provider command reactor failed to recover queued turns", { + cause: Cause.pretty(cause), + }); + }), + ); + + // Title regeneration still uses the hot event stream and cannot resume a + // pre-start request. Correlated completions only clear the request captured + // here, leaving any newer request untouched. Queued turns are recovered + // separately above from their durable projection. A pre-handoff queued + // row is safe to resume. A committed handoff is deliberately surfaced as + // ambiguous instead of replayed because provider CLIs do not share a + // durable idempotency key with orchestration. const clearInterrupted = clearInterruptedThreadTitleRegenerations( interruptedTitleRegenerations, ).pipe( @@ -1439,4 +1604,6 @@ const make = Effect.gen(function* () { } satisfies ProviderCommandReactorShape; }); -export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make); +export const ProviderCommandReactorLive = Layer.effect(ProviderCommandReactor, make).pipe( + Layer.provideMerge(ProjectionQueuedTurns.layer), +); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts index 449b1fbf5136..5abb555a847c 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts @@ -2707,7 +2707,7 @@ describe("ProviderRuntimeIngestion", () => { harness.readModel, (entry) => entry.session?.status === "error" && - entry.session?.activeTurnId === "turn-3" && + entry.session?.activeTurnId === null && entry.session?.lastError === "runtime exploded", ); expect(thread.session?.status).toBe("error"); @@ -3602,7 +3602,7 @@ describe("ProviderRuntimeIngestion", () => { harness.readModel, (entry) => entry.session?.status === "error" && - entry.session?.activeTurnId === "turn-after-failure" && + entry.session?.activeTurnId === null && entry.session?.lastError === "runtime still processed", ); expect(thread.session?.status).toBe("error"); diff --git a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts index e24825d3d1fc..2871d83f8cba 100644 --- a/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +++ b/apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts @@ -1891,7 +1891,7 @@ const make = Effect.gen(function* () { ? { providerInstanceId: event.providerInstanceId } : {}), runtimeMode: thread.session?.runtimeMode ?? "full-access", - activeTurnId: eventTurnId ?? null, + activeTurnId: null, lastError: runtimeErrorMessage, updatedAt: now, }, diff --git a/apps/server/src/orchestration/decider.queuedTurns.test.ts b/apps/server/src/orchestration/decider.queuedTurns.test.ts new file mode 100644 index 000000000000..560f62b73f1f --- /dev/null +++ b/apps/server/src/orchestration/decider.queuedTurns.test.ts @@ -0,0 +1,164 @@ +import { + CommandId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-08-16T12:00:00.000Z"; +const THREAD_ID = ThreadId.make("thread-queue-test"); +const MESSAGE_ID = MessageId.make("message-queue-test"); + +function makeReadModel(deliveryState?: "queued"): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: THREAD_ID, + projectId: ProjectId.make("project-queue-test"), + title: "Queue test", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + deletedAt: null, + messages: + deliveryState === undefined + ? [] + : [ + { + id: MESSAGE_ID, + role: "user", + text: "Run this next", + turnId: null, + streaming: false, + createdAt: NOW, + updatedAt: NOW, + deliveryState, + }, + ], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("queued turn decider", (it) => { + it.effect("persists after-current delivery instead of starting the provider immediately", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.turn.start", + commandId: CommandId.make("cmd-queue-turn"), + threadId: THREAD_ID, + message: { + messageId: MESSAGE_ID, + role: "user", + text: "Run this next", + attachments: [], + }, + runtimeMode: "full-access", + interactionMode: "default", + deliveryMode: "after-current", + createdAt: NOW, + }, + readModel: makeReadModel(), + }); + + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual([ + "thread.message-sent", + "thread.turn-queued", + ]); + }), + ); + + it.effect("dispatches a queued message into the normal provider start path", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.queued-turn.dispatch", + commandId: CommandId.make("cmd-dispatch-queued-turn"), + threadId: THREAD_ID, + messageId: MESSAGE_ID, + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + queuedAt: NOW, + createdAt: "2026-08-16T12:01:00.000Z", + }, + readModel: makeReadModel("queued"), + }); + + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual([ + "thread.queued-turn-dispatched", + "thread.turn-start-requested", + ]); + const start = events[1]; + expect(start?.type).toBe("thread.turn-start-requested"); + if (start?.type === "thread.turn-start-requested") { + expect(start.payload.messageId).toBe(MESSAGE_ID); + expect(start.payload.createdAt).toBe(NOW); + } + }), + ); + + it.effect("cancels only messages that are still queued", () => + Effect.gen(function* () { + const result = yield* decideOrchestrationCommand({ + command: { + type: "thread.queued-turn.cancel", + commandId: CommandId.make("cmd-cancel-queued-turn"), + threadId: THREAD_ID, + messageId: MESSAGE_ID, + createdAt: NOW, + }, + readModel: makeReadModel("queued"), + }); + const events = Array.isArray(result) ? result : [result]; + expect(events.map((event) => event.type)).toEqual(["thread.queued-turn-cancelled"]); + + const failure = yield* Effect.result( + decideOrchestrationCommand({ + command: { + type: "thread.queued-turn.cancel", + commandId: CommandId.make("cmd-cancel-delivered-turn"), + threadId: THREAD_ID, + messageId: MESSAGE_ID, + createdAt: NOW, + }, + readModel: makeReadModel(), + }), + ); + expect(failure._tag).toBe("Failure"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..a66fc8b9963a 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -88,9 +88,10 @@ function hasOpenBlockingRequest(thread: { /** * A queued turn start — a user message no turn has picked up yet — is work - * in flight even though session is still null (turn.start emits - * message-sent + turn-start-requested; the session arrives later). Detection - * mirrors the client's hasQueuedTurnStart: the newest user message is + * in flight even though session is still null. Durable after-current messages + * carry an explicit delivery state and remain work until dispatched or + * cancelled. For legacy immediate starts, detection mirrors the client's + * hasQueuedTurnStart: the newest user message is * strictly newer than every latestTurn timestamp (adoption stamps the new * turn's requestedAt with the message time, clearing this), and only within * the adoption grace window — historical threads whose last user message @@ -106,7 +107,11 @@ function hasOpenBlockingRequest(thread: { */ function threadHasQueuedTurnStart( thread: { - readonly messages: ReadonlyArray<{ readonly role: string; readonly createdAt: string }>; + readonly messages: ReadonlyArray<{ + readonly role: string; + readonly createdAt: string; + readonly deliveryState?: "queued" | undefined; + }>; readonly latestTurn: { readonly requestedAt: string; readonly startedAt: string | null; @@ -116,6 +121,9 @@ function threadHasQueuedTurnStart( }, occurredAt: string, ): boolean { + if (thread.messages.some((message) => message.deliveryState === "queued")) { + return true; + } const latestUserMessageAtMs = thread.messages.reduce( (latest, message) => message.role === "user" ? Math.max(latest, Date.parse(message.createdAt)) : latest, @@ -973,7 +981,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" updatedAt: command.createdAt, }, }; - const turnStartRequestedEvent: Omit = { + const turnIntentEvent: Omit = { ...(yield* withEventBase({ aggregateKind: "thread", aggregateId: command.threadId, @@ -981,7 +989,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" commandId: command.commandId, })), causationEventId: userMessageEvent.eventId, - type: "thread.turn-start-requested", + type: + command.deliveryMode === "after-current" + ? "thread.turn-queued" + : "thread.turn-start-requested", payload: { threadId: command.threadId, messageId: command.message.messageId, @@ -1033,7 +1044,90 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }, }); } - return [...lifecycleResetEvents, userMessageEvent, turnStartRequestedEvent]; + return [...lifecycleResetEvents, userMessageEvent, turnIntentEvent]; + } + + case "thread.queued-turn.cancel": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const message = thread.messages.find((entry) => entry.id === command.messageId); + if (message?.role !== "user" || message.deliveryState !== "queued") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued user message '${command.messageId}' does not exist on thread '${command.threadId}'.`, + }); + } + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.queued-turn-cancelled", + payload: { + threadId: command.threadId, + messageId: command.messageId, + cancelledAt: command.createdAt, + }, + }; + } + + case "thread.queued-turn.dispatch": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const message = thread.messages.find((entry) => entry.id === command.messageId); + if (message?.role !== "user" || message.deliveryState !== "queued") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued user message '${command.messageId}' is no longer waiting on thread '${command.threadId}'.`, + }); + } + const dispatchedEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.queued-turn-dispatched", + payload: { + threadId: command.threadId, + messageId: command.messageId, + dispatchedAt: command.createdAt, + }, + }; + const turnStartRequestedEvent: Omit = { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + causationEventId: dispatchedEvent.eventId, + type: "thread.turn-start-requested", + payload: { + threadId: command.threadId, + messageId: command.messageId, + ...(command.modelSelection !== undefined + ? { modelSelection: command.modelSelection } + : {}), + ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, + ...(command.sourceProposedPlan !== undefined + ? { sourceProposedPlan: command.sourceProposedPlan } + : {}), + createdAt: command.queuedAt, + }, + }; + return [dispatchedEvent, turnStartRequestedEvent]; } case "thread.turn.interrupt": { diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index f486dcb2bcbc..785fa4e777b3 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -147,7 +147,9 @@ function retainThreadMessagesAfterRevert( } } - return messages.filter((message) => retainedMessageIds.has(message.id)); + return messages.filter( + (message) => message.deliveryState !== "queued" && retainedMessageIds.has(message.id), + ); } function retainThreadActivitiesAfterRevert( @@ -549,6 +551,58 @@ export function projectEvent( }; }); + case "thread.turn-queued": { + const thread = nextBase.threads.find((entry) => entry.id === event.payload.threadId); + if (!thread) { + return Effect.succeed(nextBase); + } + return Effect.succeed({ + ...nextBase, + threads: updateThread(nextBase.threads, event.payload.threadId, { + messages: thread.messages.map((message) => + message.id === event.payload.messageId + ? { ...message, deliveryState: "queued" as const } + : message, + ), + updatedAt: event.occurredAt, + }), + }); + } + + case "thread.queued-turn-dispatched": { + const thread = nextBase.threads.find((entry) => entry.id === event.payload.threadId); + if (!thread) { + return Effect.succeed(nextBase); + } + return Effect.succeed({ + ...nextBase, + threads: updateThread(nextBase.threads, event.payload.threadId, { + messages: thread.messages.map((message) => { + if (message.id !== event.payload.messageId) { + return message; + } + const { deliveryState: _, ...deliveredMessage } = message; + return deliveredMessage; + }), + updatedAt: event.occurredAt, + }), + }); + } + + case "thread.queued-turn-cancelled": { + const thread = nextBase.threads.find((entry) => entry.id === event.payload.threadId); + if (!thread) { + return Effect.succeed(nextBase); + } + return Effect.succeed({ + ...nextBase, + threads: updateThread(nextBase.threads, event.payload.threadId, { + messages: thread.messages.filter((message) => message.id !== event.payload.messageId), + updatedAt: event.occurredAt, + }), + }); + } + case "thread.session-set": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 719191668869..29b5a4220561 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -4,7 +4,6 @@ import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; -import * as Struct from "effect/Struct"; import { ChatAttachment } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; @@ -15,14 +14,15 @@ import { DeleteProjectionThreadMessagesInput, ListProjectionThreadMessagesInput, ProjectionThreadMessage, + SetProjectionThreadMessageDeliveryStateInput, } from "../Services/ProjectionThreadMessages.ts"; -const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( - Struct.assign({ - isStreaming: Schema.Number, - attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), - }), -); +const ProjectionThreadMessageDbRowSchema = Schema.Struct({ + ...ProjectionThreadMessage.fields, + isStreaming: Schema.Number, + attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + deliveryState: Schema.NullOr(Schema.Literal("queued")), +}); function toProjectionThreadMessage( row: Schema.Schema.Type, @@ -37,6 +37,7 @@ function toProjectionThreadMessage( createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.deliveryState !== null ? { deliveryState: row.deliveryState } : {}), }; } @@ -57,6 +58,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json, is_streaming, + delivery_state, created_at, updated_at ) @@ -75,6 +77,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ) ), ${row.isStreaming ? 1 : 0}, + COALESCE( + ${row.deliveryState ?? null}, + ( + SELECT delivery_state + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), ${row.createdAt}, ${row.updatedAt} ) @@ -89,6 +99,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { projection_thread_messages.attachments_json ), is_streaming = excluded.is_streaming, + delivery_state = COALESCE( + excluded.delivery_state, + projection_thread_messages.delivery_state + ), created_at = excluded.created_at, updated_at = excluded.updated_at `; @@ -108,6 +122,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + delivery_state AS "deliveryState", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -129,6 +144,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + delivery_state AS "deliveryState", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -146,6 +162,23 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { `, }); + const setProjectionThreadMessageDeliveryState = SqlSchema.void({ + Request: SetProjectionThreadMessageDeliveryStateInput, + execute: ({ messageId, deliveryState }) => sql` + UPDATE projection_thread_messages + SET delivery_state = ${deliveryState} + WHERE message_id = ${messageId} + `, + }); + + const deleteProjectionThreadMessageRow = SqlSchema.void({ + Request: GetProjectionThreadMessageInput, + execute: ({ messageId }) => sql` + DELETE FROM projection_thread_messages + WHERE message_id = ${messageId} + `, + }); + const upsert: ProjectionThreadMessageRepositoryShape["upsert"] = (row) => upsertProjectionThreadMessageRow(row).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadMessageRepository.upsert:query")), @@ -167,6 +200,20 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { Effect.map((rows) => rows.map(toProjectionThreadMessage)), ); + const setDeliveryState: ProjectionThreadMessageRepositoryShape["setDeliveryState"] = (input) => + setProjectionThreadMessageDeliveryState(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.setDeliveryState:query"), + ), + ); + + const deleteByMessageId: ProjectionThreadMessageRepositoryShape["deleteByMessageId"] = (input) => + deleteProjectionThreadMessageRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadMessageRepository.deleteByMessageId:query"), + ), + ); + const deleteByThreadId: ProjectionThreadMessageRepositoryShape["deleteByThreadId"] = (input) => deleteProjectionThreadMessageRows(input).pipe( Effect.mapError( @@ -177,6 +224,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { return { upsert, getByMessageId, + setDeliveryState, + deleteByMessageId, listByThreadId, deleteByThreadId, } satisfies ProjectionThreadMessageRepositoryShape; diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index b137cedfbedd..3531be80c525 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -53,6 +53,7 @@ import Migration0037 from "./Migrations/037_ProjectionTurnsKeysetIndex.ts"; import Migration0038 from "./Migrations/038_ProjectionThreadsPinOrderKey.ts"; import Migration0039 from "./Migrations/039_ProjectionProjectsDefaultThreadEnvMode.ts"; import Migration0040 from "./Migrations/040_ProjectionProjectFaviconPath.ts"; +import Migration0041 from "./Migrations/041_ProjectionThreadTurnQueue.ts"; /** * Migration loader with all migrations defined inline. @@ -105,6 +106,7 @@ export const migrationEntries = [ [38, "ProjectionThreadsPinOrderKey", Migration0038], [39, "ProjectionProjectsDefaultThreadEnvMode", Migration0039], [40, "ProjectionProjectFaviconPath", Migration0040], + [41, "ProjectionThreadTurnQueue", Migration0041], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadTurnQueue.test.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadTurnQueue.test.ts new file mode 100644 index 000000000000..dee6cfadc0a0 --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadTurnQueue.test.ts @@ -0,0 +1,49 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("041_ProjectionThreadTurnQueue", (it) => { + it.effect("adds durable queued turns and message delivery state", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 40 }); + yield* runMigrations({ toMigrationInclusive: 41 }); + + const messageColumns = yield* sql<{ readonly name: string; readonly notnull: number }>` + PRAGMA table_info(projection_thread_messages) + `; + const deliveryState = messageColumns.find((column) => column.name === "delivery_state"); + assert.equal(deliveryState?.name, "delivery_state"); + assert.equal(deliveryState?.notnull, 0); + + const queueColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_turn_queue) + `; + assert.deepEqual( + queueColumns.map((column) => column.name), + [ + "message_id", + "thread_id", + "event_id", + "command_id", + "model_selection_json", + "title_seed", + "runtime_mode", + "interaction_mode", + "source_proposed_plan_thread_id", + "source_proposed_plan_id", + "queued_at", + "event_sequence", + "status", + ], + ); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/041_ProjectionThreadTurnQueue.ts b/apps/server/src/persistence/Migrations/041_ProjectionThreadTurnQueue.ts new file mode 100644 index 000000000000..5493c378718d --- /dev/null +++ b/apps/server/src/persistence/Migrations/041_ProjectionThreadTurnQueue.ts @@ -0,0 +1,34 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as Effect from "effect/Effect"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE projection_thread_turn_queue ( + message_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + event_id TEXT NOT NULL, + command_id TEXT NOT NULL, + model_selection_json TEXT, + title_seed TEXT, + runtime_mode TEXT NOT NULL, + interaction_mode TEXT NOT NULL, + source_proposed_plan_thread_id TEXT, + source_proposed_plan_id TEXT, + queued_at TEXT NOT NULL, + event_sequence INTEGER NOT NULL UNIQUE, + status TEXT NOT NULL CHECK (status IN ('queued', 'handoff')) + ) + `; + + yield* sql` + CREATE INDEX idx_projection_thread_turn_queue_thread_sequence + ON projection_thread_turn_queue(thread_id, event_sequence) + `; + + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN delivery_state TEXT CHECK (delivery_state IS NULL OR delivery_state = 'queued') + `; +}); diff --git a/apps/server/src/persistence/ProjectionQueuedTurns.ts b/apps/server/src/persistence/ProjectionQueuedTurns.ts new file mode 100644 index 000000000000..c4aa6f6caa58 --- /dev/null +++ b/apps/server/src/persistence/ProjectionQueuedTurns.ts @@ -0,0 +1,251 @@ +import { + CommandId, + EventId, + IsoDateTime, + MessageId, + ModelSelection, + NonNegativeInt, + OrchestrationProposedPlanId, + ProviderInteractionMode, + RuntimeMode, + ThreadId, + TrimmedNonEmptyString, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; + +import { toPersistenceDecodeError, toPersistenceSqlError } from "./Errors.ts"; +import type { ProjectionRepositoryError } from "./Errors.ts"; + +export const ProjectionQueuedTurnStatus = Schema.Literals(["queued", "handoff"]); + +export const ProjectionQueuedTurn = Schema.Struct({ + messageId: MessageId, + threadId: ThreadId, + eventId: EventId, + commandId: CommandId, + modelSelection: Schema.NullOr(ModelSelection), + titleSeed: Schema.NullOr(TrimmedNonEmptyString), + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + sourceProposedPlanThreadId: Schema.NullOr(ThreadId), + sourceProposedPlanId: Schema.NullOr(OrchestrationProposedPlanId), + queuedAt: IsoDateTime, + eventSequence: NonNegativeInt, + status: ProjectionQueuedTurnStatus, +}); +export type ProjectionQueuedTurn = typeof ProjectionQueuedTurn.Type; + +export const ProjectionQueuedTurnMessageInput = Schema.Struct({ + messageId: MessageId, +}); +export const ProjectionQueuedTurnThreadInput = Schema.Struct({ + threadId: ThreadId, +}); + +export class ProjectionQueuedTurnRepository extends Context.Service< + ProjectionQueuedTurnRepository, + { + readonly upsert: (row: ProjectionQueuedTurn) => Effect.Effect; + readonly markHandoff: ( + input: typeof ProjectionQueuedTurnMessageInput.Type, + ) => Effect.Effect; + readonly deleteByMessageId: ( + input: typeof ProjectionQueuedTurnMessageInput.Type, + ) => Effect.Effect; + readonly deleteHandoffByThreadId: ( + input: typeof ProjectionQueuedTurnThreadInput.Type, + ) => Effect.Effect; + readonly deleteByThreadId: ( + input: typeof ProjectionQueuedTurnThreadInput.Type, + ) => Effect.Effect; + readonly listByThreadId: ( + input: typeof ProjectionQueuedTurnThreadInput.Type, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly listAll: Effect.Effect, ProjectionRepositoryError>; + } +>()("t3/persistence/ProjectionQueuedTurns/ProjectionQueuedTurnRepository") {} + +const ProjectionQueuedTurnDbRow = Schema.Struct({ + ...ProjectionQueuedTurn.fields, + modelSelection: Schema.NullOr(Schema.fromJsonString(ModelSelection)), +}); + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const upsertRow = SqlSchema.void({ + Request: ProjectionQueuedTurn, + execute: (row) => sql` + INSERT INTO projection_thread_turn_queue ( + message_id, + thread_id, + event_id, + command_id, + model_selection_json, + title_seed, + runtime_mode, + interaction_mode, + source_proposed_plan_thread_id, + source_proposed_plan_id, + queued_at, + event_sequence, + status + ) VALUES ( + ${row.messageId}, + ${row.threadId}, + ${row.eventId}, + ${row.commandId}, + ${row.modelSelection === null ? null : JSON.stringify(row.modelSelection)}, + ${row.titleSeed}, + ${row.runtimeMode}, + ${row.interactionMode}, + ${row.sourceProposedPlanThreadId}, + ${row.sourceProposedPlanId}, + ${row.queuedAt}, + ${row.eventSequence}, + ${row.status} + ) + ON CONFLICT (message_id) DO UPDATE SET + thread_id = excluded.thread_id, + event_id = excluded.event_id, + command_id = excluded.command_id, + model_selection_json = excluded.model_selection_json, + title_seed = excluded.title_seed, + runtime_mode = excluded.runtime_mode, + interaction_mode = excluded.interaction_mode, + source_proposed_plan_thread_id = excluded.source_proposed_plan_thread_id, + source_proposed_plan_id = excluded.source_proposed_plan_id, + queued_at = excluded.queued_at, + event_sequence = excluded.event_sequence, + status = excluded.status + `, + }); + + const markHandoffRow = SqlSchema.void({ + Request: ProjectionQueuedTurnMessageInput, + execute: ({ messageId }) => sql` + UPDATE projection_thread_turn_queue + SET status = 'handoff' + WHERE message_id = ${messageId} + AND status = 'queued' + `, + }); + + const deleteMessageRow = SqlSchema.void({ + Request: ProjectionQueuedTurnMessageInput, + execute: ({ messageId }) => sql` + DELETE FROM projection_thread_turn_queue + WHERE message_id = ${messageId} + `, + }); + + const deleteHandoffThreadRows = SqlSchema.void({ + Request: ProjectionQueuedTurnThreadInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_turn_queue + WHERE thread_id = ${threadId} + AND status = 'handoff' + `, + }); + + const deleteThreadRows = SqlSchema.void({ + Request: ProjectionQueuedTurnThreadInput, + execute: ({ threadId }) => sql` + DELETE FROM projection_thread_turn_queue + WHERE thread_id = ${threadId} + `, + }); + + const listThreadRows = SqlSchema.findAll({ + Request: ProjectionQueuedTurnThreadInput, + Result: ProjectionQueuedTurnDbRow, + execute: ({ threadId }) => sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + event_id AS "eventId", + command_id AS "commandId", + model_selection_json AS "modelSelection", + title_seed AS "titleSeed", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + queued_at AS "queuedAt", + event_sequence AS "eventSequence", + status + FROM projection_thread_turn_queue + WHERE thread_id = ${threadId} + ORDER BY event_sequence ASC + `, + }); + + const listAllRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionQueuedTurnDbRow, + execute: () => sql` + SELECT + message_id AS "messageId", + thread_id AS "threadId", + event_id AS "eventId", + command_id AS "commandId", + model_selection_json AS "modelSelection", + title_seed AS "titleSeed", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + source_proposed_plan_thread_id AS "sourceProposedPlanThreadId", + source_proposed_plan_id AS "sourceProposedPlanId", + queued_at AS "queuedAt", + event_sequence AS "eventSequence", + status + FROM projection_thread_turn_queue + ORDER BY event_sequence ASC + `, + }); + + const mapError = (operation: string) => + Effect.mapError((cause: unknown) => + Schema.isSchemaError(cause) + ? toPersistenceDecodeError(`${operation}:decode`)(cause) + : toPersistenceSqlError(operation)(cause), + ); + const upsert: ProjectionQueuedTurnRepository["Service"]["upsert"] = (row) => + upsertRow(row).pipe(mapError("ProjectionQueuedTurnRepository.upsert:query")); + const markHandoff: ProjectionQueuedTurnRepository["Service"]["markHandoff"] = (input) => + markHandoffRow(input).pipe(mapError("ProjectionQueuedTurnRepository.markHandoff:query")); + const deleteByMessageId: ProjectionQueuedTurnRepository["Service"]["deleteByMessageId"] = ( + input, + ) => + deleteMessageRow(input).pipe( + mapError("ProjectionQueuedTurnRepository.deleteByMessageId:query"), + ); + const deleteHandoffByThreadId: ProjectionQueuedTurnRepository["Service"]["deleteHandoffByThreadId"] = + (input) => + deleteHandoffThreadRows(input).pipe( + mapError("ProjectionQueuedTurnRepository.deleteHandoffByThreadId:query"), + ); + const deleteByThreadId: ProjectionQueuedTurnRepository["Service"]["deleteByThreadId"] = (input) => + deleteThreadRows(input).pipe(mapError("ProjectionQueuedTurnRepository.deleteByThreadId:query")); + const listByThreadId: ProjectionQueuedTurnRepository["Service"]["listByThreadId"] = (input) => + listThreadRows(input).pipe(mapError("ProjectionQueuedTurnRepository.listByThreadId:query")); + const listAll: ProjectionQueuedTurnRepository["Service"]["listAll"] = listAllRows(undefined).pipe( + mapError("ProjectionQueuedTurnRepository.listAll:query"), + ); + + return { + upsert, + markHandoff, + deleteByMessageId, + deleteHandoffByThreadId, + deleteByThreadId, + listByThreadId, + listAll, + } satisfies ProjectionQueuedTurnRepository["Service"]; +}); + +export const layer = Layer.effect(ProjectionQueuedTurnRepository, make); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff3202563..3a2dc0b9ce68 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -31,6 +31,7 @@ export const ProjectionThreadMessage = Schema.Struct({ isStreaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, + deliveryState: Schema.optional(Schema.Literal("queued")), }); export type ProjectionThreadMessage = typeof ProjectionThreadMessage.Type; @@ -47,6 +48,10 @@ export type GetProjectionThreadMessageInput = typeof GetProjectionThreadMessageI export const DeleteProjectionThreadMessagesInput = Schema.Struct({ threadId: ThreadId, }); +export const SetProjectionThreadMessageDeliveryStateInput = Schema.Struct({ + messageId: MessageId, + deliveryState: Schema.NullOr(Schema.Literal("queued")), +}); export type DeleteProjectionThreadMessagesInput = typeof DeleteProjectionThreadMessagesInput.Type; /** @@ -69,6 +74,14 @@ export interface ProjectionThreadMessageRepositoryShape { input: GetProjectionThreadMessageInput, ) => Effect.Effect, ProjectionRepositoryError>; + readonly setDeliveryState: ( + input: typeof SetProjectionThreadMessageDeliveryStateInput.Type, + ) => Effect.Effect; + + readonly deleteByMessageId: ( + input: GetProjectionThreadMessageInput, + ) => Effect.Effect; + /** * List projected thread messages for a thread. * diff --git a/apps/server/src/provider/Drivers/CodexDriver.ts b/apps/server/src/provider/Drivers/CodexDriver.ts index 15d7a1ff0216..b4419a871c00 100644 --- a/apps/server/src/provider/Drivers/CodexDriver.ts +++ b/apps/server/src/provider/Drivers/CodexDriver.ts @@ -23,6 +23,7 @@ */ import { CodexSettings, ProviderDriverKind, type ServerProvider } from "@t3tools/contracts"; import * as Crypto from "effect/Crypto"; +import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; @@ -36,7 +37,12 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderDriverError } from "../Errors.ts"; import { makeCodexAdapter } from "../Layers/CodexAdapter.ts"; -import { checkCodexProviderStatus, makePendingCodexProvider } from "../Layers/CodexProvider.ts"; +import { resolveCodexLaunchArgs } from "../Layers/codexLaunchArgs.ts"; +import { + checkCodexProviderStatus, + listCodexProviderSkills, + makePendingCodexProvider, +} from "../Layers/CodexProvider.ts"; import { ProviderEventLoggers } from "../Layers/ProviderEventLoggers.ts"; import { makeManagedServerProvider } from "../makeManagedServerProvider.ts"; import type { ProviderDriver, ProviderInstance } from "../ProviderDriver.ts"; @@ -198,6 +204,29 @@ export const CodexDriver: ProviderDriver = { ), ); + const listSkills = (cwd: string) => + listCodexProviderSkills({ + binaryPath: effectiveConfig.binaryPath, + homePath: effectiveConfig.homePath, + launchArgs: resolveCodexLaunchArgs(effectiveConfig.launchArgs, processEnv), + cwd, + environment: processEnv, + }).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner), + Effect.scoped, + Effect.timeout(Duration.seconds(10)), + Effect.catch((error) => + Effect.logWarning("Codex workspace skill discovery failed; using global skills.", { + cwd, + error: String(error), + instanceId, + }).pipe( + Effect.andThen(snapshot.getSnapshot), + Effect.map((provider) => provider.skills), + ), + ), + ); + return { instanceId, driverKind: DRIVER_KIND, @@ -208,6 +237,7 @@ export const CodexDriver: ProviderDriver = { snapshot, adapter, textGeneration, + listSkills, } satisfies ProviderInstance; }), }; diff --git a/apps/server/src/provider/Errors.ts b/apps/server/src/provider/Errors.ts index 0cf1522399b4..48a38e368f08 100644 --- a/apps/server/src/provider/Errors.ts +++ b/apps/server/src/provider/Errors.ts @@ -2,6 +2,9 @@ import * as Schema from "effect/Schema"; import type { CheckpointServiceError } from "../checkpointing/Errors.ts"; +export const ProviderRequestDelivery = Schema.Literals(["not-delivered", "uncertain"]); +export type ProviderRequestDelivery = typeof ProviderRequestDelivery.Type; + /** * ProviderAdapterValidationError - Invalid adapter API input. */ @@ -60,6 +63,8 @@ export class ProviderAdapterRequestError extends Schema.TaggedErrorClass()( @@ -77,10 +80,11 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { ); public readonly sendTurnImpl = vi.fn( - (_input: CodexSessionRuntimeSendTurnInput): Promise => + (_input: CodexSessionRuntimeSendTurnInput): Promise => Promise.resolve({ threadId: this.options.threadId, turnId: asTurnId("turn-1"), + steered: false, }), ); @@ -128,7 +132,9 @@ class FakeCodexRuntime implements CodexSessionRuntimeShape { getSession = Effect.promise(() => this.startImpl()); - sendTurn(input: CodexSessionRuntimeSendTurnInput) { + sendTurn( + input: CodexSessionRuntimeSendTurnInput, + ): Effect.Effect { return Effect.promise(() => this.sendTurnImpl(input)); } @@ -361,6 +367,39 @@ sessionErrorLayer("CodexAdapterLive session errors", (it) => { }), ); + it.effect("preserves steer rejection reason and delivery certainty", () => + Effect.gen(function* () { + const adapter = yield* CodexAdapter; + const threadId = asThreadId("sess-steer-mismatch"); + yield* adapter.startSession({ + provider: ProviderDriverKind.make("codex"), + threadId, + runtimeMode: "full-access", + }); + const runtime = sessionRuntimeFactory.lastRuntime; + NodeAssert.ok(runtime); + vi.spyOn(runtime, "sendTurn").mockReturnValue( + Effect.fail( + new CodexSessionRuntimeTurnSteerRejectedError({ + threadId, + expectedTurnId: "turn-expected", + steeredTurnId: "turn-other", + reason: "turn-id-mismatch", + }), + ), + ); + + const result = yield* adapter + .sendTurn({ threadId, input: "hello", attachments: [] }) + .pipe(Effect.result); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.ok(isProviderAdapterRequestError(result.failure)); + NodeAssert.equal(result.failure.reason, "turn-id-mismatch"); + NodeAssert.equal(result.failure.delivery, "uncertain"); + }), + ); + it.effect("passes configured launch args into the session runtime", () => { const runtimeFactory = makeRuntimeFactory(); const layer = Layer.effect( diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 065156d36473..da2e855df6fe 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -57,6 +57,7 @@ import { ServerConfig } from "../../config.ts"; import { CodexResumeCursorSchema, CodexSessionRuntimeThreadIdMissingError, + CodexSessionRuntimeTurnSteerRejectedError, makeCodexSessionRuntime, type CodexSessionRuntimeError, type CodexSessionRuntimeOptions, @@ -69,6 +70,9 @@ const isCodexAppServerTransportError = Schema.is(CodexErrors.CodexAppServerTrans const isCodexSessionRuntimeThreadIdMissingError = Schema.is( CodexSessionRuntimeThreadIdMissingError, ); +const isCodexSessionRuntimeTurnSteerRejectedError = Schema.is( + CodexSessionRuntimeTurnSteerRejectedError, +); const isCodexResumeCursorSchema = Schema.is(CodexResumeCursorSchema); const PROVIDER = ProviderDriverKind.make("codex"); @@ -116,6 +120,19 @@ function mapCodexRuntimeError( }); } + // A refused steer is not a failed turn start: reporting it under the method + // that was actually attempted keeps the surfaced detail honest. + if (isCodexSessionRuntimeTurnSteerRejectedError(error)) { + return new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "turn/steer", + detail: error.message, + reason: error.reason, + delivery: error.reason === "turn-id-mismatch" ? "uncertain" : "not-delivered", + cause: error, + }); + } + return new ProviderAdapterRequestError({ provider: PROVIDER, method, diff --git a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts index a1b46e003520..7af83432e0ca 100644 --- a/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts +++ b/apps/server/src/provider/Layers/CodexCollabRuntime.integration.test.ts @@ -74,6 +74,39 @@ function buildScript() { const scriptPath = NodePath.join(import.meta.dirname, "../testFixtures/.collab-script.json"); const peerPath = NodePath.join(import.meta.dirname, "../testFixtures/codexCollabMockPeer.sh"); +/** Append-only sidecars the mock peer writes so tests can assert what the + * runtime actually put on the wire. */ +const SIDECARS = ["starts", "steers", "interrupts"] as const; + +function readSidecar(path: string): ReadonlyArray { + if (!NodeFS.existsSync(path)) { + return []; + } + const raw = NodeFS.readFileSync(path, "utf8").trim(); + return raw.length === 0 ? [] : raw.split("\n").map((line) => JSON.parse(line) as unknown); +} + +/** Writes the script, clears stale sidecars, and registers cleanup. */ +const useScript = Effect.fnUntraced(function* (script: unknown) { + // @effect-diagnostics-next-line preferSchemaOverJson:off + NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); + const paths = Object.fromEntries( + SIDECARS.map((name) => [name, `${scriptPath}.${name}`]), + ) as Record<(typeof SIDECARS)[number], string>; + for (const path of Object.values(paths)) { + NodeFS.rmSync(path, { force: true }); + } + yield* Effect.addFinalizer(() => + Effect.sync(() => { + NodeFS.rmSync(scriptPath, { force: true }); + for (const path of Object.values(paths)) { + NodeFS.rmSync(path, { force: true }); + } + }), + ); + return paths; +}); + describe("CodexSessionRuntime collab integration", () => { it.effect("replays the captured fan-out into synthetic agent events without child leaks", () => Effect.gen(function* () { @@ -280,26 +313,30 @@ describe("CodexSessionRuntime collab integration", () => { }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); - it.live("Stop targets the active turn when Codex has accepted a queued follow-up", () => + it.live("folds a mid-turn send into the running turn and keeps Stop pointed at it", () => Effect.gen(function* () { const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; - const queuedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; const script = { rootThreadId: ROOT, holdTurnOpen: true, - onlyFirstTurnStarts: true, - turnIds: [activeTurnId, queuedTurnId], + // A single scripted turn id: a second turn/start would answer with the + // fixture's own id, so the assertions below catch a regression to + // queueing a follow-up turn. + turnIds: [activeTurnId], expectedActiveTurnId: activeTurnId, notifications: [], }; // @effect-diagnostics-next-line preferSchemaOverJson:off NodeFS.writeFileSync(scriptPath, JSON.stringify(script), "utf8"); const interruptsPath = `${scriptPath}.interrupts`; + const steersPath = `${scriptPath}.steers`; NodeFS.rmSync(interruptsPath, { force: true }); + NodeFS.rmSync(steersPath, { force: true }); yield* Effect.addFinalizer(() => Effect.sync(() => { NodeFS.rmSync(scriptPath, { force: true }); NodeFS.rmSync(interruptsPath, { force: true }); + NodeFS.rmSync(steersPath, { force: true }); }), ); @@ -312,10 +349,26 @@ describe("CodexSessionRuntime collab integration", () => { }); yield* runtime.start(); - yield* runtime.sendTurn({ input: "keep working" }); - yield* runtime.sendTurn({ input: "queued follow-up" }); + const started = yield* runtime.sendTurn({ input: "keep working" }); + const steered = yield* runtime.sendTurn({ input: "follow-up while running" }); yield* runtime.interruptTurn(); + assert.equal(started.turnId, activeTurnId); + // The follow-up steers the turn that is already running rather than + // queueing a second one, so it reports the same turn id back. + assert.equal(steered.turnId, activeTurnId); + const steers = NodeFS.readFileSync(steersPath, "utf8") + .trim() + .split("\n") + .map((line) => JSON.parse(line) as unknown); + assert.deepEqual(steers, [ + { + threadId: ROOT, + expectedTurnId: activeTurnId, + input: [{ type: "text", text: "follow-up while running" }], + }, + ]); + const interrupts = NodeFS.readFileSync(interruptsPath, "utf8") .trim() .split("\n") @@ -328,4 +381,310 @@ describe("CodexSessionRuntime collab integration", () => { yield* runtime.close; }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), ); + + it.live("rejects a message typed after Stop until terminal lifecycle arrives", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + turnIds: [activeTurnId], + expectedActiveTurnId: activeTurnId, + notifications: [], + }; + const paths = yield* useScript(script); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-stop-then-type"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + yield* runtime.interruptTurn(); + const afterStop = yield* runtime + .sendTurn({ input: "actually, do this instead" }) + .pipe(Effect.flip); + + assert.equal(afterStop._tag, "CodexSessionRuntimeTurnSteerRejectedError"); + if (afterStop._tag !== "CodexSessionRuntimeTurnSteerRejectedError") { + return; + } + assert.equal(afterStop.reason, "turn-interrupting"); + assert.equal(afterStop.retryable, true); + assert.deepEqual(readSidecar(paths.steers), []); + assert.deepEqual( + readSidecar(paths.starts).map((entry) => (entry as { input?: unknown }).input), + [[{ type: "text", text: "keep working" }]], + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("rolls back interrupting when the parent interrupt RPC fails", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + turnIds: [activeTurnId], + failInterruptFor: ROOT, + notifications: [], + }; + const paths = yield* useScript(script); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-interrupt-rollback"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + yield* runtime.interruptTurn().pipe(Effect.flip); + const afterFailure = yield* runtime.sendTurn({ input: "still add this" }); + + assert.equal(afterFailure.steered, true); + assert.equal(afterFailure.turnId, activeTurnId); + assert.deepEqual( + readSidecar(paths.starts).map((entry) => (entry as { turnId?: string }).turnId), + [activeTurnId], + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("does not start a phantom turn when a no-active refusal outruns lifecycle", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const nextTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + turnIds: [activeTurnId, nextTurnId], + notifications: [], + // The turn ended just before the steer landed. Quoted verbatim from + // a captured transcript (codex-cli 0.147.0): a bare `-32600` with no + // `data` and no structured error info to key on. + steerRejection: { + code: -32600, + message: "no active turn to steer", + }, + }; + const paths = yield* useScript(script); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-steer-race"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + const raced = yield* runtime.sendTurn({ input: "one more thing" }).pipe(Effect.flip); + + assert.equal(raced._tag, "CodexSessionRuntimeTurnSteerRejectedError"); + assert.equal(readSidecar(paths.steers).length, 1); + assert.deepEqual( + readSidecar(paths.starts).map((entry) => (entry as { input?: unknown }).input), + [[{ type: "text", text: "keep working" }]], + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("serializes concurrent sends so an end-of-turn race starts exactly one fallback", () => + Effect.gen(function* () { + const activeTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const fallbackTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + turnIds: [activeTurnId, fallbackTurnId], + endTurnBeforeFirstSteer: true, + deferStaleSteerResponses: true, + notifications: [], + }; + const paths = yield* useScript(script); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-concurrent-steer-race"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + const results = yield* Effect.all( + [ + runtime.sendTurn({ input: "first follow-up" }), + runtime.sendTurn({ input: "second follow-up" }), + ], + { concurrency: "unbounded" }, + ); + + assert.deepEqual( + readSidecar(paths.starts).map((entry) => (entry as { turnId?: string }).turnId), + [activeTurnId, fallbackTurnId], + ); + assert.equal( + results.filter((result) => result.steered === false).length, + 1, + "only one concurrent send may own the stale-steer fallback", + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("reconciles rather than starting when a steer reports another active turn", () => + Effect.gen(function* () { + const responseTurnId = "019fe4ff-f18b-76f2-b132-c93f3a6c5bfb"; + const notifiedTurnId = "019fe4ff-f223-7401-8ef3-930a168477a8"; + const nextTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + turnIds: [responseTurnId, nextTurnId], + turnStartedBeforeResponse: true, + startedTurnIdOverride: notifiedTurnId, + notifications: [], + // Captured `/review` behaviour: the notification publishes one id + // while the server accepts only the response id. This refusal proves + // the found id is still active, so the runtime must reconcile it and + // must not fall back to a mid-turn turn/start. Verbatim from + // review-steer.attempt-2.jsonl. + steerRejection: { + code: -32600, + message: `expected active turn id \`${notifiedTurnId}\` but found \`${responseTurnId}\``, + }, + }; + const paths = yield* useScript(script); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-review-split"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "keep working" }); + const rejected = yield* runtime.sendTurn({ input: "and also this" }).pipe(Effect.flip); + + assert.equal(rejected._tag, "CodexSessionRuntimeTurnSteerRejectedError"); + assert.equal(readSidecar(paths.steers).length, 1); + assert.deepEqual( + readSidecar(paths.starts).map((entry) => (entry as { input?: unknown }).input), + [[{ type: "text", text: "keep working" }]], + ); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("tracks the start response's turn id when turn/started names another", () => + Effect.gen(function* () { + const responseTurnId = "019fe3e8-f908-7f31-8d51-283f4a47897a"; + const notifiedTurnId = "019fe3eb-8faf-7de3-a85b-ac64c7f9c8c3"; + const script = { + rootThreadId: ROOT, + holdTurnOpen: true, + turnIds: [responseTurnId], + // `turn/started` wins the race and publishes a different id. A + // captured `/review` shows exactly this split, with the server + // accepting only the id it returned in the response. + turnStartedBeforeResponse: true, + startedTurnIdOverride: notifiedTurnId, + expectedActiveTurnId: responseTurnId, + notifications: [], + }; + const paths = yield* useScript(script); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-started-race"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + + yield* runtime.start(); + const started = yield* runtime.sendTurn({ input: "keep working" }); + const steered = yield* runtime.sendTurn({ input: "and also this" }); + yield* runtime.interruptTurn(); + + assert.equal(started.turnId, responseTurnId); + // Both the follow-up steer and Stop address the id the server accepts. + assert.equal(steered.steered, true); + assert.deepEqual( + readSidecar(paths.steers).map( + (entry) => (entry as { expectedTurnId?: string }).expectedTurnId, + ), + [responseTurnId], + ); + assert.deepEqual(readSidecar(paths.interrupts).at(-1), { + threadId: ROOT, + turnId: responseTurnId, + }); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); + + it.live("projects one authoritative lifecycle when turn/started names a split review id", () => + Effect.gen(function* () { + const responseTurnId = "019fe4ff-f18b-76f2-b132-c93f3a6c5bfb"; + const notifiedTurnId = "019fe4ff-f223-7401-8ef3-930a168477a8"; + const script = { + rootThreadId: ROOT, + turnIds: [responseTurnId], + turnStartedBeforeResponse: true, + startedTurnIdOverride: notifiedTurnId, + notifications: [], + }; + yield* useScript(script); + + const runtime = yield* makeCodexSessionRuntime({ + threadId: ThreadId.make("thread-codex-review-projection"), + binaryPath: peerPath, + cwd: "/tmp", + runtimeMode: "full-access", + environment: { ...process.env, T3_CODEX_COLLAB_SCRIPT: scriptPath }, + }); + const eventsFiber = yield* runtime.events.pipe( + Stream.takeUntil((event) => event.method === "turn/completed"), + Stream.runCollect, + Effect.forkScoped, + ); + + yield* runtime.start(); + yield* runtime.sendTurn({ input: "review this" }); + const events = Array.from(yield* Fiber.join(eventsFiber)); + const started = events.find((event) => event.method === "turn/started"); + const completed = events.find((event) => event.method === "turn/completed"); + + assert.equal(started?.turnId, responseTurnId); + assert.equal( + (started?.payload as { turn?: { id?: string } } | undefined)?.turn?.id, + responseTurnId, + ); + assert.equal(completed?.turnId, responseTurnId); + + yield* runtime.close; + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)), + ); }); diff --git a/apps/server/src/provider/Layers/CodexProvider.ts b/apps/server/src/provider/Layers/CodexProvider.ts index 5c0f76dff4e3..8ed70e30a206 100644 --- a/apps/server/src/provider/Layers/CodexProvider.ts +++ b/apps/server/src/provider/Layers/CodexProvider.ts @@ -252,7 +252,7 @@ function appendCustomCodexModels( return customEntries.length === 0 ? models : [...models, ...customEntries]; } -function parseCodexSkillsListResponse( +export function parseCodexSkillsListResponse( response: CodexSchema.V2SkillsListResponse, cwd: string, ): ReadonlyArray { @@ -319,14 +319,17 @@ export function buildCodexInitializeParams(): CodexSchema.V1InitializeParams { }; } -const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* (input: { +interface CodexAppServerProbeInput { readonly binaryPath: string; readonly homePath?: string; readonly launchArgs?: string; readonly cwd: string; - readonly customModels?: ReadonlyArray; readonly environment?: NodeJS.ProcessEnv; -}) { +} + +const openCodexAppServerProbe = Effect.fn("openCodexAppServerProbe")(function* ( + input: CodexAppServerProbeInput, +) { // `~` is not shell-expanded when env vars are set via `child_process.spawn`, // so `CODEX_HOME=~/.codex_work` would reach codex verbatim and trip // "CODEX_HOME points to '~/.codex_work', but that path does not exist". @@ -369,22 +372,33 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun Effect.provide(clientContext), ); - const initialize = yield* client.request("initialize", { - clientInfo: { - name: "t3code_desktop", - title: "T3 Code Desktop", - version: "0.1.0", - }, - capabilities: { - experimentalApi: true, - }, - }); + const initialize = yield* client.request("initialize", buildCodexInitializeParams()); yield* client.notify("initialized", undefined); // Extract the version string after the first '/' in userAgent, up to the next space or the end const versionMatch = initialize.userAgent.match(/\/([^\s]+)/); const version = versionMatch ? versionMatch[1] : undefined; + return { client, version }; +}); + +export const listCodexProviderSkills = Effect.fn("listCodexProviderSkills")(function* (input: { + readonly binaryPath: string; + readonly homePath?: string; + readonly launchArgs?: string; + readonly cwd: string; + readonly environment?: NodeJS.ProcessEnv; +}) { + const { client } = yield* openCodexAppServerProbe(input); + const response = yield* client.request("skills/list", { cwds: [input.cwd] }); + return parseCodexSkillsListResponse(response, input.cwd); +}); + +const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(function* ( + input: CodexAppServerProbeInput & { readonly customModels?: ReadonlyArray }, +) { + const { client, version } = yield* openCodexAppServerProbe(input); + const accountResponse = yield* client.request("account/read", {}); if (!accountResponse.account && accountResponse.requiresOpenaiAuth) { return { @@ -411,7 +425,9 @@ const probeCodexAppServerProvider = Effect.fn("probeCodexAppServerProvider")(fun models: applyPreferredCodexDefaultModel( appendCustomCodexModels(models, input.customModels ?? []), ), - skills: parseCodexSkillsListResponse(skillsResponse, input.cwd), + skills: parseCodexSkillsListResponse(skillsResponse, input.cwd).filter( + (skill) => skill.scope !== "repo", + ), } satisfies CodexAppServerProviderSnapshot; }); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index a80ef2cf56a6..93a1bf0ebf27 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -2,9 +2,16 @@ import * as NodeAssert from "node:assert/strict"; import { it } from "@effect/vitest"; import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import { describe } from "vite-plus/test"; -import { DEFAULT_MODEL, ThreadId } from "@t3tools/contracts"; +import { + DEFAULT_MODEL, + ProviderDriverKind, + ThreadId, + TurnId, + type ProviderSession, +} from "@t3tools/contracts"; import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; @@ -17,12 +24,23 @@ import { import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { buildTurnStartParams, + buildTurnSteerParams, + type CodexActiveTurnState, + CodexSessionRuntimeUnknownSkillError, + CodexSessionRuntimeTurnSteerRejectedError, + type CodexSessionRuntimeSendTurnInput, hasConfiguredMcpServer, isRecoverableThreadResumeError, makeMemoryConsolidationNotificationFilter, openCodexThread, + sendCodexTurn, } from "./CodexSessionRuntime.ts"; const isCodexAppServerRequestError = Schema.is(CodexErrors.CodexAppServerRequestError); +const isCodexAppServerProtocolParseError = Schema.is(CodexErrors.CodexAppServerProtocolParseError); +const isCodexSessionRuntimeUnknownSkillError = Schema.is(CodexSessionRuntimeUnknownSkillError); +const isCodexSessionRuntimeTurnSteerRejectedError = Schema.is( + CodexSessionRuntimeTurnSteerRejectedError, +); describe("CodexSessionRuntimeIdentifierGenerationError", () => { it("retains identifier purpose and the random source failure", () => { @@ -79,6 +97,10 @@ describe("buildTurnStartParams", () => { ], }).pipe(Effect.flip), ); + if (!isCodexAppServerProtocolParseError(error)) { + NodeAssert.fail("expected CodexAppServerProtocolParseError"); + return; + } const { cause, ...directDiagnostics } = error; NodeAssert.equal(error.operation, "decode-request-payload"); @@ -222,6 +244,83 @@ describe("buildTurnStartParams", () => { }), ); + it.effect("attaches explicit $skill tokens as Codex skill user input", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "$grill-with-docs explain why this skill is not in the list", + availableSkills: [ + { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, + }, + ], + }); + + NodeAssert.deepStrictEqual(params.input, [ + { + type: "text", + text: "$grill-with-docs explain why this skill is not in the list", + }, + { + type: "skill", + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + }, + ]); + }), + ); + + it.effect("fails instead of sending an unknown $skill token", () => + Effect.gen(function* () { + const error = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "$missing-skill do this", + availableSkills: [ + { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, + }, + ], + }).pipe(Effect.flip); + + if (!isCodexSessionRuntimeUnknownSkillError(error)) { + NodeAssert.fail("expected CodexSessionRuntimeUnknownSkillError"); + return; + } + NodeAssert.deepStrictEqual(error.names, ["missing-skill"]); + NodeAssert.equal(error.message, "Unknown Codex skill $missing-skill."); + }), + ); + + it.effect("leaves a message without $skill tokens unchanged", () => + Effect.gen(function* () { + const params = yield* buildTurnStartParams({ + threadId: "provider-thread-1", + runtimeMode: "full-access", + prompt: "Review this change", + availableSkills: [ + { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, + }, + ], + }); + + NodeAssert.deepStrictEqual(params.input, [ + { + type: "text", + text: "Review this change", + }, + ]); + }), + ); + it("omits collaboration mode when interaction mode is absent", () => { const params = Effect.runSync( buildTurnStartParams({ @@ -647,3 +746,739 @@ describe("openCodexThread", () => { }), ); }); + +describe("buildTurnSteerParams", () => { + it.effect("carries the required active turn id and the message only", () => + Effect.gen(function* () { + const params = yield* buildTurnSteerParams({ + threadId: "provider-thread-1", + expectedTurnId: TurnId.make("turn-active"), + prompt: "Also update the changelog", + attachments: [ + { + type: "image", + url: "data:image/png;base64,abc", + }, + ], + }); + + NodeAssert.deepStrictEqual(params, { + threadId: "provider-thread-1", + expectedTurnId: "turn-active", + input: [ + { + type: "text", + text: "Also update the changelog", + }, + { + type: "image", + url: "data:image/png;base64,abc", + }, + ], + }); + }), + ); + + it.effect("attaches explicit $skill tokens when steering an active turn", () => + Effect.gen(function* () { + const params = yield* buildTurnSteerParams({ + threadId: "provider-thread-1", + expectedTurnId: TurnId.make("turn-active"), + prompt: "$grill-with-docs also check the changelog", + availableSkills: [ + { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, + }, + ], + }); + + NodeAssert.deepStrictEqual(params.input, [ + { + type: "text", + text: "$grill-with-docs also check the changelog", + }, + { + type: "skill", + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + }, + ]); + }), + ); + + it.effect("rejects an unknown $skill before steering", () => + Effect.gen(function* () { + const error = yield* buildTurnSteerParams({ + threadId: "provider-thread-1", + expectedTurnId: TurnId.make("turn-active"), + prompt: "$missing-skill also check the changelog", + availableSkills: [], + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeUnknownSkillError(error)); + NodeAssert.deepStrictEqual(error.names, ["missing-skill"]); + }), + ); +}); +const PROVIDER_THREAD_ID = "provider-thread-1"; +const ACTIVE_TURN_ID = TurnId.make("turn-active"); + +function makeCodexSession(overrides: Partial): ProviderSession { + return { + provider: ProviderDriverKind.make("codex"), + status: "ready", + runtimeMode: "full-access", + cwd: "/tmp/project", + model: "gpt-5.3-codex", + threadId: ThreadId.make("thread-1"), + resumeCursor: { threadId: PROVIDER_THREAD_ID }, + createdAt: "2026-04-18T00:00:00.000Z", + updatedAt: "2026-04-18T00:00:00.000Z", + ...overrides, + }; +} + +function makeActiveTurn(overrides: Partial = {}): CodexActiveTurnState { + return { + turnId: ACTIVE_TURN_ID, + model: "gpt-5.3-codex", + effort: undefined, + serviceTier: undefined, + interactionMode: undefined, + interrupting: false, + ...overrides, + }; +} + +/** Session + turn record for a thread that is genuinely mid-turn. */ +const runningSession = Effect.all({ + sessionRef: Ref.make(makeCodexSession({ status: "running", activeTurnId: ACTIVE_TURN_ID })), + activeTurnRef: Ref.make(makeActiveTurn()), +}); + +const idleSession = Effect.all({ + sessionRef: Ref.make(makeCodexSession({ status: "ready" })), + activeTurnRef: Ref.make(undefined), +}); + +interface RecordedTurnCall { + readonly method: string; + readonly payload: unknown; +} + +function makeTurnClient(input: { + readonly calls: Array; + readonly startedTurnId?: string; + readonly steer?: ( + payload: EffectCodexSchema.V2TurnSteerParams, + ) => Effect.Effect; + readonly onTurnStart?: () => Effect.Effect; +}) { + return { + raw: { + request: (method: string, payload?: unknown) => { + input.calls.push({ method, payload }); + return (input.onTurnStart ? input.onTurnStart() : Effect.void).pipe( + Effect.as({ + turn: { + id: input.startedTurnId ?? "turn-started", + items: [], + status: "inProgress", + }, + } as unknown), + ) as Effect.Effect; + }, + }, + request: (_method: "turn/steer", payload: EffectCodexSchema.V2TurnSteerParams) => { + input.calls.push({ method: "turn/steer", payload }); + return input.steer + ? input.steer(payload) + : Effect.succeed({ turnId: payload.expectedTurnId }); + }, + }; +} + +const failSteer = (error: CodexErrors.CodexAppServerRequestError) => () => Effect.fail(error); + +const sendTurnInput = { input: "Also update the changelog" } as const; + +const send = (input: { + readonly calls: Array; + readonly sessionRef: Ref.Ref; + readonly activeTurnRef: Ref.Ref; + readonly turn?: CodexSessionRuntimeSendTurnInput; + readonly startedTurnId?: string; + readonly steer?: ( + payload: EffectCodexSchema.V2TurnSteerParams, + ) => Effect.Effect; + readonly onTurnStart?: () => Effect.Effect; +}) => + sendCodexTurn({ + client: makeTurnClient({ + calls: input.calls, + ...(input.startedTurnId ? { startedTurnId: input.startedTurnId } : {}), + ...(input.steer ? { steer: input.steer } : {}), + ...(input.onTurnStart ? { onTurnStart: input.onTurnStart } : {}), + }), + sessionRef: input.sessionRef, + activeTurnRef: input.activeTurnRef, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + turn: input.turn ?? sendTurnInput, + }); + +describe("sendCodexTurn", () => { + it.effect("steers the running turn instead of starting a second one", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + const sessionBefore = yield* Ref.get(sessionRef); + + const result = yield* send({ calls, sessionRef, activeTurnRef }); + + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + NodeAssert.deepStrictEqual(calls[0]?.payload, { + threadId: PROVIDER_THREAD_ID, + expectedTurnId: "turn-active", + input: [ + { + type: "text", + text: "Also update the changelog", + }, + ], + }); + NodeAssert.equal(result.turnId, "turn-active"); + NodeAssert.equal(result.steered, true); + NodeAssert.deepStrictEqual(yield* Ref.get(sessionRef), sessionBefore); + }), + ); + + it.effect("reports the running turn so nothing downstream projects a new turn", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + + const result = yield* send({ calls, sessionRef, activeTurnRef }); + + // A new turn is only ever projected from a `turn/started` notification, + // which the app-server does not send for a steer. Returning the running + // turn's id keeps the caller's active-turn record pointed at the turn + // that `turn/interrupt` accepts. + NodeAssert.deepStrictEqual(result, { + threadId: "thread-1", + turnId: "turn-active", + resumeCursor: { threadId: PROVIDER_THREAD_ID }, + steered: true, + }); + }), + ); + + it.effect("starts a turn when the session is idle", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* idleSession; + + const result = yield* send({ calls, sessionRef, activeTurnRef }); + + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/start"], + ); + NodeAssert.equal(result.turnId, "turn-started"); + NodeAssert.equal(result.steered, false); + const session = yield* Ref.get(sessionRef); + NodeAssert.equal(session.status, "running"); + NodeAssert.equal(session.activeTurnId, "turn-started"); + // The turn's settings become the baseline a later mid-turn send is + // checked against. + NodeAssert.deepStrictEqual(yield* Ref.get(activeTurnRef), { + turnId: "turn-started", + model: "gpt-5.3-codex", + effort: undefined, + serviceTier: undefined, + interactionMode: undefined, + interrupting: false, + }); + }), + ); + + it.effect("prefers the start response's turn id over a racing turn/started", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* idleSession; + + // `turn/started` wins the race against the turn/start response and + // publishes a different id. Captured against codex-cli 0.147.0, the + // server validates `expectedTurnId` and `turn/interrupt` against the + // id it returned in the response, so that one has to win. + const result = yield* send({ + calls, + sessionRef, + activeTurnRef, + startedTurnId: "turn-from-response", + onTurnStart: () => + Ref.update(sessionRef, (session) => ({ + ...session, + status: "running" as const, + activeTurnId: TurnId.make("turn-from-notification"), + })), + }); + + NodeAssert.equal(result.turnId, "turn-from-response"); + NodeAssert.equal((yield* Ref.get(sessionRef)).activeTurnId, "turn-from-response"); + NodeAssert.equal((yield* Ref.get(activeTurnRef))?.turnId, "turn-from-response"); + }), + ); + + it.effect("rejects retryably instead of starting while Stop awaits terminal lifecycle", () => + Effect.gen(function* () { + const calls: Array = []; + const sessionRef = yield* Ref.make( + makeCodexSession({ status: "running", activeTurnId: ACTIVE_TURN_ID }), + ); + const activeTurnRef = yield* Ref.make( + makeActiveTurn({ interrupting: true }), + ); + + const error = yield* send({ calls, sessionRef, activeTurnRef }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "turn-interrupting"); + NodeAssert.equal(error.retryable, true); + NodeAssert.deepStrictEqual(calls, []); + }), + ); + + it.effect("does not start a phantom turn when the running turn's settings are unknown", () => + Effect.gen(function* () { + const calls: Array = []; + const sessionRef = yield* Ref.make( + makeCodexSession({ status: "running", activeTurnId: ACTIVE_TURN_ID }), + ); + const activeTurnRef = yield* Ref.make(undefined); + + const error = yield* send({ calls, sessionRef, activeTurnRef }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "rejected"); + NodeAssert.match(error.message, /metadata is unavailable/); + NodeAssert.deepStrictEqual(calls, []); + }), + ); + + for (const change of [ + { label: "model", turn: { ...sendTurnInput, model: "gpt-5.4" }, setting: "the model" }, + { + label: "reasoning effort", + turn: { ...sendTurnInput, effort: "high" }, + setting: "reasoning effort", + }, + { + label: "interaction mode", + turn: { ...sendTurnInput, interactionMode: "plan" }, + setting: "the interaction mode", + }, + { + label: "service tier", + turn: { ...sendTurnInput, serviceTier: "priority" }, + setting: "the service tier", + }, + ] as const) { + it.effect(`refuses to silently drop a ${change.label} switch mid-turn`, () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + const sessionBefore = yield* Ref.get(sessionRef); + + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + turn: change.turn as CodexSessionRuntimeSendTurnInput, + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "turn-settings-changed"); + NodeAssert.equal(error.changedSetting, change.setting); + NodeAssert.equal(error.retryable, false); + // Refused before any RPC: the message is not sent anywhere. + NodeAssert.deepStrictEqual(calls, []); + NodeAssert.deepStrictEqual(yield* Ref.get(sessionRef), sessionBefore); + }), + ); + } + + it.effect("steers when the send repeats the settings the turn already runs with", () => + Effect.gen(function* () { + const calls: Array = []; + const sessionRef = yield* Ref.make( + makeCodexSession({ status: "running", activeTurnId: ACTIVE_TURN_ID }), + ); + const activeTurnRef = yield* Ref.make( + makeActiveTurn({ effort: "high", interactionMode: "default" }), + ); + + const result = yield* send({ + calls, + sessionRef, + activeTurnRef, + turn: { + ...sendTurnInput, + model: "gpt-5.3-codex", + effort: "high", + interactionMode: "default", + }, + }); + + NodeAssert.equal(result.steered, true); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + }), + ); + + it.effect("records the effective default effort used by interaction mode", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* idleSession; + + yield* send({ + calls, + sessionRef, + activeTurnRef, + turn: { ...sendTurnInput, interactionMode: "default" }, + }); + const repeated = yield* send({ + calls, + sessionRef, + activeTurnRef, + turn: { ...sendTurnInput, effort: "medium", interactionMode: "default" }, + }); + + NodeAssert.equal(repeated.steered, true); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/start", "turn/steer"], + ); + NodeAssert.equal((yield* Ref.get(activeTurnRef))?.effort, "medium"); + }), + ); + + it.effect("re-issues after a no-active refusal only when the runtime has become idle", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + + const result = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: () => + Ref.update(sessionRef, (session) => ({ + ...session, + status: "ready" as const, + activeTurnId: undefined, + })).pipe( + Effect.andThen(Ref.set(activeTurnRef, undefined)), + Effect.andThen( + Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "no active turn to steer", + }), + ), + ), + ), + }); + + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer", "turn/start"], + ); + NodeAssert.equal(result.turnId, "turn-started"); + NodeAssert.equal(result.steered, false); + }), + ); + + it.effect("does not start after a no-active refusal while the runtime still sees a turn", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: failSteer( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "no active turn to steer", + }), + ), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.retryable, false); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + }), + ); + + it.effect("reconciles a found active turn without starting a phantom turn", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + const foundTurnId = "019fe4fe-eaeb-7a02-b448-18071e35f6f9"; + + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: failSteer( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: `expected active turn id \`${ACTIVE_TURN_ID}\` but found \`${foundTurnId}\``, + }), + ), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.retryable, false); + NodeAssert.equal((yield* Ref.get(sessionRef)).activeTurnId, foundTurnId); + NodeAssert.equal((yield* Ref.get(activeTurnRef))?.turnId, foundTurnId); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + }), + ); + + it.effect("keeps a refusal terminal when the session still sees the turn running", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + const sessionBefore = yield* Ref.get(sessionRef); + + // Neither captured precondition message: re-issuing could double post. + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: failSteer( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "steering is disabled", + }), + ), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "rejected"); + NodeAssert.equal(error.retryable, false); + NodeAssert.equal(error.detail, "steering is disabled"); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + NodeAssert.deepStrictEqual(yield* Ref.get(sessionRef), sessionBefore); + }), + ); + + it.effect("keeps a precondition-shaped message terminal under another code", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: failSteer( + new CodexErrors.CodexAppServerRequestError({ + code: -32603, + errorMessage: "no active turn to steer", + }), + ), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "rejected"); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + }), + ); + + it.effect("classifies the schema-declared activeTurnNotSteerable refusal", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + const sessionBefore = yield* Ref.get(sessionRef); + + // Schema-declared, wire-unproven: no capture of this refusal exists. + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: failSteer( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "active turn cannot be steered", + data: { codexErrorInfo: { activeTurnNotSteerable: { turnKind: "review" } } }, + }), + ), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "active-turn-not-steerable"); + NodeAssert.equal(error.turnKind, "review"); + NodeAssert.equal(error.retryable, false); + NodeAssert.match(error.message, /running a review turn/); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + NodeAssert.deepStrictEqual(yield* Ref.get(sessionRef), sessionBefore); + }), + ); + + it.effect("never re-issues an activeTurnNotSteerable refusal worded like a stale one", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + + // The variant wins over the message prefix: a `/review` turn is still + // running, so re-issuing would post into it. + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: failSteer( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "no active turn to steer", + data: { codexErrorInfo: { activeTurnNotSteerable: { turnKind: "compact" } } }, + }), + ), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "active-turn-not-steerable"); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + }), + ); + + it.effect("never renders a placeholder turn kind the app-server did not name", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: failSteer( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "active turn cannot be steered", + data: { codexErrorInfo: { activeTurnNotSteerable: {} } }, + }), + ), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "active-turn-not-steerable"); + NodeAssert.equal(error.turnKind, undefined); + NodeAssert.doesNotMatch(error.message, /unknown/); + }), + ); + + it.effect("rejects when the app-server steers a turn we did not ask for", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + const sessionBefore = yield* Ref.get(sessionRef); + + const error = yield* send({ + calls, + sessionRef, + activeTurnRef, + steer: () => Effect.succeed({ turnId: "turn-other" }), + }).pipe(Effect.flip); + + NodeAssert.ok(isCodexSessionRuntimeTurnSteerRejectedError(error)); + NodeAssert.equal(error.reason, "turn-id-mismatch"); + NodeAssert.equal(error.steeredTurnId, "turn-other"); + // The message may have landed on that turn, so it is never re-issued. + NodeAssert.equal(error.retryable, false); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer"], + ); + NodeAssert.deepStrictEqual(yield* Ref.get(sessionRef), sessionBefore); + }), + ); + + it.effect("keeps a failed fallback typed as the turn-start failure it is", () => + Effect.gen(function* () { + const calls: Array = []; + const { sessionRef, activeTurnRef } = yield* runningSession; + + const error = yield* sendCodexTurn({ + client: { + raw: { + request: (method: string, payload?: unknown) => { + calls.push({ method, payload }); + return Effect.fail( + new CodexErrors.CodexAppServerProcessExitedError({ code: 1 }), + ) as Effect.Effect; + }, + }, + request: (_method: "turn/steer", payload: EffectCodexSchema.V2TurnSteerParams) => { + calls.push({ method: "turn/steer", payload }); + return Ref.update(sessionRef, (session) => ({ + ...session, + status: "ready" as const, + activeTurnId: undefined, + })).pipe( + Effect.andThen(Ref.set(activeTurnRef, undefined)), + Effect.andThen( + Effect.fail( + new CodexErrors.CodexAppServerRequestError({ + code: -32600, + errorMessage: "no active turn to steer", + }), + ), + ), + ); + }, + }, + sessionRef, + activeTurnRef, + threadId: ThreadId.make("thread-1"), + runtimeMode: "full-access", + turn: sendTurnInput, + }).pipe(Effect.flip); + + // Disguising this as a steer rejection would cost the adapter its + // session-closed classification. + NodeAssert.equal(error._tag, "CodexAppServerProcessExitedError"); + NodeAssert.deepStrictEqual( + calls.map((call) => call.method), + ["turn/steer", "turn/start"], + ); + }), + ); +}); diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 29bb992611c1..836dc86dbdee 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -16,8 +16,9 @@ import { ThreadId, TurnId, } from "@t3tools/contracts"; -import { resolveSpawnCommand } from "@t3tools/shared/shell"; +import { collectComposerSkillInvocations } from "@t3tools/shared/composerInlineTokens"; import { normalizeModelSlug } from "@t3tools/shared/model"; +import { resolveSpawnCommand } from "@t3tools/shared/shell"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Deferred from "effect/Deferred"; @@ -28,6 +29,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import * as CodexClient from "effect-codex-app-server/client"; @@ -35,7 +37,8 @@ import * as CodexErrors from "effect-codex-app-server/errors"; import * as CodexRpc from "effect-codex-app-server/rpc"; import * as EffectCodexSchema from "effect-codex-app-server/schema"; -import { buildCodexInitializeParams } from "./CodexProvider.ts"; +import { buildCodexInitializeParams, parseCodexSkillsListResponse } from "./CodexProvider.ts"; +import { bindCodexSkillInvocations } from "./codexSkillInvocations.ts"; import { codexSessionAppServerArgs } from "./codexLaunchArgs.ts"; import { expandHomePath } from "../../pathExpansion.ts"; import { buildCodexDeveloperInstructions } from "../CodexDeveloperInstructions.ts"; @@ -84,6 +87,7 @@ const CodexTurnStartParamsWithCollaborationMode = EffectCodexSchema.V2TurnStartP const decodeCodexTurnStartParamsWithCollaborationMode = Schema.decodeUnknownEffect( CodexTurnStartParamsWithCollaborationMode, ); +const decodeCodexTurnSteerParams = Schema.decodeUnknownEffect(EffectCodexSchema.V2TurnSteerParams); export type CodexTurnStartParamsWithCollaborationMode = typeof CodexTurnStartParamsWithCollaborationMode.Type; @@ -136,7 +140,7 @@ export interface CodexSessionRuntimeShape { readonly getSession: Effect.Effect; readonly sendTurn: ( input: CodexSessionRuntimeSendTurnInput, - ) => Effect.Effect; + ) => Effect.Effect; readonly interruptTurn: (turnId?: TurnId) => Effect.Effect; readonly readThread: Effect.Effect; readonly rollbackThread: ( @@ -159,7 +163,9 @@ export type CodexSessionRuntimeError = | CodexSessionRuntimePendingApprovalNotFoundError | CodexSessionRuntimePendingUserInputNotFoundError | CodexSessionRuntimeInvalidUserInputAnswersError - | CodexSessionRuntimeThreadIdMissingError; + | CodexSessionRuntimeThreadIdMissingError + | CodexSessionRuntimeUnknownSkillError + | CodexSessionRuntimeTurnSteerRejectedError; export class CodexSessionRuntimePendingApprovalNotFoundError extends Schema.TaggedErrorClass()( "CodexSessionRuntimePendingApprovalNotFoundError", @@ -205,6 +211,112 @@ export class CodexSessionRuntimeThreadIdMissingError extends Schema.TaggedErrorC } } +export class CodexSessionRuntimeUnknownSkillError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeUnknownSkillError", + { + names: Schema.Array(Schema.String), + }, +) { + override get message(): string { + const listed = this.names.map((name) => `$${name}`).join(", "); + if (this.names.length === 1) { + return `Unknown Codex skill ${listed}.`; + } + return `Unknown Codex skills ${listed}.`; + } +} + +/** + * The one rejection the runtime may recover from on its own: the steer lost + * a race with the end of the turn it named, so nothing was delivered and the + * message can be re-issued as a fresh `turn/start`. Kept as its own literal + * union so retryability is a type-level property rather than a convention — + * {@link isRetryableCodexTurnSteerRejection} is the only place that decides. + */ +export const CodexTurnSteerRetryableReason = Schema.Literals([ + "stale-expected-turn-id", + "turn-interrupting", +]); +export type CodexTurnSteerRetryableReason = typeof CodexTurnSteerRetryableReason.Type; + +export const CodexTurnSteerTerminalReason = Schema.Literals([ + // Documented protocol outcome, not a fault: the running turn is a + // `/review` or manual `/compact`, which never accepts same-turn steering. + "active-turn-not-steerable", + // The send asks for a model/effort/service tier/interaction mode the + // running turn cannot adopt — steering would silently drop the switch. + "turn-settings-changed", + // The app-server answered with some other turn: the message may have + // landed, so re-issuing it risks a double post. + "turn-id-mismatch", + // Refused while the runtime still sees the turn running: unclassified, and + // re-issuing could double post. + "rejected", +]); +export type CodexTurnSteerTerminalReason = typeof CodexTurnSteerTerminalReason.Type; + +export const CodexTurnSteerRejectionReason = Schema.Union([ + CodexTurnSteerRetryableReason, + CodexTurnSteerTerminalReason, +]); +export type CodexTurnSteerRejectionReason = typeof CodexTurnSteerRejectionReason.Type; + +const isCodexTurnSteerRetryableReason = Schema.is(CodexTurnSteerRetryableReason); + +export function isRetryableCodexTurnSteerRejection( + reason: CodexTurnSteerRejectionReason, +): reason is CodexTurnSteerRetryableReason { + return isCodexTurnSteerRetryableReason(reason); +} + +export class CodexSessionRuntimeTurnSteerRejectedError extends Schema.TaggedErrorClass()( + "CodexSessionRuntimeTurnSteerRejectedError", + { + threadId: Schema.String, + expectedTurnId: Schema.String, + reason: CodexTurnSteerRejectionReason, + turnKind: Schema.optionalKey(Schema.String), + changedSetting: Schema.optionalKey(Schema.String), + steeredTurnId: Schema.optionalKey(Schema.String), + detail: Schema.optionalKey(Schema.String), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + /** + * A rejected steer never delivers the message, and it never means the + * session is broken — the running turn keeps going. Callers use this to + * keep the send out of the session-error path. + */ + get retryable(): boolean { + return isRetryableCodexTurnSteerRejection(this.reason); + } + + override get message(): string { + switch (this.reason) { + case "active-turn-not-steerable": + // `turnKind` is only rendered when the app-server actually named one; + // a placeholder would read as a real turn kind to the user. + return this.turnKind + ? `Codex is running a ${this.turnKind} turn, which does not accept new messages until it finishes.` + : "Codex is running a turn that does not accept new messages until it finishes."; + case "turn-settings-changed": + return `Codex cannot change ${this.changedSetting ?? "turn settings"} while a turn is running; send this message after the current turn finishes.`; + case "turn-id-mismatch": + return `Codex steered turn ${this.steeredTurnId ?? "an unnamed turn"} instead of the expected active turn ${this.expectedTurnId}.`; + case "stale-expected-turn-id": + return `Codex turn ${this.expectedTurnId} ended before the message reached it${ + this.detail ? `: ${this.detail}` : "." + }`; + case "turn-interrupting": + return `Codex turn ${this.expectedTurnId} is stopping; the message was not sent.`; + default: + return `Codex rejected steering turn ${this.expectedTurnId}${ + this.detail ? `: ${this.detail}` : "." + }`; + } + } +} + interface PendingApproval { readonly requestId: ApprovalRequestId; readonly jsonRpcId: string; @@ -369,6 +481,11 @@ export function buildTurnStartParams(input: { readonly type: "image"; readonly url: string; }>; + readonly availableSkills?: ReadonlyArray<{ + readonly name: string; + readonly path: string; + readonly enabled: boolean; + }>; readonly model?: string; readonly serviceTier?: CodexServiceTier; readonly effort?: EffectCodexSchema.V2TurnStartParams__ReasoningEffort; @@ -377,8 +494,13 @@ export function buildTurnStartParams(input: { readonly browserToolsAvailable?: boolean; }): Effect.Effect< CodexTurnStartParamsWithCollaborationMode, - CodexErrors.CodexAppServerProtocolParseError + CodexErrors.CodexAppServerProtocolParseError | CodexSessionRuntimeUnknownSkillError > { + const boundSkills = bindCodexSkillInvocations(input.prompt, input.availableSkills ?? []); + if (!boundSkills.ok) { + return Effect.fail(new CodexSessionRuntimeUnknownSkillError({ names: boundSkills.names })); + } + const turnInput: Array = []; if (input.prompt) { turnInput.push({ @@ -386,6 +508,9 @@ export function buildTurnStartParams(input: { text: input.prompt, }); } + for (const skill of boundSkills.inputs) { + turnInput.push(skill); + } for (const attachment of input.attachments ?? []) { turnInput.push(attachment); } @@ -419,6 +544,64 @@ export function buildTurnStartParams(input: { ); } +/** + * Steering carries the message and nothing else: the wire contract has no + * model, effort, sandbox or collaboration fields because the turn that is + * already running keeps the settings it started with. `expectedTurnId` is a + * precondition — the app-server fails the request when it is not the turn + * currently active on the thread. + */ +export function buildTurnSteerParams(input: { + readonly threadId: string; + readonly expectedTurnId: TurnId; + readonly prompt?: string; + readonly attachments?: ReadonlyArray<{ + readonly type: "image"; + readonly url: string; + }>; + readonly availableSkills?: ReadonlyArray<{ + readonly name: string; + readonly path: string; + readonly enabled: boolean; + }>; +}): Effect.Effect< + EffectCodexSchema.V2TurnSteerParams, + CodexErrors.CodexAppServerProtocolParseError | CodexSessionRuntimeUnknownSkillError +> { + const boundSkills = bindCodexSkillInvocations(input.prompt, input.availableSkills ?? []); + if (!boundSkills.ok) { + return Effect.fail(new CodexSessionRuntimeUnknownSkillError({ names: boundSkills.names })); + } + + const turnInput: Array = []; + if (input.prompt) { + turnInput.push({ + type: "text", + text: input.prompt, + }); + } + for (const skill of boundSkills.inputs) { + turnInput.push(skill); + } + for (const attachment of input.attachments ?? []) { + turnInput.push(attachment); + } + + return decodeCodexTurnSteerParams({ + threadId: input.threadId, + expectedTurnId: input.expectedTurnId, + input: turnInput, + }).pipe( + Effect.mapError((cause) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-request-payload", + cause, + { method: "turn/steer" }, + ), + ), + ); +} + function classifyCodexStderrLine(rawLine: string): { readonly message: string } | null { const line = rawLine.replaceAll(ANSI_ESCAPE_REGEX, "").trim(); if (!line) { @@ -887,6 +1070,432 @@ function parseThreadSnapshot( }; } +function readRecord(value: unknown): Record | undefined { + return typeof value === "object" && value !== null + ? (value as Record) + : undefined; +} + +/** + * JSON-RPC code every steer rejection captured against codex-cli 0.147.0 + * carries. Anything else is not a steer precondition failure. + */ +const CODEX_STEER_REJECTION_CODE = -32600; + +/** + * Matching on message text is unavoidable here: captured codex-cli 0.147.0 + * responses carry no structured error data. Keep the two observed refusals + * distinct. "No active" permits a fresh start only after the serialized + * runtime view is also idle; "found B" proves B is running and must never + * directly fall back to turn/start. + */ +function isNoActiveTurnRejection(cause: CodexErrors.CodexAppServerRequestError): boolean { + return ( + cause.code === CODEX_STEER_REJECTION_CODE && + cause.errorMessage.trim().toLowerCase().startsWith("no active turn to steer") + ); +} + +function readFoundActiveTurnId(cause: CodexErrors.CodexAppServerRequestError): TurnId | undefined { + if (cause.code !== CODEX_STEER_REJECTION_CODE) { + return undefined; + } + const match = /^expected active turn id `[^`]+` but found `([^`]+)`/i.exec( + cause.errorMessage.trim(), + ); + return match?.[1] ? TurnId.make(match[1]) : undefined; +} + +/** + * Reads the `activeTurnNotSteerable` codex error info the generated schema + * declares as a `CodexErrorInfo` variant. + * + * Schema-declared, wire-unproven: no capture of this refusal exists. Every + * steer rejection captured against codex-cli 0.147.0 is a bare + * `{code: -32600, message}` with no `data`, and the `/review` refusal that + * would carry the variant was never reproduced. This reads the one position + * the schema implies (`data.codexErrorInfo`) purely so the day it appears is + * a better message rather than a surprise. + * + * A miss can only make a rejection *more* conservative: unmatched refusals + * stay terminal and are never re-issued. `turnKind` stays absent unless the + * payload names one — the user-facing message must not invent a turn kind. + */ +function readActiveTurnNotSteerable( + data: unknown, +): { readonly turnKind: string | undefined } | undefined { + const variant = readRecord(readRecord(readRecord(data)?.codexErrorInfo)?.activeTurnNotSteerable); + return variant + ? { turnKind: typeof variant.turnKind === "string" ? variant.turnKind : undefined } + : undefined; +} + +interface CodexTurnSubmitClient { + readonly raw: { + readonly request: ( + method: string, + payload?: unknown, + ) => Effect.Effect; + }; + readonly request: ( + method: "turn/steer", + payload: CodexRpc.ClientRequestParamsByMethod["turn/steer"], + ) => Effect.Effect< + CodexRpc.ClientRequestResponsesByMethod["turn/steer"], + CodexErrors.CodexAppServerError + >; +} + +/** + * Settings the running turn was started with. Steering carries none of them + * (the wire contract has no such fields), so a send that asks to change one + * has to be refused rather than folded in — otherwise the UI shows a switch + * the model never received. + * + * `interrupting` flips the moment `turn/interrupt` is issued: a message typed + * right after Stop must not be folded into the turn being aborted. + */ +export interface CodexActiveTurnState { + readonly turnId: TurnId; + readonly model: string | undefined; + readonly effort: EffectCodexSchema.V2TurnStartParams__ReasoningEffort | undefined; + readonly serviceTier: CodexServiceTier | undefined; + readonly interactionMode: ProviderInteractionMode | undefined; + readonly interrupting: boolean; +} + +export interface CodexSendTurnResult extends ProviderTurnStartResult { + /** True when the message folded into an already-running turn. */ + readonly steered: boolean; +} + +/** + * Names the first setting the send asks to change, or undefined when it asks + * for nothing the running turn cannot already provide. An omitted field is + * "no preference", not "reset to default", so it never counts as a change. + */ +function readTurnSettingsChange( + active: CodexActiveTurnState, + turn: CodexSessionRuntimeSendTurnInput, +): string | undefined { + const requestedModel = normalizeCodexModelSlug(turn.model); + if (requestedModel !== undefined && requestedModel !== active.model) { + return "the model"; + } + if (turn.effort !== undefined && turn.effort !== active.effort) { + return "reasoning effort"; + } + if (turn.serviceTier !== undefined && turn.serviceTier !== active.serviceTier) { + return "the service tier"; + } + if (turn.interactionMode !== undefined && turn.interactionMode !== active.interactionMode) { + return "the interaction mode"; + } + return undefined; +} + +/** + * Submits one user message to a Codex thread. + * + * Idle session → `turn/start`, which opens a new provider turn exactly as + * before. + * + * Mid-turn → `turn/steer`, which folds the message into the turn that is + * already running. Codex answers a steer with the id of that same turn and + * emits no `turn/started` notification, so nothing downstream projects a new + * turn and the runtime's active-turn bookkeeping is left untouched: the + * caller gets the running turn's id back and the message lands in that + * turn's stream. + * + * Issuing `turn/start` mid-turn instead — what this runtime used to do — is + * worse than it looks. Captured against codex-cli 0.147.0, the response + * hands back a *different* turn id that never starts: no `turn/started`, no + * items and no `turn/completed` ever carry it, while the message itself + * folds into the turn already running. The runtime then reported that + * phantom id as the turn's id, so callers persisted it as the active turn + * and `turn/interrupt` was refused ("expected active turn id … but found + * …"). Steering returns the id the server actually accepts. + * + * Only an idle session takes the `turn/start` path. A turn already being + * interrupted rejects the send retryably until its terminal lifecycle + * arrives, while a known active turn whose start-time settings are missing + * rejects rather than risking a mid-turn phantom `turn/start` response. + * + * Rejections split by whether the message can still be delivered: + * + * - The steer lost a race with the end of its turn — the app-server refuses a + * stale `expectedTurnId`, and the runtime's own view has since gone idle. + * Nothing was delivered (the precondition failed), so the message is + * re-issued as a `turn/start` exactly once. This is the ordinary + * end-of-turn race, not a fault. + * - `activeTurnNotSteerable` (`/review`, manual `/compact`) is a documented + * protocol outcome and is never re-issued: the running turn keeps going and + * the caller is told the message was not sent. + * - A refusal reporting "found B" is terminal and reconciles B as active; + * its failed precondition proves this message was not delivered. A + * successful response naming a different turn is also terminal, but its + * delivery is uncertain, so re-issuing it risks a double post. + * + * No rejection is ever a session-level failure — see + * `CodexSessionRuntimeTurnSteerRejectedError`. + */ +export const sendCodexTurn = (input: { + readonly client: CodexTurnSubmitClient; + readonly sessionRef: Ref.Ref; + readonly activeTurnRef: Ref.Ref; + readonly pendingTurnStartIdRef?: Ref.Ref | undefined>; + readonly threadId: ThreadId; + readonly runtimeMode: RuntimeMode; + readonly turn: CodexSessionRuntimeSendTurnInput; + readonly availableSkills?: ReadonlyArray<{ + readonly name: string; + readonly path: string; + readonly enabled: boolean; + }>; + readonly browserToolsAvailable?: boolean; +}): Effect.Effect => + Effect.gen(function* () { + const session = yield* Ref.get(input.sessionRef); + const providerThreadId = currentProviderThreadId(session); + if (!providerThreadId) { + return yield* new CodexSessionRuntimeThreadIdMissingError({ + threadId: input.threadId, + }); + } + + const startTurn = Effect.gen(function* () { + const pendingTurnStartId = input.pendingTurnStartIdRef + ? yield* Deferred.make() + : undefined; + if (pendingTurnStartId && input.pendingTurnStartIdRef) { + yield* Ref.set(input.pendingTurnStartIdRef, pendingTurnStartId); + } + + return yield* Effect.gen(function* () { + // Read the model fresh: on the end-of-turn retry path the session has + // moved on since the snapshot above. + const current = yield* Ref.get(input.sessionRef); + const requestedModel = normalizeCodexModelSlug(input.turn.model ?? current.model); + const effectiveSettings = { + model: + requestedModel ?? + (input.turn.interactionMode !== undefined ? DEFAULT_MODEL : undefined), + effort: + input.turn.effort ?? (input.turn.interactionMode !== undefined ? "medium" : undefined), + serviceTier: input.turn.serviceTier, + interactionMode: input.turn.interactionMode, + } as const; + const startParams = yield* buildTurnStartParams({ + threadId: providerThreadId, + runtimeMode: input.runtimeMode, + ...(input.turn.input ? { prompt: input.turn.input } : {}), + ...(input.turn.attachments ? { attachments: input.turn.attachments } : {}), + ...(input.availableSkills ? { availableSkills: input.availableSkills } : {}), + ...(effectiveSettings.model ? { model: effectiveSettings.model } : {}), + ...(effectiveSettings.serviceTier ? { serviceTier: effectiveSettings.serviceTier } : {}), + ...(effectiveSettings.effort ? { effort: effectiveSettings.effort } : {}), + ...(effectiveSettings.interactionMode + ? { interactionMode: effectiveSettings.interactionMode } + : {}), + browserToolsAvailable: input.browserToolsAvailable ?? true, + }); + const rawResponse = yield* input.client.raw.request("turn/start", startParams); + const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( + Effect.mapError((error) => + CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( + "decode-response-payload", + error, + { method: "turn/start" }, + ), + ), + ); + const startedTurnId = TurnId.make(response.turn.id); + // The response id is what the server validates `expectedTurnId` and + // `turn/interrupt` against, so it wins over any id a `turn/started` + // notification published — including when that notification arrived + // first. The two agree for ordinary turns; a captured `/review` shows + // them diverging, with the server naming the response id as active. + yield* updateSession(input.sessionRef, { + status: "running", + activeTurnId: startedTurnId, + ...(effectiveSettings.model ? { model: effectiveSettings.model } : {}), + }); + yield* Ref.set(input.activeTurnRef, { + turnId: startedTurnId, + ...effectiveSettings, + interrupting: false, + }); + if (pendingTurnStartId) { + yield* Deferred.succeed(pendingTurnStartId, startedTurnId); + } + return startedTurnId; + }).pipe( + Effect.ensuring( + pendingTurnStartId && input.pendingTurnStartIdRef + ? Deferred.succeed(pendingTurnStartId, undefined).pipe( + Effect.andThen(Ref.set(input.pendingTurnStartIdRef, undefined)), + ) + : Effect.void, + ), + ); + }); + + const finish = (turnId: TurnId, steered: boolean) => + Effect.gen(function* () { + const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(input.sessionRef)); + return { + threadId: input.threadId, + turnId, + ...(resumedProviderThreadId + ? { resumeCursor: { threadId: resumedProviderThreadId } } + : {}), + steered, + } satisfies CodexSendTurnResult; + }); + + const activeTurnId = session.activeTurnId; + const activeTurn = yield* Ref.get(input.activeTurnRef); + if (activeTurnId === undefined) { + return yield* finish(yield* startTurn, false); + } + if (activeTurn === undefined || activeTurn.turnId !== activeTurnId) { + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "rejected", + detail: "active turn metadata is unavailable; message was not sent", + }); + } + if (activeTurn.interrupting) { + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "turn-interrupting", + }); + } + + const changedSetting = readTurnSettingsChange(activeTurn, input.turn); + if (changedSetting) { + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "turn-settings-changed", + changedSetting, + }); + } + + // `expectedTurnId` must be the id the SERVER considers active, which is + // the one it returned from `turn/start` — not necessarily the one a + // `turn/started` notification published. A captured `/review` shows the + // two diverging, with the server accepting only the response id. The + // runtime tracks the response id for exactly this reason (see the start + // path). If the server still reports a different active id, reconcile it + // below and leave this message terminally undelivered. + const steerParams = yield* buildTurnSteerParams({ + threadId: providerThreadId, + expectedTurnId: activeTurnId, + ...(input.turn.input ? { prompt: input.turn.input } : {}), + ...(input.turn.attachments ? { attachments: input.turn.attachments } : {}), + ...(input.availableSkills ? { availableSkills: input.availableSkills } : {}), + }); + const steered = yield* input.client.request("turn/steer", steerParams).pipe( + Effect.map((response) => ({ ok: true as const, turnId: TurnId.make(response.turnId) })), + Effect.catchTag("CodexAppServerRequestError", (cause) => + Effect.succeed({ ok: false as const, cause }), + ), + ); + + if (steered.ok) { + if (steered.turnId !== activeTurnId) { + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "turn-id-mismatch", + steeredTurnId: steered.turnId, + }); + } + // Deliberately no session update: the steered message belongs to a turn + // that is already tracked, and its settings are unchanged by + // construction (see the refusal above). + return yield* finish(activeTurnId, true); + } + + // Classify from the response itself. The `activeTurnNotSteerable` + // variant is checked first so the schema-declared refusal, if it ever + // arrives, cannot be mistaken for a stale precondition and re-issued. + const notSteerable = readActiveTurnNotSteerable(steered.cause.data); + if (notSteerable) { + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "active-turn-not-steerable", + ...(notSteerable.turnKind ? { turnKind: notSteerable.turnKind } : {}), + detail: steered.cause.message, + cause: steered.cause, + }); + } + + const foundActiveTurnId = readFoundActiveTurnId(steered.cause); + if (foundActiveTurnId !== undefined) { + yield* updateSession(input.sessionRef, { + status: "running", + activeTurnId: foundActiveTurnId, + }); + yield* Ref.set(input.activeTurnRef, { + ...activeTurn, + turnId: foundActiveTurnId, + }); + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "rejected", + steeredTurnId: foundActiveTurnId, + detail: `${steered.cause.message}; active turn reconciled, message was not sent`, + cause: steered.cause, + }); + } + + if (!isNoActiveTurnRejection(steered.cause)) { + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "rejected", + detail: steered.cause.message, + cause: steered.cause, + }); + } + + const latestSession = yield* Ref.get(input.sessionRef); + const latestActiveTurn = yield* Ref.get(input.activeTurnRef); + if (latestSession.activeTurnId !== undefined || latestActiveTurn !== undefined) { + return yield* new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "rejected", + detail: `${steered.cause.message}; runtime still reports an active turn, message was not sent`, + cause: steered.cause, + }); + } + + const rejection = new CodexSessionRuntimeTurnSteerRejectedError({ + threadId: input.threadId, + expectedTurnId: activeTurnId, + reason: "stale-expected-turn-id", + detail: steered.cause.message, + cause: steered.cause, + }); + + // The serialized runtime view independently confirms the refused turn is + // gone. Re-issue exactly once as a fresh turn. A failure here keeps its + // turn-start error type rather than being disguised as a steer rejection. + yield* Effect.logDebug("codex refused a stale steer precondition; re-issuing as turn/start", { + threadId: input.threadId, + expectedTurnId: activeTurnId, + cause: rejection.detail, + }); + return yield* finish(yield* startTurn, false); + }); + export const makeCodexSessionRuntime = ( options: CodexSessionRuntimeOptions, ): Effect.Effect< @@ -907,6 +1516,19 @@ export const makeCodexSessionRuntime = ( /** Child provider-thread id → its currently running provider turn id. */ const collabChildLiveTurnsRef = yield* Ref.make(new Map()); const suppressMemoryConsolidationNotification = makeMemoryConsolidationNotificationFilter(); + /** + * Settings of the turn currently running, plus whether it is being + * interrupted. Steering carries neither, so both have to be remembered + * here to decide whether a mid-turn send can fold in. + */ + const activeTurnRef = yield* Ref.make(undefined); + const pendingTurnStartIdRef = yield* Ref.make< + Deferred.Deferred | undefined + >(undefined); + // One runtime owns one provider thread. Serialize the complete + // decision/RPC/fallback span so concurrent sends always re-read the state + // produced by the preceding send before choosing steer versus start. + const turnSubmissionSemaphore = yield* Semaphore.make(1); const closedRef = yield* Ref.make(false); // `~` is not shell-expanded when env vars are set via @@ -1302,7 +1924,7 @@ export const makeCodexSessionRuntime = ( const isMemoryConsolidationNotification = suppressMemoryConsolidationNotification(notification); - const payload = notification.params; + let payload: unknown = notification.params; const route = readRouteFields(notification); const collabReceiverTurns = yield* Ref.get(collabReceiverTurnsRef); const childParentTurnId = (() => { @@ -1409,6 +2031,23 @@ export const makeCodexSessionRuntime = ( } } + if (notification.method === "turn/started") { + const pendingTurnStartId = yield* Ref.get(pendingTurnStartIdRef); + const authoritativeTurnId = pendingTurnStartId + ? yield* Deferred.await(pendingTurnStartId) + : (yield* Ref.get(sessionRef)).activeTurnId; + if (authoritativeTurnId) { + turnId = authoritativeTurnId; + payload = { + ...notification.params, + turn: { + ...notification.params.turn, + id: authoritativeTurnId, + }, + }; + } + } + yield* Ref.set(collabReceiverTurnsRef, collabReceiverTurns); yield* emitEvent({ kind: "notification", @@ -1446,10 +2085,15 @@ export const makeCodexSessionRuntime = ( if (providerThreadId && payload.threadId !== providerThreadId) { return Effect.void; } - return updateSession(sessionRef, { + return updateSession(sessionRef, (session) => ({ status: "running", - activeTurnId: TurnId.make(payload.turn.id), - }); + // Only fills a gap — it never renames a turn the runtime already + // tracks. A captured `/review` publishes a different id here than + // the one the server accepts for steer and interrupt, so letting + // this overwrite the start response's id would point both at an + // id the server rejects. + activeTurnId: session.activeTurnId ?? TurnId.make(payload.turn.id), + })); }), ), ); @@ -1468,26 +2112,43 @@ export const makeCodexSessionRuntime = ( status: payload.turn.status === "failed" ? "error" : "ready", activeTurnId: undefined, ...(lastError ? { lastError } : {}), - }); + }).pipe(Effect.andThen(Ref.set(activeTurnRef, undefined))); }), ), ); yield* client.handleServerNotification("error", (payload) => - currentSessionProviderThreadId.pipe( - Effect.flatMap((providerThreadId) => { - const payloadThreadId = payload.threadId; - if (providerThreadId && payloadThreadId && payloadThreadId !== providerThreadId) { - return Effect.void; - } - const errorMessage = payload.error.message; - const willRetry = payload.willRetry; - return updateSession(sessionRef, { - status: willRetry ? "running" : "error", + Effect.gen(function* () { + const session = yield* Ref.get(sessionRef); + const providerThreadId = currentProviderThreadId(session); + const payloadThreadId = payload.threadId; + if (providerThreadId && payloadThreadId && payloadThreadId !== providerThreadId) { + return; + } + const errorMessage = payload.error.message; + // The protocol makes `turnId` required. An error about some other + // turn must not rewrite this session's status, and — the bug this + // scoping fixes — a terminal error must not leave `activeTurnId` + // pointing at a turn that is already dead, which is what made a + // later send try to steer a turn that no longer existed. + if (session.activeTurnId === undefined || payload.turnId !== session.activeTurnId) { + return yield* errorMessage + ? updateSession(sessionRef, { lastError: errorMessage }) + : Effect.void; + } + if (payload.willRetry) { + return yield* updateSession(sessionRef, { + status: "running", ...(errorMessage ? { lastError: errorMessage } : {}), }); - }), - ), + } + yield* updateSession(sessionRef, { + status: "error", + activeTurnId: undefined, + ...(errorMessage ? { lastError: errorMessage } : {}), + }); + yield* Ref.set(activeTurnRef, undefined); + }), ); yield* client.handleServerRequest("item/commandExecution/requestApproval", (payload) => @@ -1723,6 +2384,7 @@ export const makeCodexSessionRuntime = ( status: nextStatus, activeTurnId: undefined, }).pipe( + Effect.andThen(Ref.set(activeTurnRef, undefined)), Effect.andThen( emitSessionEvent( "session/exited", @@ -1790,6 +2452,7 @@ export const makeCodexSessionRuntime = ( status: "closed", activeTurnId: undefined, }); + yield* Ref.set(activeTurnRef, undefined); yield* emitSessionEvent("session/closed", "Session stopped").pipe( Effect.catch((cause) => Effect.logError("Failed to emit Codex session closed event.", { cause }), @@ -1804,66 +2467,59 @@ export const makeCodexSessionRuntime = ( start, getSession: Ref.get(sessionRef), sendTurn: (input) => - Effect.gen(function* () { - const providerThreadId = yield* readProviderThreadId; - if (hasConfiguredMcpServer(options.appServerArgs)) { - yield* client.request("config/mcpServer/reload", undefined).pipe( - Effect.catch((cause) => - Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { - cause, - }), - ), - ); - } - const normalizedModel = normalizeCodexModelSlug( - input.model ?? (yield* Ref.get(sessionRef)).model, - ); - const params = yield* buildTurnStartParams({ - threadId: providerThreadId, - runtimeMode: options.runtimeMode, - ...(input.input ? { prompt: input.input } : {}), - ...(input.attachments ? { attachments: input.attachments } : {}), - ...(normalizedModel ? { model: normalizedModel } : {}), - ...(input.serviceTier ? { serviceTier: input.serviceTier } : {}), - ...(input.effort ? { effort: input.effort } : {}), - ...(input.interactionMode ? { interactionMode: input.interactionMode } : {}), - // Derived from the session's own MCP configuration rather than the - // setting, so the prompt describes the tools this turn actually - // has even if the setting changed after the session started. - browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs), - }); - const rawResponse = yield* client.raw.request("turn/start", params); - const response = yield* decodeV2TurnStartResponse(rawResponse).pipe( - Effect.mapError((error) => - CodexErrors.CodexAppServerProtocolParseError.fromSchemaError( - "decode-response-payload", - error, - { method: "turn/start" }, - ), - ), - ); - const turnId = TurnId.make(response.turn.id); - yield* updateSession(sessionRef, (session) => ({ - status: "running", - // Codex accepts follow-ups while the current turn is still - // running. The response contains the queued turn id, but - // turn/interrupt only accepts the id that is active now. - activeTurnId: session.activeTurnId ?? turnId, - ...(normalizedModel ? { model: normalizedModel } : {}), - })); - const resumedProviderThreadId = currentProviderThreadId(yield* Ref.get(sessionRef)); - return { - threadId: options.threadId, - turnId, - ...(resumedProviderThreadId - ? { resumeCursor: { threadId: resumedProviderThreadId } } - : {}), - } satisfies ProviderTurnStartResult; - }), + turnSubmissionSemaphore.withPermit( + Effect.gen(function* () { + // Fail before touching the MCP catalog when the session has no + // provider thread yet. + yield* readProviderThreadId; + if (hasConfiguredMcpServer(options.appServerArgs)) { + yield* client.request("config/mcpServer/reload", undefined).pipe( + Effect.catch((cause) => + Effect.logWarning("Failed to refresh Codex MCP tool catalog before turn.", { + cause, + }), + ), + ); + } + const skillInvocations = collectComposerSkillInvocations(input.input ?? ""); + let availableSkills: ReturnType | undefined; + if (skillInvocations.length > 0) { + const session = yield* Ref.get(sessionRef); + const cwd = session.cwd ?? options.cwd; + const skillsResponse = yield* client.request("skills/list", { cwds: [cwd] }); + availableSkills = parseCodexSkillsListResponse(skillsResponse, cwd); + } + return yield* sendCodexTurn({ + client, + sessionRef, + activeTurnRef, + pendingTurnStartIdRef, + threadId: options.threadId, + runtimeMode: options.runtimeMode, + turn: input, + ...(availableSkills ? { availableSkills } : {}), + // Derived from the session's own MCP configuration rather than + // settings, so the prompt matches the tools this turn has. + browserToolsAvailable: hasConfiguredMcpServer(options.appServerArgs), + }); + }), + ), interruptTurn: (turnId) => Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; const session = yield* Ref.get(sessionRef); + const effectiveTurnId = turnId ?? session.activeTurnId; + if (!effectiveTurnId) { + return; + } + const previousActiveTurn = yield* Ref.get(activeTurnRef); + // Stop makes the turn unsteerable from this instant, before any RPC + // goes out: a message typed right after Stop belongs to the next + // turn, not the one being aborted (it would otherwise be folded + // into a turn that is about to stop reading). + yield* Ref.update(activeTurnRef, (current) => + current?.turnId === effectiveTurnId ? { ...current, interrupting: true } : current, + ); // Stop-everything: children are full threads with their own turns; // interrupting only the parent leaves the fleet running. Interrupt // each live child turn first, best-effort per child, BOUNDED: the @@ -1884,14 +2540,22 @@ export const makeCodexSessionRuntime = ( .pipe(Effect.timeoutOption("3 seconds"), Effect.ignore), { concurrency: 8, discard: true }, ).pipe(Effect.timeoutOption("10 seconds"), Effect.ignore); - const effectiveTurnId = turnId ?? session.activeTurnId; - if (!effectiveTurnId) { - return; - } - yield* client.request("turn/interrupt", { - threadId: providerThreadId, - turnId: effectiveTurnId, - }); + yield* client + .request("turn/interrupt", { + threadId: providerThreadId, + turnId: effectiveTurnId, + }) + .pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) + ? Effect.void + : Ref.update(activeTurnRef, (current) => + current?.turnId === effectiveTurnId && current.interrupting + ? previousActiveTurn + : current, + ), + ), + ); }), readThread: Effect.gen(function* () { const providerThreadId = yield* readProviderThreadId; @@ -1912,6 +2576,7 @@ export const makeCodexSessionRuntime = ( status: "ready", activeTurnId: undefined, }); + yield* Ref.set(activeTurnRef, undefined); return parseThreadSnapshot(response); }), respondToRequest: (requestId, decision) => diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9a72ea83d3c0..10b81038213b 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -817,7 +817,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); - it.effect("does not run provider probes during layer construction", () => + it.effect("does not probe at construction and routes cwd-scoped skill discovery", () => Effect.gen(function* () { const codexDriver = ProviderDriverKind.make("codex"); const codexInstanceId = ProviderInstanceId.make("codex"); @@ -836,6 +836,13 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te skills: [], } as const satisfies ServerProvider; const refreshCalls = yield* Ref.make(0); + const skillCwds = yield* Ref.make>([]); + const workspaceSkill = { + name: "project-review", + path: "/tmp/project/.agents/skills/project-review/SKILL.md", + scope: "repo", + enabled: true, + } as const; const instance = { instanceId: codexInstanceId, driverKind: codexDriver, @@ -858,6 +865,8 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te }, adapter: {} as ProviderInstance["adapter"], textGeneration: {} as ProviderInstance["textGeneration"], + listSkills: (cwd: string) => + Ref.update(skillCwds, (cwds) => [...cwds, cwd]).pipe(Effect.as([workspaceSkill])), } satisfies ProviderInstance; const instanceRegistryLayer = Layer.succeed( ProviderInstanceRegistry.ProviderInstanceRegistry, @@ -887,6 +896,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te const registry = yield* ProviderRegistry.ProviderRegistry; assert.deepStrictEqual(yield* registry.getProviders, [initialProvider]); assert.strictEqual(yield* Ref.get(refreshCalls), 0); + assert.deepStrictEqual( + yield* registry.listSkills({ instanceId: codexInstanceId, cwd: "/tmp/project" }), + [workspaceSkill], + ); + assert.deepStrictEqual(yield* Ref.get(skillCwds), ["/tmp/project"]); }).pipe(Effect.provide(runtimeServices)); }), ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e8..76afb7d605fe 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -496,6 +496,20 @@ export const ProviderRegistryLive = Layer.effect( return yield* refreshOneSource(providerSource); }); + const listSkills = Effect.fn("listSkills")(function* (input: { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }) { + const instance = yield* instanceRegistry.getInstance(input.instanceId); + if (instance === undefined || !instance.enabled) { + return []; + } + if (instance.listSkills !== undefined) { + return yield* instance.listSkills(input.cwd); + } + return (yield* instance.snapshot.getSnapshot).skills; + }); + const getProviderMaintenanceCapabilitiesForInstance = Effect.fn( "getProviderMaintenanceCapabilitiesForInstance", )(function* (instanceId: ProviderInstanceId, provider: ProviderDriverKind) { @@ -710,6 +724,7 @@ export const ProviderRegistryLive = Layer.effect( refresh(provider).pipe(Effect.catchCause(recoverRefreshFailure)), refreshInstance: (instanceId: ProviderInstanceId) => refreshInstance(instanceId).pipe(Effect.catchCause(recoverRefreshFailure)), + listSkills, getProviderMaintenanceCapabilitiesForInstance, setProviderMaintenanceActionState, get streamChanges() { diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index 67b4bd9bd37c..fc5a38f1fb7d 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1111,6 +1111,55 @@ routing.layer("ProviderServiceLive routing", (it) => { }), ); + it.effect("does not persist a model selection a steered turn never applied", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + const directory = yield* ProviderSessionDirectory.ProviderSessionDirectory; + const threadId = asThreadId("thread-steer-model-selection"); + const runningModel = createModelSelection(codexInstanceId, "gpt-5.3-codex"); + + yield* provider.startSession(threadId, { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId, + cwd: "/tmp/project-steer-model-selection", + modelSelection: runningModel, + runtimeMode: "full-access", + }); + yield* provider.sendTurn({ + threadId, + input: "start working", + attachments: [], + modelSelection: runningModel, + }); + + // The adapter folded this one into the turn already running: its model + // selection never reached the model. + routing.codex.sendTurn.mockImplementationOnce((input: ProviderSendTurnInput) => + Effect.succeed({ + threadId: input.threadId, + turnId: asTurnId("turn-running"), + steered: true, + }), + ); + yield* provider.sendTurn({ + threadId, + input: "one more thing", + attachments: [], + modelSelection: createModelSelection(codexInstanceId, "gpt-5.4"), + }); + + const binding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + const runtimePayload = binding?.runtimePayload as + | { readonly modelSelection?: { readonly model?: string }; readonly activeTurnId?: string } + | undefined; + // Persisting it would make the UI claim a switch that never happened + // after a reconnect. + assert.equal(runtimePayload?.modelSelection?.model, "gpt-5.3-codex"); + assert.equal(runtimePayload?.activeTurnId, "turn-running"); + }), + ); + it.effect("dies when an active session conflicts with its persisted binding", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 8e7f9147dc3e..9782aa41a56c 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -792,7 +792,13 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( status: "running", ...(turn.resumeCursor !== undefined ? { resumeCursor: turn.resumeCursor } : {}), runtimePayload: { - ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), + // A steered message folds into a turn that is already running, so + // its model selection never reached the model. Persisting it would + // make the directory — and the UI after a reconnect — report a + // switch that did not happen. + ...(input.modelSelection !== undefined && turn.steered !== true + ? { modelSelection: input.modelSelection } + : {}), activeTurnId: turn.turnId, lastRuntimeEvent: "provider.sendTurn", lastRuntimeEventAt: yield* nowIso, diff --git a/apps/server/src/provider/Layers/codexSkillInvocations.test.ts b/apps/server/src/provider/Layers/codexSkillInvocations.test.ts new file mode 100644 index 000000000000..7db78620058b --- /dev/null +++ b/apps/server/src/provider/Layers/codexSkillInvocations.test.ts @@ -0,0 +1,184 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { bindCodexSkillInvocations } from "./codexSkillInvocations.ts"; + +const grillWithDocs = { + name: "grill-with-docs", + path: "/Users/me/.agents/skills/grill-with-docs/SKILL.md", + enabled: true, +}; + +const grilling = { + name: "grilling", + path: "/Users/me/.agents/skills/grilling/SKILL.md", + enabled: true, +}; + +describe("bindCodexSkillInvocations", () => { + it("attaches a known enabled skill as a structured skill item", () => { + expect( + bindCodexSkillInvocations("$grill-with-docs explain why this skill is not in the list", [ + grillWithDocs, + grilling, + ]), + ).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "grill-with-docs", + path: grillWithDocs.path, + }, + ], + }); + }); + + it("attaches a user-invoked-only skill when it is enabled in the list", () => { + expect(bindCodexSkillInvocations("Use $grill-with-docs please", [grillWithDocs])).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "grill-with-docs", + path: grillWithDocs.path, + }, + ], + }); + }); + + it("attaches an explicit token even when the listed skill is disabled", () => { + expect( + bindCodexSkillInvocations("$grill-with-docs go", [{ ...grillWithDocs, enabled: false }]), + ).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "grill-with-docs", + path: grillWithDocs.path, + }, + ], + }); + }); + + it("returns unknown names instead of attaching a silent token", () => { + expect(bindCodexSkillInvocations("$missing-skill do this", [grillWithDocs])).toEqual({ + ok: false, + names: ["missing-skill"], + }); + }); + + it("leaves messages without $skill tokens unchanged", () => { + expect(bindCodexSkillInvocations("explain this change", [grillWithDocs])).toEqual({ + ok: true, + inputs: [], + }); + expect(bindCodexSkillInvocations("costs $100 please", [grillWithDocs])).toEqual({ + ok: true, + inputs: [], + }); + expect(bindCodexSkillInvocations(undefined, [grillWithDocs])).toEqual({ + ok: true, + inputs: [], + }); + }); + + it("prefers an exact name match over an enabled case-insensitive skill", () => { + expect( + bindCodexSkillInvocations("$Foo go", [ + { name: "foo", path: "/skills/foo/SKILL.md", enabled: true }, + { name: "Foo", path: "/skills/Foo/SKILL.md", enabled: false }, + ]), + ).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "Foo", + path: "/skills/Foo/SKILL.md", + }, + ], + }); + }); + + it("falls back to a case-insensitive match when no exact name exists", () => { + expect( + bindCodexSkillInvocations("$Foo go", [ + { name: "foo", path: "/skills/foo/SKILL.md", enabled: true }, + ]), + ).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "foo", + path: "/skills/foo/SKILL.md", + }, + ], + }); + }); + + it("attaches a skill invoked with trailing punctuation", () => { + expect(bindCodexSkillInvocations("Use $grill-with-docs.", [grillWithDocs])).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "grill-with-docs", + path: grillWithDocs.path, + }, + ], + }); + }); + + it("does not treat shell paths as unknown skills", () => { + expect(bindCodexSkillInvocations("check $HOME/.config", [grillWithDocs])).toEqual({ + ok: true, + inputs: [], + }); + expect(bindCodexSkillInvocations("read $FOO/bar", [grillWithDocs])).toEqual({ + ok: true, + inputs: [], + }); + }); + + it("attaches one skill item when case variants resolve to the same path", () => { + expect( + bindCodexSkillInvocations("$Foo then $foo", [ + { name: "foo", path: "/skills/foo/SKILL.md", enabled: true }, + ]), + ).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "foo", + path: "/skills/foo/SKILL.md", + }, + ], + }); + }); + + it("attaches each distinct skill once and keeps list order", () => { + expect( + bindCodexSkillInvocations("$grill-with-docs then $grilling then $grill-with-docs", [ + grillWithDocs, + grilling, + ]), + ).toEqual({ + ok: true, + inputs: [ + { + type: "skill", + name: "grill-with-docs", + path: grillWithDocs.path, + }, + { + type: "skill", + name: "grilling", + path: grilling.path, + }, + ], + }); + }); +}); diff --git a/apps/server/src/provider/Layers/codexSkillInvocations.ts b/apps/server/src/provider/Layers/codexSkillInvocations.ts new file mode 100644 index 000000000000..12552a2d645f --- /dev/null +++ b/apps/server/src/provider/Layers/codexSkillInvocations.ts @@ -0,0 +1,61 @@ +import type { ServerProviderSkill } from "@t3tools/contracts"; +import { collectComposerSkillInvocations } from "@t3tools/shared/composerInlineTokens"; + +export type CodexSkillUserInput = { + readonly type: "skill"; + readonly name: string; + readonly path: string; +}; + +export type BindCodexSkillInvocationsResult = + | { readonly ok: true; readonly inputs: ReadonlyArray } + | { readonly ok: false; readonly names: readonly string[] }; + +function findCodexSkill( + name: string, + skills: ReadonlyArray>, +): Pick | undefined { + const exact = skills.find((skill) => skill.name === name); + if (exact) { + return exact; + } + const lower = name.toLowerCase(); + const matches = skills.filter((skill) => skill.name.toLowerCase() === lower); + return matches.find((skill) => skill.enabled) ?? matches[0]; +} + +export function bindCodexSkillInvocations( + prompt: string | undefined, + skills: ReadonlyArray>, +): BindCodexSkillInvocationsResult { + const invocations = collectComposerSkillInvocations(prompt ?? ""); + if (invocations.length === 0) { + return { ok: true, inputs: [] }; + } + + const inputs: CodexSkillUserInput[] = []; + const unknown: string[] = []; + const seenPaths = new Set(); + + for (const name of invocations) { + const skill = findCodexSkill(name, skills); + if (!skill) { + unknown.push(name); + continue; + } + if (seenPaths.has(skill.path)) { + continue; + } + seenPaths.add(skill.path); + inputs.push({ + type: "skill", + name: skill.name, + path: skill.path, + }); + } + + if (unknown.length > 0) { + return { ok: false, names: unknown }; + } + return { ok: true, inputs }; +} diff --git a/apps/server/src/provider/ProviderDriver.ts b/apps/server/src/provider/ProviderDriver.ts index c738882c23a4..2a2d9d0c85e2 100644 --- a/apps/server/src/provider/ProviderDriver.ts +++ b/apps/server/src/provider/ProviderDriver.ts @@ -25,6 +25,7 @@ import type { ProviderDriverKind, ProviderInstanceEnvironment, ProviderInstanceId, + ServerProviderSkill, } from "@t3tools/contracts"; import type * as Effect from "effect/Effect"; import type * as Schema from "effect/Schema"; @@ -71,6 +72,8 @@ export interface ProviderInstance { readonly snapshot: ServerProviderShape; readonly adapter: ProviderAdapterShape; readonly textGeneration: TextGeneration.TextGeneration["Service"]; + /** Resolve the skills visible from a workspace cwd. */ + readonly listSkills?: (cwd: string) => Effect.Effect>; } export interface ProviderContinuationIdentity { diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd7..780cfc724472 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -42,6 +42,17 @@ export interface ProviderThreadSnapshot { readonly turns: ReadonlyArray; } +/** + * Server-internal widening of the wire contract: adapters that fold a + * mid-turn message into the running turn report it here, so callers can tell + * "a new turn started" from "an existing turn absorbed this message" without + * inferring it from turn ids. Absent means a new turn, which is what every + * adapter that cannot steer reports. + */ +export interface ProviderAdapterTurnStartResult extends ProviderTurnStartResult { + readonly steered?: boolean; +} + export interface ProviderAdapterShape { /** * Provider kind implemented by this adapter. @@ -61,7 +72,7 @@ export interface ProviderAdapterShape { */ readonly sendTurn: ( input: ProviderSendTurnInput, - ) => Effect.Effect; + ) => Effect.Effect; /** * Interrupt an active turn. diff --git a/apps/server/src/provider/Services/ProviderRegistry.ts b/apps/server/src/provider/Services/ProviderRegistry.ts index b7426b303381..2cd323716875 100644 --- a/apps/server/src/provider/Services/ProviderRegistry.ts +++ b/apps/server/src/provider/Services/ProviderRegistry.ts @@ -10,6 +10,7 @@ import type { ProviderInstanceId, ProviderDriverKind, ServerProvider, + ServerProviderSkill, ServerProviderUpdateState, } from "@t3tools/contracts"; import * as Context from "effect/Context"; @@ -19,6 +20,11 @@ import type { ProviderMaintenanceCapabilities } from "../providerMaintenance.ts" export type ProviderMaintenanceActionKind = "update"; +export interface ProviderRegistryListSkillsInput { + readonly instanceId: ProviderInstanceId; + readonly cwd: string; +} + export interface ProviderRegistryShape { /** * Read the latest provider snapshots for every configured instance. @@ -48,6 +54,11 @@ export interface ProviderRegistryShape { instanceId: ProviderInstanceId, ) => Effect.Effect>; + /** Resolve provider skills using the active workspace cwd. */ + readonly listSkills: ( + input: ProviderRegistryListSkillsInput, + ) => Effect.Effect>; + /** * Resolve the maintenance capabilities owned by one live provider instance. * Falls back to manual-only capabilities when the instance is not live. diff --git a/apps/server/src/provider/providerMaintenanceRunner.test.ts b/apps/server/src/provider/providerMaintenanceRunner.test.ts index 641c9b52e56c..df8f6b8dc862 100644 --- a/apps/server/src/provider/providerMaintenanceRunner.test.ts +++ b/apps/server/src/provider/providerMaintenanceRunner.test.ts @@ -187,6 +187,7 @@ function makeRegistry( const registry: ProviderRegistryShape = { getProviders: Ref.get(providersRef), + listSkills: () => Effect.succeed([]), refresh: () => Ref.get(providersRef), refreshInstance: () => Ref.get(providersRef), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs index f06e984c9aa5..b7147da4938e 100644 --- a/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs +++ b/apps/server/src/provider/testFixtures/codexCollabMockPeer.mjs @@ -16,7 +16,16 @@ const fixture = JSON.parse( const script = JSON.parse(NodeFS.readFileSync(process.env.T3_CODEX_COLLAB_SCRIPT, "utf8")); const write = (message) => process.stdout.write(`${JSON.stringify(message)}\n`); +const appendSidecar = (suffix, entry) => + NodeFS.appendFileSync( + `${process.env.T3_CODEX_COLLAB_SCRIPT}.${suffix}`, + `${JSON.stringify(entry)}\n`, + ); let turnStartCount = 0; +/** Turn id the thread is currently running, mirroring the app-server's own + * precondition state: `turn/steer` is only accepted against this id. */ +let activeTurnId; +let steerCount = 0; const rl = NodeReadline.createInterface({ input: process.stdin }); rl.on("line", (line) => { @@ -49,19 +58,58 @@ rl.on("line", (line) => { ? { ...fixture.responses.turnStart.turn, id: turnId } : fixture.responses.turnStart.turn; turnStartCount += 1; - write({ id, result: { ...fixture.responses.turnStart, turn } }); + appendSidecar("starts", { turnId: turn.id, input: message.params?.input }); const rootThreadId = script.rootThreadId; - if (script.onlyFirstTurnStarts !== true || turnStartCount === 1) { + // `turn/started` normally follows the response, but the notification can + // win the race on a real app-server. `turnStartedBeforeResponse` replays + // that ordering, and `startedTurnIdOverride` lets it name a different id + // than the response so the runtime's preference between the two is + // actually observable. + const startedTurn = script.startedTurnIdOverride + ? { ...turn, id: script.startedTurnIdOverride } + : turn; + if (activeTurnId !== undefined) { + // Captured codex-cli 0.147.0 behavior: mid-turn `turn/start` returns a + // different phantom id but folds the user message into the active turn. + // It does not start a second lifecycle and must not replace the active + // id the peer validates for steer/interrupt. + write({ id, result: { ...fixture.responses.turnStart, turn } }); + const foldedItem = { + id: `mid-turn-start-item-${turnStartCount}`, + type: "userMessage", + text: message.params?.input?.find((entry) => entry.type === "text")?.text ?? "", + }; + for (const itemMethod of ["item/started", "item/completed"]) { + write({ + jsonrpc: "2.0", + method: itemMethod, + params: { threadId: script.rootThreadId, turnId: activeTurnId, item: foldedItem }, + }); + } + return; + } + // The server validates steer/interrupt against the id it RETURNS, even + // when the notification publishes another one (captured `/review` + // behaviour), so the response id is the authoritative active turn here. + activeTurnId = turn.id; + const writeTurnStarted = () => write({ jsonrpc: "2.0", method: "turn/started", - params: { threadId: rootThreadId, turn }, + params: { threadId: rootThreadId, turn: startedTurn }, }); + if (script.turnStartedBeforeResponse === true) { + writeTurnStarted(); + write({ id, result: { ...fixture.responses.turnStart, turn } }); + } else { + write({ id, result: { ...fixture.responses.turnStart, turn } }); + writeTurnStarted(); } for (const notification of script.notifications) { write({ jsonrpc: "2.0", method: notification.method, params: notification.params }); } if (script.holdTurnOpen !== true) { + activeTurnId = undefined; write({ jsonrpc: "2.0", method: "turn/completed", @@ -73,6 +121,86 @@ rl.on("line", (line) => { } return; } + if (method === "turn/steer") { + // Record the steer (append-only sidecar the test reads) so mid-turn send + // coverage can assert the message folded into the running turn. + steerCount += 1; + appendSidecar("steers", { + threadId: message.params?.threadId, + expectedTurnId: message.params?.expectedTurnId, + input: message.params?.input, + }); + if (script.endTurnBeforeFirstSteer === true && steerCount === 1) { + const endedTurnId = activeTurnId; + activeTurnId = undefined; + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { + threadId: script.rootThreadId, + turn: { ...fixture.responses.turnStart.turn, id: endedTurnId, status: "completed" }, + }, + }); + } + if (script.deferStaleSteerResponses === true && activeTurnId === undefined) { + setImmediate(() => { + write({ id, error: { code: -32600, message: "no active turn to steer" } }); + }); + return; + } + // `steerRejectAfter` lets a script serve N steers then refuse. + const rejectNow = + script.steerRejection && + (script.steerRejectAfter === undefined || steerCount > script.steerRejectAfter); + if (rejectNow) { + write({ id, error: script.steerRejection }); + return; + } + // expectedTurnId is a precondition on the real app-server. Both refusals + // are quoted from captured transcripts (codex-cli 0.147.0): bare + // `{code: -32600, message}`, with no `data` and no structured error info. + if (activeTurnId === undefined) { + write({ id, error: { code: -32600, message: "no active turn to steer" } }); + return; + } + if (message.params?.expectedTurnId !== activeTurnId) { + write({ + id, + error: { + code: -32600, + message: `expected active turn id \`${message.params?.expectedTurnId}\` but found \`${activeTurnId}\``, + }, + }); + return; + } + // Captured shape (codex-cli 0.147.0): the steered message joins the + // running turn as a `userMessage` item with both `item/started` and + // `item/completed` carrying the ORIGINAL turn id, and the response + // echoes that same id. The turn keeps running and completes once. + const steerItem = { + id: `steer-item-${steerCount}`, + type: "userMessage", + text: message.params?.input?.find((entry) => entry.type === "text")?.text ?? "", + }; + for (const itemMethod of ["item/started", "item/completed"]) { + write({ + jsonrpc: "2.0", + method: itemMethod, + params: { threadId: script.rootThreadId, turnId: activeTurnId, item: steerItem }, + }); + } + write({ id, result: { turnId: activeTurnId } }); + if (script.completeTurnAfterSteer === true) { + const steeredTurn = { ...fixture.responses.turnStart.turn, id: activeTurnId }; + activeTurnId = undefined; + write({ + jsonrpc: "2.0", + method: "turn/completed", + params: { threadId: script.rootThreadId, turn: { ...steeredTurn, status: "completed" } }, + }); + } + return; + } if (method === "turn/interrupt") { // Record which thread/turn was interrupted (append-only sidecar file the // test reads) so Stop coverage can assert every live child was reached. diff --git a/apps/server/src/provider/testUtils/providerRegistryMock.ts b/apps/server/src/provider/testUtils/providerRegistryMock.ts index 36598b059009..dff3834d81c7 100644 --- a/apps/server/src/provider/testUtils/providerRegistryMock.ts +++ b/apps/server/src/provider/testUtils/providerRegistryMock.ts @@ -9,6 +9,7 @@ export const makeProviderRegistryMock = ( providers: ReadonlyArray = [], ): ProviderRegistryShape => ({ getProviders: Effect.succeed(providers), + listSkills: () => Effect.succeed([]), refresh: () => Effect.succeed(providers), refreshInstance: () => Effect.succeed(providers), getProviderMaintenanceCapabilitiesForInstance: (_instanceId, provider) => diff --git a/apps/server/src/relay/AgentAwarenessRelay.ts b/apps/server/src/relay/AgentAwarenessRelay.ts index 5127ecf7d359..69eb6992ea04 100644 --- a/apps/server/src/relay/AgentAwarenessRelay.ts +++ b/apps/server/src/relay/AgentAwarenessRelay.ts @@ -69,6 +69,9 @@ export function eventThreadId(event: OrchestrationEvent): ThreadId | null { export function shouldPublishAgentAwarenessEvent(event: OrchestrationEvent): boolean { switch (event.type) { case "thread.message-sent": + case "thread.turn-queued": + case "thread.queued-turn-dispatched": + case "thread.queued-turn-cancelled": case "thread.turn-start-requested": // These events express intent to start work, but the shell still contains // the previous turn's terminal state until the provider acknowledges the diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 44bbc7131e32..7402b065654e 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4409,6 +4409,85 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("routes websocket rpc server.listProviderSkills with the active workspace cwd", () => + Effect.gen(function* () { + const instanceId = ProviderInstanceId.make("codex_work"); + const projectId = ProjectId.make("project-skills"); + const threadId = ThreadId.make("thread-skills"); + const skills = [ + { + name: "project-review", + path: "/tmp/project-worktree/.agents/skills/project-review/SKILL.md", + scope: "repo", + enabled: true, + }, + ] as const; + const receivedInputs: Array<{ + readonly instanceId: ProviderInstanceId; + readonly cwd: string; + }> = []; + + yield* buildAppUnderTest({ + layers: { + providerRegistry: { + listSkills: (input) => + Effect.sync(() => { + receivedInputs.push(input); + return skills; + }), + }, + projectionSnapshotQuery: { + getProjectShellById: (requestedProjectId) => + Effect.succeed( + Option.some({ + ...makeDefaultOrchestrationReadModel().projects[0]!, + id: requestedProjectId, + workspaceRoot: "/tmp/project", + }), + ), + getThreadShellById: (requestedThreadId) => + Effect.succeed( + Option.some( + makeDefaultOrchestrationThreadShell({ + id: requestedThreadId, + projectId, + worktreePath: "/tmp/project-worktree", + }), + ), + ), + }, + }, + }); + + const wsUrl = yield* getWsServerUrl("/ws"); + const responses = yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + Effect.gen(function* () { + const projectResponse = yield* client[WS_METHODS.serverListProviderSkills]({ + instanceId, + projectId, + }); + const threadResponse = yield* client[WS_METHODS.serverListProviderSkills]({ + instanceId, + projectId, + threadId, + }); + return [projectResponse, threadResponse] as const; + }), + ), + ); + + assert.deepEqual(receivedInputs, [ + { instanceId, cwd: "/tmp/project" }, + { instanceId, cwd: "/tmp/project-worktree" }, + ]); + assert.deepEqual( + responses.map((response) => response.skills), + [skills, skills], + ); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc server.removeKeybinding", () => Effect.gen(function* () { const rule: KeybindingRule = { diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 19bc916d8114..5e331da7d232 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -1484,6 +1484,36 @@ const makeWsRpcLayer = ( ).pipe(Effect.map((providers) => ({ providers }))), { "rpc.aggregate": "server" }, ), + [WS_METHODS.serverListProviderSkills]: (input) => + observeRpcEffect( + WS_METHODS.serverListProviderSkills, + Effect.gen(function* () { + const project = yield* projectionSnapshotQuery + .getProjectShellById(input.projectId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(project)) { + return { skills: [] }; + } + + let cwd = project.value.workspaceRoot; + if (input.threadId !== undefined) { + const thread = yield* projectionSnapshotQuery + .getThreadShellById(input.threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + if (Option.isNone(thread) || thread.value.projectId !== input.projectId) { + return { skills: [] }; + } + cwd = thread.value.worktreePath ?? cwd; + } + + const skills = yield* providerRegistry.listSkills({ + instanceId: input.instanceId, + cwd, + }); + return { skills }; + }), + { "rpc.aggregate": "server" }, + ), [WS_METHODS.serverUpdateProvider]: (input) => observeRpcEffect( WS_METHODS.serverUpdateProvider, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 643aa4d774fd..93ccfb39b32e 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -14,6 +14,7 @@ import { type ResolvedKeybindingsConfig, type ScopedThreadRef, type ThreadId, + type ThreadTurnDeliveryMode, type TurnId, type KeybindingCommand, OrchestrationThreadActivity, @@ -46,6 +47,7 @@ import { import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; +import { resolveComposerDeliveryMode } from "@t3tools/shared/threadTurnDelivery"; import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; @@ -1237,6 +1239,10 @@ function ChatViewContent(props: ChatViewProps) { reportFailure: false, }); const startThreadTurn = useAtomCommand(threadEnvironment.startTurn, { reportFailure: false }); + const cancelQueuedTurn = useAtomCommand( + threadEnvironment.cancelQueuedTurn, + "queued message cancellation", + ); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, { reportFailure: false, }); @@ -4977,6 +4983,7 @@ function ChatViewContent(props: ChatViewProps) { const onSend = async ( e?: { preventDefault: () => void }, + deliveryMode?: ThreadTurnDeliveryMode, directAnnotation?: { annotation: PreviewAnnotationPayload; image: ComposerImageAttachment | null; @@ -5205,6 +5212,10 @@ function ChatViewContent(props: ChatViewProps) { const messageIdForSend = newMessageId(); const messageCreatedAt = new Date().toISOString(); + const resolvedDeliveryMode = resolveComposerDeliveryMode({ + hasActiveTurn: phase === "running", + ...(deliveryMode ? { requested: deliveryMode } : {}), + }); const turnAttachmentsPromise = Promise.all( composerImagesSnapshot.map(async (image) => ({ type: "image" as const, @@ -5377,6 +5388,7 @@ function ChatViewContent(props: ChatViewProps) { titleSeed: title, runtimeMode, interactionMode, + deliveryMode: resolvedDeliveryMode, ...(bootstrap ? { bootstrap } : {}), createdAt: messageCreatedAt, }, @@ -6109,6 +6121,21 @@ function ChatViewContent(props: ChatViewProps) { } void onRevertToTurnCountRef.current(targetTurnCount); }, []); + const onCancelQueuedMessage = useCallback( + (messageId: MessageId) => { + if (!activeThread) { + return; + } + void cancelQueuedTurn({ + environmentId: activeThread.environmentId, + input: { + threadId: activeThread.id, + messageId, + }, + }); + }, + [activeThread, cancelQueuedTurn], + ); // Empty state: no active thread if (!activeThread) { @@ -6161,7 +6188,7 @@ function ChatViewContent(props: ChatViewProps) { configuredUrls={configuredPreviewUrls} visible onSendAnnotation={(annotation, image) => { - void onSend(undefined, { annotation, image }); + void onSend(undefined, undefined, { annotation, image }); }} /> @@ -6391,6 +6418,7 @@ function ChatViewContent(props: ChatViewProps) { onOpenTurnDiff={onOpenTurnDiff} revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} + onCancelQueuedMessage={onCancelQueuedMessage} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} @@ -6492,6 +6520,7 @@ function ChatViewContent(props: ChatViewProps) { routeKind={routeKind} routeThreadRef={routeThreadRef} draftId={draftId} + activeProjectId={activeProject?.id ?? null} activeThreadId={activeThreadId} activeThreadEnvironmentId={activeThread?.environmentId} activeThread={activeThread} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 07a9afecbd9f..77ee41ded093 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -3,13 +3,16 @@ import type { EnvironmentId, ModelSelection, PreviewAnnotationPayload, + ProjectId, ProviderApprovalDecision, ProviderInteractionMode, ResolvedKeybindingsConfig, RuntimeMode, ScopedThreadRef, ServerProvider, + ServerProviderSkill, ThreadId, + ThreadTurnDeliveryMode, } from "@t3tools/contracts"; import { isProviderSendTurnSupportedImageMimeType, @@ -76,6 +79,7 @@ import { removeInlineTerminalContextPlaceholder, } from "../../lib/terminalContext"; import { useComposerPathSearch } from "../../lib/composerPathSearchState"; +import { useProviderSkills } from "../../state/queries"; import { type ElementContextDraft } from "../../lib/elementContext"; import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts"; import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments"; @@ -415,6 +419,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( showSendWhileRunning?: boolean; onPreviousPendingQuestion: () => void; onInterrupt: () => void; + onSteer: () => void; onImplementPlanInNewThread: () => void; }) { return ( @@ -444,6 +449,7 @@ const ComposerFooterPrimaryActions = memo(function ComposerFooterPrimaryActions( showSendWhileRunning={props.showSendWhileRunning ?? false} onPreviousPendingQuestion={props.onPreviousPendingQuestion} onInterrupt={props.onInterrupt} + onSteer={props.onSteer} onImplementPlanInNewThread={props.onImplementPlanInNewThread} /> @@ -508,6 +514,7 @@ export interface ChatComposerProps { draftId: DraftId | null; // Thread context + activeProjectId: ProjectId | null; activeThreadId: ThreadId | null; activeThreadEnvironmentId: EnvironmentId | undefined; activeThread: Thread | undefined; @@ -576,7 +583,7 @@ export interface ChatComposerProps { composerRef: React.RefObject; // Callbacks - onSend: (e?: { preventDefault: () => void }) => void; + onSend: (e?: { preventDefault: () => void }, deliveryMode?: ThreadTurnDeliveryMode) => void; onInterrupt: () => void; onImplementPlanInNewThread: () => void; onRespondToApproval: ( @@ -617,10 +624,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) routeKind, routeThreadRef, draftId, + activeProjectId, activeThreadId, activeThreadEnvironmentId: _activeThreadEnvironmentId, activeThread, - isServerThread: _isServerThread, + isServerThread, isLocalDraftThread: _isLocalDraftThread, forceExpandedOnMobile, projectSelectionRequired, @@ -859,7 +867,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) () => selectedProviderEntry?.models ?? [], [selectedProviderEntry], ); - const composerPromptInjectionState = useMemo( () => getComposerPromptInjectionState(prompt), [prompt], @@ -1031,6 +1038,33 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) cwd: isPathTrigger ? gitCwd : null, query: isPathTrigger ? pathTriggerQuery : null, }); + const providerSkillsThreadId = isServerThread ? activeThreadId : null; + const providerSkills = useProviderSkills({ + environmentId, + instanceId: selectedInstanceId, + projectId: activeProjectId, + threadId: providerSkillsThreadId, + enabled: composerTriggerKind === "skill", + }); + const providerSkillsTargetKey = `${environmentId}\0${selectedInstanceId}\0${activeProjectId ?? ""}\0${providerSkillsThreadId ?? ""}`; + const [cachedProviderSkills, setCachedProviderSkills] = useState<{ + readonly targetKey: string; + readonly skills: ReadonlyArray; + } | null>(null); + useEffect(() => { + if (providerSkills.data) { + setCachedProviderSkills({ + targetKey: providerSkillsTargetKey, + skills: providerSkills.data.skills, + }); + } + }, [providerSkills.data, providerSkillsTargetKey]); + const selectedProviderSkills = + providerSkills.data?.skills ?? + (cachedProviderSkills?.targetKey === providerSkillsTargetKey + ? cachedProviderSkills.skills + : selectedProviderStatus?.skills.filter((skill) => skill.scope !== "repo")) ?? + []; const composerMenuItems = useMemo(() => { if (!composerTrigger) return []; @@ -1090,25 +1124,24 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) return searchSlashCommandItems(slashCommandItems, query); } if (composerTrigger.kind === "skill") { - return searchProviderSkills(selectedProviderStatus?.skills ?? [], composerTrigger.query).map( - (skill) => ({ - id: `skill:${selectedProvider}:${skill.name}`, - type: "skill" as const, - provider: selectedProvider, - skill, - label: formatProviderSkillDisplayName(skill), - description: - skill.shortDescription ?? - skill.description ?? - (skill.scope ? `${skill.scope} skill` : "Run provider skill"), - }), - ); + return searchProviderSkills(selectedProviderSkills, composerTrigger.query).map((skill) => ({ + id: `skill:${selectedProvider}:${skill.name}`, + type: "skill" as const, + provider: selectedProvider, + skill, + label: formatProviderSkillDisplayName(skill), + description: + skill.shortDescription ?? + skill.description ?? + (skill.scope ? `${skill.scope} skill` : "Run provider skill"), + })); } return []; }, [ composerTrigger, planModeUiEnabled, selectedProvider, + selectedProviderSkills, selectedProviderStatus, workspaceEntries.entries, ]); @@ -1175,7 +1208,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const isComposerMenuLoading = - composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending; + (composerTriggerKind === "path" && pathTriggerQuery.length > 0 && workspaceEntries.isPending) || + (composerTriggerKind === "skill" && providerSkills.isPending && composerMenuItems.length === 0); const composerMenuEmptyState = useMemo(() => { if (composerTriggerKind === "skill") { return "No skills found. Try / to browse provider commands."; @@ -1240,7 +1274,6 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) [activePendingIsResponding, activePendingProgress, activePendingResolvedAnswers], ); const collapsedComposerPrimaryActionDisabled = - phase === "running" || isSendBusy || isSendDisabled || isConnecting || @@ -1248,7 +1281,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) projectSelectionRequired || environmentUnavailable !== null || !composerSendState.hasSendableContent; - const collapsedComposerPrimaryActionLabel = "Send message"; + const collapsedComposerPrimaryActionLabel = + phase === "running" ? "Queue message" : "Send message"; const showMobilePendingAnswerActions = isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null; @@ -1836,7 +1870,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ]); const submitComposer = useCallback( - (event?: { preventDefault: () => void }) => { + (event?: { preventDefault: () => void }, deliveryMode?: ThreadTurnDeliveryMode) => { if (noProviderAvailable || isSendDisabled) { event?.preventDefault(); return; @@ -1862,7 +1896,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) // ChatView reports its final composed-input preflight through the // composer handle before its first asynchronous send step. providerInputRejectedRef.current = false; - onSend(sendEvent); + onSend(sendEvent, deliveryMode); return !providerInputRejectedRef.current; }, }); @@ -2490,6 +2524,9 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const handleInterruptPrimaryAction = useCallback(() => { void onInterrupt(); }, [onInterrupt]); + const handleSteerPrimaryAction = useCallback(() => { + submitComposer(undefined, "immediate"); + }, [submitComposer]); const handleImplementPlanInNewThreadPrimaryAction = useCallback(() => { void onImplementPlanInNewThread(); }, [onImplementPlanInNewThread]); @@ -2821,6 +2858,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) preserveComposerFocusOnPointerDown onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} + onSteer={handleSteerPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} /> ) : null} @@ -2849,6 +2887,21 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) : prompt.trim() || (noProviderAvailable ? "Enable a provider in Settings" : "Ask anything...")} + {phase === "running" && composerSendState.hasSendableContent ? ( + + ) : null} + + + ) : null} ); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 617ee0b80d1c..e597c5656e6e 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -186,6 +186,7 @@ function buildProps() { onOpenTurnDiff: () => {}, revertTurnCountByUserMessageId: new Map(), onRevertUserMessage: () => {}, + onCancelQueuedMessage: () => {}, isRevertingCheckpoint: false, onImageExpand: () => {}, activeThreadEnvironmentId: ACTIVE_THREAD_ENVIRONMENT_ID, @@ -475,6 +476,27 @@ describe("MessagesTimeline", () => { expect(markup).toContain("rounded-2xl bg-message p-3"); }); + it("keeps server-queued messages visibly cancellable", () => { + const entry = buildUserTimelineEntry("Run this next"); + const markup = renderToStaticMarkup( + , + ); + + expect(markup).toContain("Queued"); + expect(markup).toContain("Cancel"); + expect(markup).toContain('aria-label="Cancel queued message"'); + expect(markup).toContain('data-slot="button"'); + expect(markup).toContain("opacity-100"); + }); + it("preserves arbitrary XML-like tags and comparisons in rendered user messages", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index c90aa771f8d1..0ebc0ccee91a 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -139,6 +139,7 @@ interface TimelineRowSharedState { skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; + onCancelQueuedMessage: (messageId: MessageId) => void; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; @@ -218,6 +219,7 @@ interface MessagesTimelineProps { onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; + onCancelQueuedMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; activeThreadEnvironmentId: EnvironmentId; @@ -264,6 +266,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onOpenTurnDiff, revertTurnCountByUserMessageId, onRevertUserMessage, + onCancelQueuedMessage, isRevertingCheckpoint, onImageExpand, activeThreadEnvironmentId, @@ -512,6 +515,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, + onCancelQueuedMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, @@ -528,6 +532,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, + onCancelQueuedMessage, onImageExpand, onOpenTurnDiff, onToggleTurnFold, @@ -1035,8 +1040,28 @@ function UserTimelineRow({ row }: { row: Extract -
+
+ {row.message.deliveryState === "queued" ? ( +
+ Queued + +
+ ) : null} }> {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} @@ -2235,7 +2260,12 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { const [expanded, setExpanded] = useState(false); const iconConfig = workToneIcon(workEntry.tone); const showWarningIndicator = workEntry.sourceActivityKind === "runtime.warning"; - const entryIconName = showWarningIndicator ? "x" : workEntryIconName(workEntry); + const showRejectedIndicator = workEntry.sourceActivityKind === "provider.turn.steer.rejected"; + const entryIconName = showWarningIndicator + ? "x" + : showRejectedIndicator + ? "circle-alert" + : workEntryIconName(workEntry); const heading = toolWorkEntryHeading(workEntry); const rawPreview = workEntryPreview(workEntry, workspaceRoot); const preview = @@ -2255,17 +2285,21 @@ const PlainWorkEntryRow = memo(function PlainWorkEntryRow(props: { "flex size-5 shrink-0 items-center justify-center", showWarningIndicator ? "text-destructive" - : showDestructiveRowStyle - ? "text-destructive" - : workEntry.tone === "tool" || showFailedIndicator - ? "text-icon-muted" - : iconConfig.className, + : showRejectedIndicator + ? "text-warning" + : showDestructiveRowStyle + ? "text-destructive" + : workEntry.tone === "tool" || showFailedIndicator + ? "text-icon-muted" + : iconConfig.className, ); const headingClass = showWarningIndicator ? "font-medium text-warning" - : showDestructiveRowStyle - ? "font-medium text-destructive" - : "font-medium text-foreground"; + : showRejectedIndicator + ? "font-medium text-warning" + : showDestructiveRowStyle + ? "font-medium text-destructive" + : "font-medium text-foreground"; const turnSettled = !activity.activeTurnInProgress; const showNeutralIndicator = !turnSettled && workEntryIndicatesToolNeutralStatus(workEntry); const showSuccessIndicator = diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 094db94c4dcf..9027d769fd66 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -14,6 +14,8 @@ import type { OrchestrationThread, ProjectContentMatch, ProjectEntryKind, + ProjectId, + ProviderInstanceId, ThreadId, VcsListRefsResult, VcsRef, @@ -28,6 +30,7 @@ import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; +import { serverEnvironment } from "./server"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; @@ -302,6 +305,30 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); } +export function useProviderSkills(target: { + readonly environmentId: EnvironmentId | null; + readonly instanceId: ProviderInstanceId | null; + readonly projectId: ProjectId | null; + readonly threadId: ThreadId | null; + readonly enabled: boolean; +}) { + return useEnvironmentQuery( + target.enabled && + target.environmentId !== null && + target.instanceId !== null && + target.projectId !== null + ? serverEnvironment.providerSkills({ + environmentId: target.environmentId, + input: { + instanceId: target.instanceId, + projectId: target.projectId, + ...(target.threadId !== null ? { threadId: target.threadId } : {}), + }, + }) + : null, + ); +} + interface ProjectContentSearchTarget { readonly environmentId: EnvironmentId | null; readonly cwd: string | null; diff --git a/docs/personal-fork-changes.md b/docs/personal-fork-changes.md index 5965dd5c36df..bab79de9c0f9 100644 --- a/docs/personal-fork-changes.md +++ b/docs/personal-fork-changes.md @@ -1,8 +1,9 @@ # Personal Fork Changes -The personal fork intentionally maintains four product differences from `upstream/main`: desktop -fork identity, completion/attention sounds, native macOS completion notifications, and -worktree-grouped web/desktop threads. Everything else follows upstream directly. +The personal fork intentionally maintains six product differences from `upstream/main`: desktop +fork identity, completion/attention sounds, native macOS completion notifications, +worktree-grouped web/desktop threads, Codex skill handling, and queue-first active-turn delivery. +Everything else follows upstream directly. This file is both the current inventory and the retirement record used during upstream syncs. @@ -66,6 +67,41 @@ This file is both the current inventory and the retirement record used during up extended rather than replaced. Future merges must preserve upstream search, drafts, pinning and reorder, lifecycle/context-menu actions, provider badges, shelf persistence, and PR snapshots. +### Codex project skills and explicit invocation + +- Codex skill discovery follows the active project or worktree instead of only the server process + directory, so the composer shows workspace-local skills alongside personal skills. +- Explicit `$skill-name` tokens are sent to Codex as structured skill inputs while the original + prompt text remains intact. This applies to both new turns and messages that steer an active turn. +- Unknown explicit skill names fail visibly instead of silently becoming plain prompt text. + Path-like shell variables such as `$HOME/.config` remain ordinary text. +- Source candidates: upstream [#5335](https://github.com/pingdotgg/t3code/pull/5335) for + workspace-aware discovery and [#7196](https://github.com/pingdotgg/t3code/pull/7196) for + structured invocation and token matching. +- Fork implementation: [jln13x/t3code#28](https://github.com/jln13x/t3code/pull/28). +- Sync boundary: preserve the project-scoped `providerSkills` RPC path through contracts, server + registry, client runtime, and composer queries. In `CodexSessionRuntime`, skill binding must stay + shared by `turn/start` and `turn/steer`; future upstream changes to either path need both cases + rechecked. + +### Queue-first active-turn delivery and explicit Codex steering + +- Sending while a turn is active queues the message durably on the server by default. Queued + messages are projected into the thread, survive client disconnects, run in order, and can be + cancelled before provider handoff. +- **Steer** is a separate per-message action on web, desktop, and mobile. It sends immediately to + the active Codex turn without creating a phantom turn. There is intentionally no global + Queue/Steer preference. +- Source candidates: upstream [#7240](https://github.com/pingdotgg/t3code/pull/7240) for the durable + server queue and [#5795](https://github.com/pingdotgg/t3code/pull/5795) for correct Codex + `turn/steer` handling. +- Fork implementation: [jln13x/t3code#28](https://github.com/jln13x/t3code/pull/28). +- Sync boundary: `ThreadTurnDeliveryMode` and queued-turn projections form the cross-client wire + contract. Web and mobile must continue to send `after-current` for the primary action during + active work and `immediate` only for explicit Steer. Codex's active-turn state and serialized + submission path in `CodexSessionRuntime` must remain aligned with orchestration receipts so a + steer never projects a second turn or retries an ambiguously delivered message. + ## Retired on 2026-08-16 The following customizations and their centralized feature flags were removed in favor of current @@ -82,7 +118,6 @@ tests were removed with them. | Checkout-aware thread creation | The broad implementation reused arbitrary existing worktrees, added a searchable mobile picker, resolved pull requests to worktrees, and changed cross-project draft inheritance. Those behaviors remain retired. Grouping now carries only the explicit web/desktop `chat.newInWorktree` sibling-thread command described above. | | Fork-aware pull-request targeting | Targeted the upstream repository when creating a pull request from a fork. This remained a real fork difference when retired; it was removed by explicit product choice in favor of upstream targeting. | | Durable pull-request status | Persisted canonical PR identity and last-known state, retained stale state through provider failures, and refreshed through a shared rate-limited cache. The fork now uses upstream change-request discovery and status. | -| Project provider skill discovery | Rediscovered provider skills for the active project and worktree. The fork now uses upstream provider-skill behavior. | | Markdown and text attachments | Allowed text files to be attached directly to prompts. The fork now uses upstream attachment behavior. | | Generated-image rendering | Rendered generated image artifacts inline in chat. The fork now uses upstream artifact rendering. | | Fork backports and integration ledger | Fork-carried upstream fixes and `docs/upstream-integrations.md` were removed after syncing to an upstream revision that contains or supersedes the applicable work. Future sync history belongs in Git and this inventory. | diff --git a/docs/user/composer.md b/docs/user/composer.md index d2e49db247b0..8566722d5cb3 100644 --- a/docs/user/composer.md +++ b/docs/user/composer.md @@ -3,3 +3,12 @@ Messages can contain up to 120,000 characters. If a draft is longer, T3 Code keeps it in the composer and shows how many characters need to be removed. Shorten the draft or split it into multiple messages, then send again in the same thread. + +While an agent is working, the primary send action queues the message to run after the active turn. +Use **Steer** when the message should reach the active Codex turn immediately instead. On mobile, +expand the composer to see both actions. Server-queued messages remain visible in the thread and +can be cancelled before they start, even if the client disconnects. + +If the server restarts during the narrow handoff to a provider, T3 Code reports the delivery as +interrupted instead of replaying it automatically, because the provider may already have received +the message. Check the provider transcript before resending it. diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index cb74f117b772..3355cb71c2d3 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -46,6 +46,7 @@ export type UpdateThreadMetadataInput = CommandInput<"thread.meta.update">; export type SetThreadRuntimeModeInput = CommandInput<"thread.runtime-mode.set">; export type SetThreadInteractionModeInput = CommandInput<"thread.interaction-mode.set">; export type StartThreadTurnInput = CommandInput<"thread.turn.start">; +export type CancelQueuedThreadTurnInput = CommandInput<"thread.queued-turn.cancel">; export type InterruptThreadTurnInput = CommandInput<"thread.turn.interrupt">; export type RespondToThreadApprovalInput = CommandInput<"thread.approval.respond">; export type RespondToThreadUserInputInput = CommandInput<"thread.user-input.respond">; @@ -275,6 +276,17 @@ export const startThreadTurn: (input: StartThreadTurnInput) => CommandEffect = E }); }); +export const cancelQueuedThreadTurn: (input: CancelQueuedThreadTurnInput) => CommandEffect = + Effect.fn("EnvironmentCommands.cancelQueuedThreadTurn")(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.queued-turn.cancel", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); + }); + export const interruptThreadTurn: (input: InterruptThreadTurnInput) => CommandEffect = Effect.fn( "EnvironmentCommands.interruptThreadTurn", )(function* (input) { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index f579453c27fc..4d7dc23a05f8 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -714,6 +714,11 @@ export function createServerEnvironmentAtoms( tag: WS_METHODS.serverGetUsageSummary, staleTimeMs: 60_000, }), + providerSkills: createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:server:provider-skills", + tag: WS_METHODS.serverListProviderSkills, + staleTimeMs: 30_000, + }), configProjection, welcome: createEnvironmentRpcSubscriptionAtomFamily(runtime, { label: "environment-data:server:welcome", diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index ed3537e4f83b..4674bfc05d98 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -5,6 +5,7 @@ import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime. import { type ArchiveThreadInput, type CreateThreadInput, + type CancelQueuedThreadTurnInput, type DeleteThreadInput, type InterruptThreadTurnInput, type RespondToThreadApprovalInput, @@ -25,6 +26,7 @@ import { type UpdateThreadMetadataInput, archiveThread, createThread, + cancelQueuedThreadTurn, deleteThread, interruptThreadTurn, respondToThreadApproval, @@ -49,6 +51,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; export type { ArchiveThreadInput, CreateThreadInput, + CancelQueuedThreadTurnInput, DeleteThreadInput, InterruptThreadTurnInput, RespondToThreadApprovalInput, @@ -169,6 +172,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + cancelQueuedTurn: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:cancel-queued-turn", + execute: (input: CancelQueuedThreadTurnInput) => cancelQueuedThreadTurn(input), + scheduler, + concurrency, + }), interruptTurn: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:interrupt-turn", execute: (input: InterruptThreadTurnInput) => interruptThreadTurn(input), diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 8b2479c7a349..c84c76be7c1f 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -454,6 +454,75 @@ describe("applyThreadDetailEvent", () => { }); }); + describe("queued turn delivery", () => { + const queuedMessage = { + id: MessageId.make("msg-queued"), + role: "user" as const, + text: "Run this next", + turnId: null, + streaming: false, + createdAt: "2026-04-01T07:00:00.000Z", + updatedAt: "2026-04-01T07:00:00.000Z", + }; + + it("marks, dispatches, and cancels durable queued messages", () => { + const thread = { ...baseThread, messages: [queuedMessage] }; + const queued = applyThreadDetailEvent(thread, { + ...baseEventFields, + sequence: 9, + occurredAt: "2026-04-01T07:00:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.turn-queued", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: queuedMessage.id, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: queuedMessage.createdAt, + }, + }); + expect(queued.kind).toBe("updated"); + if (queued.kind !== "updated") return; + expect(queued.thread.messages[0]?.deliveryState).toBe("queued"); + + const dispatched = applyThreadDetailEvent(queued.thread, { + ...baseEventFields, + sequence: 10, + occurredAt: "2026-04-01T07:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.queued-turn-dispatched", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: queuedMessage.id, + dispatchedAt: "2026-04-01T07:01:00.000Z", + }, + }); + expect(dispatched.kind).toBe("updated"); + if (dispatched.kind !== "updated") return; + expect(dispatched.thread.messages[0]?.deliveryState).toBeUndefined(); + + const cancelled = applyThreadDetailEvent(queued.thread, { + ...baseEventFields, + sequence: 11, + occurredAt: "2026-04-01T07:02:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.queued-turn-cancelled", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: queuedMessage.id, + cancelledAt: "2026-04-01T07:02:00.000Z", + }, + }); + expect(cancelled.kind).toBe("updated"); + if (cancelled.kind === "updated") { + expect(cancelled.thread.messages).toEqual([]); + } + }); + }); + describe("thread.session-set", () => { it("settles a running latestTurn when the session leaves the running status", () => { const threadWithRunningTurn: OrchestrationThread = { @@ -834,6 +903,16 @@ describe("applyThreadDetailEvent", () => { createdAt: "2026-04-01T03:00:00.000Z", updatedAt: "2026-04-01T03:00:00.000Z", }, + { + id: MessageId.make("msg-queued"), + role: "user", + text: "Run this next", + turnId: null, + streaming: false, + createdAt: "2026-04-01T03:30:00.000Z", + updatedAt: "2026-04-01T03:30:00.000Z", + deliveryState: "queued", + }, ], checkpoints: [ { @@ -875,8 +954,10 @@ describe("applyThreadDetailEvent", () => { // turn-2 checkpoint is filtered out (turnCount 2 > revert target 1) expect(result.thread.checkpoints).toHaveLength(1); expect(result.thread.checkpoints[0]?.turnId).toBe("turn-1"); - // msg-3 (turn-2) is filtered, msg-1 (no turn) and msg-2 (turn-1) remain + // msg-3 (turn-2) and the cancelled-by-revert queue entry are filtered; + // msg-1 (no turn) and msg-2 (turn-1) remain. expect(result.thread.messages).toHaveLength(2); + expect(result.thread.messages.some((message) => message.id === "msg-queued")).toBe(false); expect(result.thread.latestTurn?.turnId).toBe("turn-1"); } }); diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 970fd94b1a16..518f4b6dfd1f 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -257,6 +257,46 @@ export function applyThreadDetailEvent( }, }; + case "thread.turn-queued": + return { + kind: "updated", + thread: { + ...thread, + messages: thread.messages.map((message) => + message.id === event.payload.messageId + ? { ...message, deliveryState: "queued" as const } + : message, + ), + updatedAt: event.occurredAt, + }, + }; + + case "thread.queued-turn-dispatched": + return { + kind: "updated", + thread: { + ...thread, + messages: thread.messages.map((message) => { + if (message.id !== event.payload.messageId) { + return message; + } + const { deliveryState: _, ...deliveredMessage } = message; + return deliveredMessage; + }), + updatedAt: event.occurredAt, + }, + }; + + case "thread.queued-turn-cancelled": + return { + kind: "updated", + thread: { + ...thread, + messages: thread.messages.filter((message) => message.id !== event.payload.messageId), + updatedAt: event.occurredAt, + }, + }; + case "thread.turn-interrupt-requested": { if (event.payload.turnId === undefined) { return { kind: "unchanged" }; @@ -658,6 +698,9 @@ function retainMessagesAfterRevert( // Keep messages that belong to a retained turn, plus system messages and // messages without a turn binding (pre-turn-0 user messages). return Arr.filter(messages, (message) => { + if (message.deliveryState === "queued") { + return false; + } if (message.role === "system") { return true; } diff --git a/packages/client-runtime/src/state/threadSettled.test.ts b/packages/client-runtime/src/state/threadSettled.test.ts index 97f397da3e80..6a1a260d7d49 100644 --- a/packages/client-runtime/src/state/threadSettled.test.ts +++ b/packages/client-runtime/src/state/threadSettled.test.ts @@ -322,6 +322,19 @@ describe("hasQueuedTurnStart", () => { // Within the adoption grace window of the queued message. const JUST_AFTER = { now: "2026-04-09T12:00:30.000Z" }; + it("keeps durable server-queued work blocked beyond the adoption grace window", () => { + expect( + hasQueuedTurnStart( + { + ...makeShell({ activityAt: FRESH }), + latestUserMessageAt: "2026-04-01T00:00:00.000Z", + hasQueuedTurns: true, + }, + { now: NOW }, + ), + ).toBe(true); + }); + it("flags a user message no turn has picked up, within the grace window", () => { const noTurn = { latestUserMessageAt: QUEUED_AT, latestTurn: null, session: null }; expect(hasQueuedTurnStart(noTurn, JUST_AFTER)).toBe(true); diff --git a/packages/client-runtime/src/state/threadSettled.ts b/packages/client-runtime/src/state/threadSettled.ts index e2e93f288889..708aa5ac9e14 100644 --- a/packages/client-runtime/src/state/threadSettled.ts +++ b/packages/client-runtime/src/state/threadSettled.ts @@ -54,9 +54,13 @@ export const QUEUED_TURN_START_GRACE_MS = 2 * 60 * 1_000; * within the adoption grace window. */ export function hasQueuedTurnStart( - shell: Pick, + shell: Pick< + OrchestrationThreadShell, + "latestUserMessageAt" | "latestTurn" | "session" | "hasQueuedTurns" + >, options: { readonly now: string }, ): boolean { + if (shell.hasQueuedTurns === true) return true; if (shell.latestUserMessageAt == null) return false; // A failed session start clears the queued state: the failure is already // visible (status edge / error). @@ -87,7 +91,12 @@ export function hasQueuedTurnStart( export function canSettle( shell: Pick< OrchestrationThreadShell, - "hasPendingApprovals" | "hasPendingUserInput" | "session" | "latestUserMessageAt" | "latestTurn" + | "hasPendingApprovals" + | "hasPendingUserInput" + | "session" + | "latestUserMessageAt" + | "latestTurn" + | "hasQueuedTurns" >, options: { readonly now: string }, ): boolean { diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index f403e6de26cc..5091bdadbd12 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -252,6 +252,26 @@ it.effect("preserves explicit provider and runtime mode in thread.turn.start", ( }), ); +it.effect("accepts durable after-current delivery in thread.turn.start", () => + Effect.gen(function* () { + const parsed = yield* decodeThreadTurnStartCommand({ + type: "thread.turn.start", + commandId: "cmd-turn-after-current", + threadId: "thread-1", + message: { + messageId: "msg-after-current", + role: "user", + text: "run next", + attachments: [], + }, + deliveryMode: "after-current", + createdAt: "2026-01-01T00:00:00.000Z", + }); + + assert.strictEqual(parsed.deliveryMode, "after-current"); + }), +); + it.effect("accepts bootstrap metadata in thread.turn.start", () => Effect.gen(function* () { const parsed = yield* decodeThreadTurnStartCommand({ diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index cd9f3a747876..d31347e2978a 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -127,6 +127,8 @@ export const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; export const ProviderInteractionMode = Schema.Literals(["default", "plan"]); export type ProviderInteractionMode = typeof ProviderInteractionMode.Type; export const DEFAULT_PROVIDER_INTERACTION_MODE: ProviderInteractionMode = "default"; +export const ThreadTurnDeliveryMode = Schema.Literals(["immediate", "after-current"]); +export type ThreadTurnDeliveryMode = typeof ThreadTurnDeliveryMode.Type; export const ProviderRequestKind = Schema.Literals(["command", "file-read", "file-change"]); export type ProviderRequestKind = typeof ProviderRequestKind.Type; export const AssistantDeliveryMode = Schema.Literals(["buffered", "streaming"]); @@ -261,6 +263,9 @@ export const OrchestrationMessage = Schema.Struct({ streaming: Schema.Boolean, createdAt: IsoDateTime, updatedAt: IsoDateTime, + // Present while a user message is durably waiting for the active turn to + // finish. Optional keeps cached snapshots from older servers compatible. + deliveryState: Schema.optional(Schema.Literal("queued")), }); export type OrchestrationMessage = typeof OrchestrationMessage.Type; @@ -471,6 +476,9 @@ export const OrchestrationThreadShell = Schema.Struct({ titleRegeneration: Schema.optional(Schema.NullOr(ThreadTitleRegeneration)), session: Schema.NullOr(OrchestrationSession), latestUserMessageAt: Schema.NullOr(IsoDateTime), + // Durable server queue state. Optional so older servers and cached shells + // continue to decode; absent means the server did not expose this signal. + hasQueuedTurns: Schema.optional(Schema.Boolean), hasPendingApprovals: Schema.Boolean, hasPendingUserInput: Schema.Boolean, hasActionableProposedPlan: Schema.Boolean, @@ -840,6 +848,7 @@ export const ThreadTurnStartCommand = Schema.Struct({ ), bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + deliveryMode: Schema.optional(ThreadTurnDeliveryMode), createdAt: IsoDateTime, }); @@ -859,6 +868,15 @@ const ClientThreadTurnStartCommand = Schema.Struct({ interactionMode: ProviderInteractionMode, bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + deliveryMode: Schema.optional(ThreadTurnDeliveryMode), + createdAt: IsoDateTime, +}); + +const ThreadQueuedTurnCancelCommand = Schema.Struct({ + type: Schema.Literal("thread.queued-turn.cancel"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, createdAt: IsoDateTime, }); @@ -928,6 +946,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ThreadTurnStartCommand, + ThreadQueuedTurnCancelCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, @@ -956,6 +975,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ThreadRuntimeModeSetCommand, ThreadInteractionModeSetCommand, ClientThreadTurnStartCommand, + ThreadQueuedTurnCancelCommand, ThreadTurnInterruptCommand, ThreadApprovalRespondCommand, ThreadUserInputRespondCommand, @@ -972,6 +992,20 @@ const ThreadSessionSetCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadQueuedTurnDispatchCommand = Schema.Struct({ + type: Schema.Literal("thread.queued-turn.dispatch"), + commandId: CommandId, + threadId: ThreadId, + messageId: MessageId, + modelSelection: Schema.optional(ModelSelection), + titleSeed: Schema.optional(TrimmedNonEmptyString), + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + queuedAt: IsoDateTime, + createdAt: IsoDateTime, +}); + const ThreadMessageAssistantDeltaCommand = Schema.Struct({ type: Schema.Literal("thread.message.assistant.delta"), commandId: CommandId, @@ -1038,6 +1072,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ }); const InternalOrchestrationCommand = Schema.Union([ + ThreadQueuedTurnDispatchCommand, ThreadSessionSetCommand, ThreadMessageAssistantDeltaCommand, ThreadMessageAssistantCompleteCommand, @@ -1074,6 +1109,9 @@ export const OrchestrationEventType = Schema.Literals([ "thread.runtime-mode-set", "thread.interaction-mode-set", "thread.message-sent", + "thread.turn-queued", + "thread.queued-turn-dispatched", + "thread.queued-turn-cancelled", "thread.turn-start-requested", "thread.turn-interrupt-requested", "thread.approval-response-requested", @@ -1257,6 +1295,20 @@ export const ThreadTurnStartRequestedPayload = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadTurnQueuedPayload = ThreadTurnStartRequestedPayload; + +export const ThreadQueuedTurnDispatchedPayload = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + dispatchedAt: IsoDateTime, +}); + +export const ThreadQueuedTurnCancelledPayload = Schema.Struct({ + threadId: ThreadId, + messageId: MessageId, + cancelledAt: IsoDateTime, +}); + export const ThreadTurnInterruptRequestedPayload = Schema.Struct({ threadId: ThreadId, turnId: Schema.optional(TurnId), @@ -1431,6 +1483,21 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.message-sent"), payload: ThreadMessageSentPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.turn-queued"), + payload: ThreadTurnQueuedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.queued-turn-dispatched"), + payload: ThreadQueuedTurnDispatchedPayload, + }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.queued-turn-cancelled"), + payload: ThreadQueuedTurnCancelledPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.turn-start-requested"), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 51c65f50e1a2..75734b9444f1 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -156,6 +156,8 @@ import { ServerConfig, ServerProviderUpdateError, ServerProviderUpdateInput, + ServerProviderListSkillsInput, + ServerProviderListSkillsResult, ServerLifecycleStreamEvent, ServerRemoveKeybindingInput, ServerRemoveKeybindingResult, @@ -255,6 +257,7 @@ export const WS_METHODS = { serverProbe: "server.probe", serverGetConfig: "server.getConfig", serverRefreshProviders: "server.refreshProviders", + serverListProviderSkills: "server.listProviderSkills", serverUpdateProvider: "server.updateProvider", serverUpdateServer: "server.updateServer", serverUpdateServerWithProgress: "server.updateServerWithProgress", @@ -353,6 +356,12 @@ export const WsServerRefreshProvidersRpc = Rpc.make(WS_METHODS.serverRefreshProv error: EnvironmentAuthorizationError, }); +export const WsServerListProviderSkillsRpc = Rpc.make(WS_METHODS.serverListProviderSkills, { + payload: ServerProviderListSkillsInput, + success: ServerProviderListSkillsResult, + error: EnvironmentAuthorizationError, +}); + export const WsServerUpdateProviderRpc = Rpc.make(WS_METHODS.serverUpdateProvider, { payload: ServerProviderUpdateInput, success: ServerProviderUpdatedPayload, @@ -986,6 +995,7 @@ export const WsRpcGroup = RpcGroup.make( WsServerProbeRpc, WsServerGetConfigRpc, WsServerRefreshProvidersRpc, + WsServerListProviderSkillsRpc, WsServerUpdateProviderRpc, WsServerUpdateServerRpc, WsServerUpdateServerWithProgressRpc, diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index 9791a4f62185..616d5de27170 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -96,6 +96,18 @@ export const ServerProviderSkill = Schema.Struct({ }); export type ServerProviderSkill = typeof ServerProviderSkill.Type; +export const ServerProviderListSkillsInput = Schema.Struct({ + instanceId: ProviderInstanceId, + projectId: ProjectId, + threadId: Schema.optional(ThreadId), +}); +export type ServerProviderListSkillsInput = typeof ServerProviderListSkillsInput.Type; + +export const ServerProviderListSkillsResult = Schema.Struct({ + skills: Schema.Array(ServerProviderSkill), +}); +export type ServerProviderListSkillsResult = typeof ServerProviderListSkillsResult.Type; + /** * Availability of a configured provider instance from the runtime's POV. * diff --git a/packages/shared/package.json b/packages/shared/package.json index 2b030acbce88..585203080205 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -230,6 +230,10 @@ "./worktreeResource": { "types": "./src/worktreeResource.ts", "import": "./src/worktreeResource.ts" + }, + "./threadTurnDelivery": { + "types": "./src/threadTurnDelivery.ts", + "import": "./src/threadTurnDelivery.ts" } }, "scripts": { diff --git a/packages/shared/src/composerInlineTokens.test.ts b/packages/shared/src/composerInlineTokens.test.ts index 81fd6add2056..1e7e2308d9f5 100644 --- a/packages/shared/src/composerInlineTokens.test.ts +++ b/packages/shared/src/composerInlineTokens.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from "vite-plus/test"; -import { collectComposerInlineTokens } from "./composerInlineTokens.ts"; +import { + collectComposerInlineTokens, + collectComposerSkillInvocations, +} from "./composerInlineTokens.ts"; describe("collectComposerInlineTokens", () => { it("collects file links, mentions, and skills with source ranges", () => { @@ -143,6 +146,37 @@ describe("collectComposerInlineTokens", () => { expect(collectComposerInlineTokens(`see [${label}](src/${label}) ok`)).toEqual([]); }); + it("collects complete submitted skill invocations including a trailing token", () => { + expect(collectComposerSkillInvocations("$grill-with-docs explain this")).toEqual([ + "grill-with-docs", + ]); + expect(collectComposerSkillInvocations("Use $grill-with-docs")).toEqual(["grill-with-docs"]); + expect(collectComposerSkillInvocations("$ui then $ui again $review")).toEqual(["ui", "review"]); + }); + + it("collects skill invocations terminated by punctuation", () => { + expect(collectComposerSkillInvocations("Use $review.")).toEqual(["review"]); + expect(collectComposerSkillInvocations("$review, then continue")).toEqual(["review"]); + expect(collectComposerSkillInvocations("call $skill-name.")).toEqual(["skill-name"]); + expect(collectComposerSkillInvocations("try $review!")).toEqual(["review"]); + }); + + it("does not treat shell paths or filenames as skill invocations", () => { + expect(collectComposerSkillInvocations("check $HOME/.config")).toEqual([]); + expect(collectComposerSkillInvocations("read $FOO/bar")).toEqual([]); + expect(collectComposerSkillInvocations("open $review.md")).toEqual([]); + expect(collectComposerSkillInvocations("open $review.配置")).toEqual([]); + expect(collectComposerSkillInvocations("open $review.é")).toEqual([]); + expect(collectComposerSkillInvocations("$PATH/bin then $review.")).toEqual(["review"]); + }); + + it("ignores non-skill dollar text and empty prompts", () => { + expect(collectComposerSkillInvocations("")).toEqual([]); + expect(collectComposerSkillInvocations("plain text")).toEqual([]); + expect(collectComposerSkillInvocations("costs $100 please")).toEqual([]); + expect(collectComposerSkillInvocations("foo$bar baz")).toEqual([]); + }); + it("stays fast on unterminated bracket runs", () => { // Unbounded, the label body rescanned the rest of the text from every // whitespace: this input took seconds. diff --git a/packages/shared/src/composerInlineTokens.ts b/packages/shared/src/composerInlineTokens.ts index 11a5accf37b7..cb78161d2050 100644 --- a/packages/shared/src/composerInlineTokens.ts +++ b/packages/shared/src/composerInlineTokens.ts @@ -18,7 +18,17 @@ export interface CollectComposerInlineTokensOptions { readonly preserveTrailingFrom?: ReadonlyArray; } -const SKILL_TOKEN_REGEX = /(^|\s)\$([a-zA-Z][a-zA-Z0-9:_-]*)(?=\s)/g; +const SKILL_NAME_PATTERN = "[a-zA-Z][a-zA-Z0-9:_-]*"; +// Trailing whitespace is required while typing so `$partial` does not become a chip. +const SKILL_TOKEN_REGEX = new RegExp(`(^|\\s)\\$(${SKILL_NAME_PATTERN})(?=\\s)`, "g"); +// Submitted messages also accept sentence punctuation so `$review.` / `$review,` +// bind. A period only terminates when it is not an extension (`$review.md`, +// `$review.配置`). Path-like `$HOME/.config` stays plain text — `/` is not a +// terminator. +const COMPLETE_SKILL_INVOCATION_REGEX = new RegExp( + `(^|\\s)\\$(${SKILL_NAME_PATTERN})(?=\\s|$|[,!?;)"'\\]]|\\.(?![\\p{L}\\p{N}]))`, + "gu", +); const MENTION_TOKEN_REGEX = /(^|\s)@(?:"((?:\\.|[^"\\])*)"|([^\s@"]+))(?=\s)/g; /** * The label body is bounded rather than `*`. Unbounded, every whitespace in @@ -133,3 +143,25 @@ export function collectComposerInlineTokens( return [...matches].sort((left, right) => left.start - right.start); } + +/** + * Distinct complete `$skill` names in submitted composer text, including a + * trailing token at end of input. Used for send-time Codex skill binding. + */ +export function collectComposerSkillInvocations(text: string): readonly string[] { + if (!text) { + return []; + } + + const names: string[] = []; + const seen = new Set(); + for (const match of text.matchAll(COMPLETE_SKILL_INVOCATION_REGEX)) { + const name = match[2] ?? ""; + if (!name || seen.has(name)) { + continue; + } + seen.add(name); + names.push(name); + } + return names; +} diff --git a/packages/shared/src/threadTurnDelivery.test.ts b/packages/shared/src/threadTurnDelivery.test.ts new file mode 100644 index 000000000000..d55914465df8 --- /dev/null +++ b/packages/shared/src/threadTurnDelivery.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { resolveComposerDeliveryMode } from "./threadTurnDelivery.ts"; + +describe("resolveComposerDeliveryMode", () => { + it("queues by default while a turn is active", () => { + expect(resolveComposerDeliveryMode({ hasActiveTurn: true })).toBe("after-current"); + }); + + it("sends immediately by default while idle", () => { + expect(resolveComposerDeliveryMode({ hasActiveTurn: false })).toBe("immediate"); + }); + + it("preserves an explicit steer request", () => { + expect(resolveComposerDeliveryMode({ hasActiveTurn: true, requested: "immediate" })).toBe( + "immediate", + ); + }); +}); diff --git a/packages/shared/src/threadTurnDelivery.ts b/packages/shared/src/threadTurnDelivery.ts new file mode 100644 index 000000000000..b8b964993fb2 --- /dev/null +++ b/packages/shared/src/threadTurnDelivery.ts @@ -0,0 +1,8 @@ +import type { ThreadTurnDeliveryMode } from "@t3tools/contracts"; + +export function resolveComposerDeliveryMode(input: { + readonly hasActiveTurn: boolean; + readonly requested?: ThreadTurnDeliveryMode; +}): ThreadTurnDeliveryMode { + return input.requested ?? (input.hasActiveTurn ? "after-current" : "immediate"); +}