diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index 115d18d02b..054ad3501c 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -48,6 +48,7 @@ import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointRea import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts"; +import { QueuedFollowUpReactorLive } from "../src/orchestration/Layers/QueuedFollowUpReactor.ts"; import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts"; import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts"; import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts"; @@ -165,6 +166,7 @@ export interface OrchestrationIntegrationHarness { readonly dbPath: string; readonly adapterHarness: TestProviderAdapterHarness | null; readonly engine: OrchestrationEngineShape; + readonly startReactor: Effect.Effect; readonly snapshotQuery: ProjectionSnapshotQuery["Service"]; readonly providerService: ProviderService["Service"]; readonly checkpointStore: CheckpointStore["Service"]; @@ -211,6 +213,8 @@ export interface OrchestrationIntegrationHarness { interface MakeOrchestrationIntegrationHarnessOptions { readonly provider?: ProviderKind; readonly realCodex?: boolean; + readonly rootDir?: string; + readonly autoStartReactor?: boolean; } export const makeOrchestrationIntegrationHarness = ( @@ -236,16 +240,25 @@ export const makeOrchestrationIntegrationHarness = ( listProviders: () => Effect.succeed([adapterHarness.provider]), } as typeof ProviderAdapterRegistry.Service) : null; - const rootDir = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-orchestration-integration-", - }); + const rootDir = + options?.rootDir ?? + (yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-orchestration-integration-", + })); const workspaceDir = path.join(rootDir, "workspace"); const { stateDir, dbPath } = yield* deriveServerPaths(rootDir, undefined).pipe( Effect.provideService(Path.Path, path), ); + yield* fileSystem.makeDirectory(rootDir, { recursive: true }); yield* fileSystem.makeDirectory(workspaceDir, { recursive: true }); yield* fileSystem.makeDirectory(stateDir, { recursive: true }); - yield* initializeGitWorkspace(workspaceDir); + const workspaceGitDir = path.join(workspaceDir, ".git"); + const gitDirExists = yield* fileSystem + .exists(workspaceGitDir) + .pipe(Effect.orElseSucceed(() => false)); + if (!gitDirExists) { + yield* initializeGitWorkspace(workspaceDir); + } const persistenceLayer = makeSqlitePersistenceLive(dbPath); const orchestrationLayer = OrchestrationEngineLive.pipe( @@ -317,10 +330,14 @@ export const makeOrchestrationIntegrationHarness = ( const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), ); + const queuedFollowUpReactorLayer = QueuedFollowUpReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), + Layer.provideMerge(queuedFollowUpReactorLayer), ); const layer = orchestrationReactorLayer.pipe( Layer.provide(persistenceLayer), @@ -358,9 +375,19 @@ export const makeOrchestrationIntegrationHarness = ( ).pipe(Effect.orDie); const scope = yield* Scope.make("sequential"); - yield* tryRuntimePromise("start OrchestrationReactor", () => - runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), - ).pipe(Effect.orDie); + let reactorStarted = false; + const startReactor = Effect.gen(function* () { + if (reactorStarted) { + return; + } + reactorStarted = true; + yield* tryRuntimePromise("start OrchestrationReactor", () => + runtime.runPromise(reactor.start.pipe(Scope.provide(scope))), + ).pipe(Effect.orDie); + }).pipe(Effect.orDie); + if (options?.autoStartReactor !== false) { + yield* startReactor; + } const receiptHistory = yield* Ref.make>([]); yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) => Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid), @@ -491,6 +518,7 @@ export const makeOrchestrationIntegrationHarness = ( dbPath, adapterHarness, engine, + startReactor, snapshotQuery, providerService, checkpointStore, diff --git a/apps/server/integration/orchestrationEngine.integration.test.ts b/apps/server/integration/orchestrationEngine.integration.test.ts index d6b1004749..f26ff098ff 100644 --- a/apps/server/integration/orchestrationEngine.integration.test.ts +++ b/apps/server/integration/orchestrationEngine.integration.test.ts @@ -1,4 +1,5 @@ import fs from "node:fs"; +import os from "node:os"; import path from "node:path"; import { @@ -94,6 +95,17 @@ function withHarness( ).pipe(Effect.provide(NodeServices.layer)); } +function withHarnessOptions( + options: Parameters[0], + use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, +) { + return Effect.acquireUseRelease( + makeOrchestrationIntegrationHarness(options), + use, + (harness) => harness.dispose, + ).pipe(Effect.provide(NodeServices.layer)); +} + function withRealCodexHarness( use: (harness: OrchestrationIntegrationHarness) => Effect.Effect, ) { @@ -252,6 +264,89 @@ it.live("runs a single turn end-to-end and persists checkpoint state in sqlite + ), ); +it.live("replays queued follow-ups after orchestration restarts", () => + Effect.gen(function* () { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "t3-orchestration-queue-restart-")); + + yield* withHarnessOptions({ rootDir }, (harness) => + Effect.gen(function* () { + yield* seedProjectAndThread(harness); + + yield* harness.engine.dispatch({ + type: "thread.queued-follow-up.enqueue", + commandId: CommandId.makeUnsafe("cmd-queued-follow-up-restart-enqueue"), + threadId: THREAD_ID, + followUp: { + id: "follow-up-restart-1", + createdAt: nowIso(), + prompt: "Resume this after restart", + attachments: [], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: DEFAULT_MODEL_BY_PROVIDER.codex, + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + lastSendError: null, + }, + createdAt: nowIso(), + }); + + const queuedThread = yield* harness.waitForThread( + THREAD_ID, + (thread) => thread.queuedFollowUps.length === 1, + ); + assert.equal(queuedThread.queuedFollowUps[0]?.prompt, "Resume this after restart"); + }), + ); + + yield* withHarnessOptions({ rootDir, autoStartReactor: false }, (harness) => + Effect.gen(function* () { + yield* harness.adapterHarness!.queueTurnResponseForNextSession({ + events: [ + { + type: "turn.started", + ...runtimeBase("evt-queued-restart-1", "2026-03-28T12:05:00.000Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + }, + { + type: "message.delta", + ...runtimeBase("evt-queued-restart-2", "2026-03-28T12:05:00.050Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + delta: "Recovered queued follow-up output.\n", + }, + { + type: "turn.completed", + ...runtimeBase("evt-queued-restart-3", "2026-03-28T12:05:00.100Z"), + threadId: THREAD_ID, + turnId: FIXTURE_TURN_ID, + status: "completed", + }, + ], + }); + + yield* harness.startReactor; + + const recoveredThread = yield* harness.waitForThread( + THREAD_ID, + (thread) => + thread.queuedFollowUps.length === 0 && + thread.messages.some( + (message) => + message.role === "assistant" && + message.text.includes("Recovered queued follow-up output."), + ), + ); + + assert.equal(recoveredThread.queuedFollowUps.length, 0); + }), + ); + }).pipe(Effect.provide(NodeServices.layer)), +); + it.live.skipIf(!process.env.CODEX_BINARY_PATH)( "keeps the same Codex provider thread across runtime mode switches", () => diff --git a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts index 9fb2500ce4..30e0462e35 100644 --- a/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +++ b/apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts @@ -64,6 +64,7 @@ function makeSnapshot(input: { archivedAt: null, deletedAt: null, messages: [], + queuedFollowUps: [], activities: [], proposedPlans: [], checkpoints: [ diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts index 1514bef595..33ae86586d 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts @@ -5,6 +5,7 @@ import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; +import { QueuedFollowUpReactor } from "../Services/QueuedFollowUpReactor.ts"; import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; describe("OrchestrationReactor", () => { @@ -46,6 +47,14 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(QueuedFollowUpReactor, { + start: Effect.sync(() => { + started.push("queued-follow-up-reactor"); + }), + drain: Effect.void, + }), + ), ), ); @@ -57,6 +66,7 @@ describe("OrchestrationReactor", () => { "provider-runtime-ingestion", "provider-command-reactor", "checkpoint-reactor", + "queued-follow-up-reactor", ]); await Effect.runPromise(Scope.close(scope, Exit.void)); diff --git a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts index 1e498885a0..8ba7beb2ab 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationReactor.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationReactor.ts @@ -7,16 +7,19 @@ import { import { CheckpointReactor } from "../Services/CheckpointReactor.ts"; import { ProviderCommandReactor } from "../Services/ProviderCommandReactor.ts"; import { ProviderRuntimeIngestionService } from "../Services/ProviderRuntimeIngestion.ts"; +import { QueuedFollowUpReactor } from "../Services/QueuedFollowUpReactor.ts"; export const makeOrchestrationReactor = Effect.gen(function* () { const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService; const providerCommandReactor = yield* ProviderCommandReactor; const checkpointReactor = yield* CheckpointReactor; + const queuedFollowUpReactor = yield* QueuedFollowUpReactor; const start: OrchestrationReactorShape["start"] = Effect.gen(function* () { yield* providerRuntimeIngestion.start; yield* providerCommandReactor.start; yield* checkpointReactor.start; + yield* queuedFollowUpReactor.start; }); return { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 77b5d4d619..5c9227d620 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -921,6 +921,320 @@ it.layer( it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-revert-")))( "OrchestrationProjectionPipeline", (it) => { + it.effect( + "clears queued follow-ups and prunes queued attachment files when a thread is reverted", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const { attachmentsDir } = yield* ServerConfig; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("thread-queued-revert"); + const queuedAttachmentId = "thread-queued-revert-00000000-0000-4000-8000-000000000001"; + const otherThreadAttachmentId = + "thread-queued-revert-extra-00000000-0000-4000-8000-000000000002"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-queued-revert-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-queued-revert"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-revert-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-revert-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-queued-revert"), + title: "Project Queued Revert", + workspaceRoot: "/tmp/project-queued-revert", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-queued-revert-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-revert-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-revert-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-queued-revert"), + title: "Thread Queued Revert", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.queued-follow-up-enqueued", + eventId: EventId.makeUnsafe("evt-queued-revert-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-revert-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-revert-3"), + metadata: {}, + payload: { + threadId, + createdAt: now, + followUp: { + id: "queued-follow-up-1", + createdAt: now, + prompt: "queued prompt", + attachments: [ + { + type: "image", + id: queuedAttachmentId, + name: "queued.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + }, + }); + + const queuedAttachmentPath = path.join(attachmentsDir, `${queuedAttachmentId}.png`); + const otherThreadAttachmentPath = path.join( + attachmentsDir, + `${otherThreadAttachmentId}.png`, + ); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(queuedAttachmentPath, "queued"); + yield* fileSystem.writeFileString(otherThreadAttachmentPath, "other-thread"); + assert.isTrue(yield* exists(queuedAttachmentPath)); + assert.isTrue(yield* exists(otherThreadAttachmentPath)); + + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.makeUnsafe("evt-queued-revert-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-revert-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-revert-4"), + metadata: {}, + payload: { + threadId, + turnCount: 0, + }, + }); + + const queuedRows = yield* sql<{ readonly followUpId: string }>` + SELECT follow_up_id AS "followUpId" + FROM projection_thread_queued_follow_ups + WHERE thread_id = ${threadId} + `; + assert.deepEqual(queuedRows, []); + assert.isFalse(yield* exists(queuedAttachmentPath)); + assert.isTrue(yield* exists(otherThreadAttachmentPath)); + }), + ); + + it.effect("prunes removed queued follow-up attachment files on queue mutation", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = new Date().toISOString(); + const threadId = ThreadId.makeUnsafe("thread-queued-remove"); + const removedAttachmentId = "thread-queued-remove-00000000-0000-4000-8000-000000000001"; + const keptAttachmentId = "thread-queued-remove-00000000-0000-4000-8000-000000000002"; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-queued-remove-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-queued-remove"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-remove-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-remove-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-queued-remove"), + title: "Project Queued Remove", + workspaceRoot: "/tmp/project-queued-remove", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-queued-remove-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-remove-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-remove-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-queued-remove"), + title: "Thread Queued Remove", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.queued-follow-up-enqueued", + eventId: EventId.makeUnsafe("evt-queued-remove-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-remove-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-remove-3"), + metadata: {}, + payload: { + threadId, + createdAt: now, + followUp: { + id: "queued-follow-up-remove", + createdAt: now, + prompt: "remove me", + attachments: [ + { + type: "image", + id: removedAttachmentId, + name: "remove.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + }, + }); + + yield* appendAndProject({ + type: "thread.queued-follow-up-enqueued", + eventId: EventId.makeUnsafe("evt-queued-remove-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-remove-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-remove-4"), + metadata: {}, + payload: { + threadId, + createdAt: now, + followUp: { + id: "queued-follow-up-keep", + createdAt: now, + prompt: "keep me", + attachments: [ + { + type: "image", + id: keptAttachmentId, + name: "keep.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + }, + }); + + const removedAttachmentPath = path.join(attachmentsDir, `${removedAttachmentId}.png`); + const keptAttachmentPath = path.join(attachmentsDir, `${keptAttachmentId}.png`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(removedAttachmentPath, "remove"); + yield* fileSystem.writeFileString(keptAttachmentPath, "keep"); + assert.isTrue(yield* exists(removedAttachmentPath)); + assert.isTrue(yield* exists(keptAttachmentPath)); + + yield* appendAndProject({ + type: "thread.queued-follow-up-removed", + eventId: EventId.makeUnsafe("evt-queued-remove-5"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-remove-5"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-remove-5"), + metadata: {}, + payload: { + threadId, + followUpId: "queued-follow-up-remove", + createdAt: now, + }, + }); + + assert.isFalse(yield* exists(removedAttachmentPath)); + assert.isTrue(yield* exists(keptAttachmentPath)); + }), + ); + it.effect("removes thread attachment directory when thread is deleted", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 7d85d1e38c..b226a2b3bf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -27,6 +27,10 @@ import { type ProjectionTurn, ProjectionTurnRepository, } from "../../persistence/Services/ProjectionTurns.ts"; +import { + type ProjectionThreadQueuedFollowUp, + ProjectionThreadQueuedFollowUpRepository, +} from "../../persistence/Services/ProjectionThreadQueuedFollowUps.ts"; import { ProjectionThreadRepository } from "../../persistence/Services/ProjectionThreads.ts"; import { ProjectionPendingApprovalRepositoryLive } from "../../persistence/Layers/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepositoryLive } from "../../persistence/Layers/ProjectionProjects.ts"; @@ -36,6 +40,7 @@ import { ProjectionThreadMessageRepositoryLive } from "../../persistence/Layers/ import { ProjectionThreadProposedPlanRepositoryLive } from "../../persistence/Layers/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSessionRepositoryLive } from "../../persistence/Layers/ProjectionThreadSessions.ts"; import { ProjectionTurnRepositoryLive } from "../../persistence/Layers/ProjectionTurns.ts"; +import { ProjectionThreadQueuedFollowUpRepositoryLive } from "../../persistence/Layers/ProjectionThreadQueuedFollowUps.ts"; import { ProjectionThreadRepositoryLive } from "../../persistence/Layers/ProjectionThreads.ts"; import { ServerConfig } from "../../config.ts"; import { @@ -56,6 +61,7 @@ export const ORCHESTRATION_PROJECTOR_NAMES = { threadProposedPlans: "projection.thread-proposed-plans", threadActivities: "projection.thread-activities", threadSessions: "projection.thread-sessions", + queuedFollowUps: "projection.thread-queued-follow-ups", threadTurns: "projection.thread-turns", checkpoints: "projection.checkpoints", pendingApprovals: "projection.pending-approvals", @@ -238,6 +244,37 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } +function collectQueuedFollowUpAttachmentRelativePaths( + threadId: string, + followUps: ReadonlyArray, +): Set { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + return new Set(); + } + const relativePaths = new Set(); + for (const followUp of followUps) { + for (const attachment of followUp.attachments ?? []) { + if (attachment.type !== "image") { + continue; + } + const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachment.id); + if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { + continue; + } + relativePaths.add(attachmentRelativePath(attachment)); + } + } + return relativePaths; +} + +function mergeThreadAttachmentRelativePaths( + messagePaths: ReadonlySet, + queuedFollowUpPaths: ReadonlySet, +): Set { + return new Set([...messagePaths, ...queuedFollowUpPaths]); +} + const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSideEffects) { const serverConfig = yield* Effect.service(ServerConfig); const fileSystem = yield* Effect.service(FileSystem.FileSystem); @@ -348,6 +385,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const projectionThreadActivityRepository = yield* ProjectionThreadActivityRepository; const projectionThreadSessionRepository = yield* ProjectionThreadSessionRepository; const projectionTurnRepository = yield* ProjectionTurnRepository; + const projectionThreadQueuedFollowUpRepository = yield* ProjectionThreadQueuedFollowUpRepository; const projectionPendingApprovalRepository = yield* ProjectionPendingApprovalRepository; const fileSystem = yield* FileSystem.FileSystem; @@ -1058,6 +1096,192 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { } }); + const applyQueuedFollowUpsProjection: ProjectorDefinition["apply"] = ( + event, + attachmentSideEffects, + ) => + Effect.gen(function* () { + const replaceThreadQueue = ( + threadId: ProjectionThreadQueuedFollowUp["threadId"], + followUps: ReadonlyArray, + ) => + projectionThreadQueuedFollowUpRepository.replaceByThreadId({ + threadId, + followUps: followUps.map((followUp, index) => ({ + ...followUp, + queuePosition: index, + })), + }); + const syncQueuedFollowUpAttachmentPaths = Effect.fn(function* ( + threadId: ProjectionThreadQueuedFollowUp["threadId"], + followUps: ReadonlyArray, + ) { + const messageRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId, + }); + attachmentSideEffects.prunedThreadRelativePaths.set( + threadId, + mergeThreadAttachmentRelativePaths( + collectThreadAttachmentRelativePaths(threadId, messageRows), + collectQueuedFollowUpAttachmentRelativePaths(threadId, followUps), + ), + ); + }); + + switch (event.type) { + case "thread.queued-follow-up-enqueued": { + const current = yield* projectionThreadQueuedFollowUpRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const nextFollowUp: ProjectionThreadQueuedFollowUp = { + followUpId: event.payload.followUp.id, + threadId: event.payload.threadId, + queuePosition: current.length, + createdAt: event.payload.followUp.createdAt, + updatedAt: event.payload.createdAt, + prompt: event.payload.followUp.prompt, + attachments: event.payload.followUp.attachments, + terminalContexts: event.payload.followUp.terminalContexts, + modelSelection: event.payload.followUp.modelSelection, + runtimeMode: event.payload.followUp.runtimeMode, + interactionMode: event.payload.followUp.interactionMode, + lastSendError: event.payload.followUp.lastSendError, + }; + const withoutExisting = current.filter( + (followUp) => followUp.followUpId !== nextFollowUp.followUpId, + ); + const targetIndex = + event.payload.targetIndex === undefined + ? withoutExisting.length + : Math.max(0, Math.min(event.payload.targetIndex, withoutExisting.length)); + yield* replaceThreadQueue(event.payload.threadId, [ + ...withoutExisting.slice(0, targetIndex), + nextFollowUp, + ...withoutExisting.slice(targetIndex), + ]); + return; + } + + case "thread.queued-follow-up-updated": { + const current = yield* projectionThreadQueuedFollowUpRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const nextFollowUps = current.map((followUp) => + followUp.followUpId === event.payload.followUp.id + ? Object.assign({}, followUp, { + updatedAt: event.payload.createdAt, + prompt: event.payload.followUp.prompt, + attachments: event.payload.followUp.attachments, + terminalContexts: event.payload.followUp.terminalContexts, + modelSelection: event.payload.followUp.modelSelection, + runtimeMode: event.payload.followUp.runtimeMode, + interactionMode: event.payload.followUp.interactionMode, + lastSendError: event.payload.followUp.lastSendError, + }) + : followUp, + ); + yield* replaceThreadQueue(event.payload.threadId, nextFollowUps); + yield* syncQueuedFollowUpAttachmentPaths(event.payload.threadId, nextFollowUps); + return; + } + + case "thread.queued-follow-up-removed": { + const current = yield* projectionThreadQueuedFollowUpRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const nextFollowUps = current.filter( + (followUp) => followUp.followUpId !== event.payload.followUpId, + ); + yield* replaceThreadQueue(event.payload.threadId, nextFollowUps); + yield* syncQueuedFollowUpAttachmentPaths(event.payload.threadId, nextFollowUps); + return; + } + + case "thread.queued-follow-up-reordered": { + const current = yield* projectionThreadQueuedFollowUpRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + const currentIndex = current.findIndex( + (followUp) => followUp.followUpId === event.payload.followUpId, + ); + if (currentIndex < 0) { + return; + } + const boundedTargetIndex = Math.max( + 0, + Math.min(event.payload.targetIndex, current.length - 1), + ); + const nextQueuedFollowUps = [...current]; + const [movedFollowUp] = nextQueuedFollowUps.splice(currentIndex, 1); + if (!movedFollowUp) { + return; + } + nextQueuedFollowUps.splice(boundedTargetIndex, 0, { + ...movedFollowUp, + updatedAt: event.payload.createdAt, + }); + yield* replaceThreadQueue(event.payload.threadId, nextQueuedFollowUps); + return; + } + + case "thread.queued-follow-up-send-failed": { + const current = yield* projectionThreadQueuedFollowUpRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* replaceThreadQueue( + event.payload.threadId, + current.map((followUp) => + followUp.followUpId === event.payload.followUpId + ? Object.assign({}, followUp, { + updatedAt: event.payload.createdAt, + lastSendError: event.payload.lastSendError, + }) + : followUp, + ), + ); + return; + } + + case "thread.queued-follow-up-send-error-cleared": { + const current = yield* projectionThreadQueuedFollowUpRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + yield* replaceThreadQueue( + event.payload.threadId, + current.map((followUp) => + followUp.followUpId === event.payload.followUpId + ? Object.assign({}, followUp, { + updatedAt: event.payload.createdAt, + lastSendError: null, + }) + : followUp, + ), + ); + return; + } + + case "thread.deleted": + case "thread.reverted": { + yield* projectionThreadQueuedFollowUpRepository.deleteByThreadId({ + threadId: event.payload.threadId, + }); + if (event.type === "thread.reverted") { + const messageRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + attachmentSideEffects.prunedThreadRelativePaths.set( + event.payload.threadId, + collectThreadAttachmentRelativePaths(event.payload.threadId, messageRows), + ); + } + return; + } + + default: + return; + } + }); + const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = ( @@ -1171,6 +1395,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, apply: applyThreadSessionsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.queuedFollowUps, + apply: applyQueuedFollowUpsProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, apply: applyThreadTurnsProjection, @@ -1285,6 +1513,7 @@ export const OrchestrationProjectionPipelineLive = Layer.effect( Layer.provideMerge(ProjectionThreadProposedPlanRepositoryLive), Layer.provideMerge(ProjectionThreadActivityRepositoryLive), Layer.provideMerge(ProjectionThreadSessionRepositoryLive), + Layer.provideMerge(ProjectionThreadQueuedFollowUpRepositoryLive), Layer.provideMerge(ProjectionTurnRepositoryLive), 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 32143d751f..eedce5ed8c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -27,6 +27,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { yield* sql`DELETE FROM projection_projects`; yield* sql`DELETE FROM projection_state`; yield* sql`DELETE FROM projection_thread_proposed_plans`; + yield* sql`DELETE FROM projection_thread_queued_follow_ups`; yield* sql`DELETE FROM projection_turns`; yield* sql` @@ -148,6 +149,37 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { ) `; + yield* sql` + INSERT INTO projection_thread_queued_follow_ups ( + follow_up_id, + thread_id, + queue_position, + created_at, + updated_at, + prompt, + attachments_json, + terminal_contexts_json, + model_selection_json, + runtime_mode, + interaction_mode, + last_send_error + ) + VALUES ( + 'queued-follow-up-1', + 'thread-1', + 0, + '2026-02-24T00:00:06.250Z', + '2026-02-24T00:00:06.250Z', + 'follow up after this turn', + '[]', + '[]', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL + ) + `; + yield* sql` INSERT INTO projection_thread_sessions ( thread_id, @@ -303,6 +335,22 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { updatedAt: "2026-02-24T00:00:05.500Z", }, ], + queuedFollowUps: [ + { + id: "queued-follow-up-1", + createdAt: "2026-02-24T00:00:06.250Z", + prompt: "follow up after this turn", + attachments: [], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + ], activities: [ { id: asEventId("activity-1"), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index f951c54b5b..54bff4bfc9 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -10,6 +10,8 @@ import { ThreadId, TurnId, type OrchestrationCheckpointSummary, + type OrchestrationQueuedFollowUp, + OrchestrationQueuedTerminalContext, type OrchestrationLatestTurn, type OrchestrationMessage, type OrchestrationProposedPlan, @@ -37,6 +39,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts"; import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts"; import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts"; +import { ProjectionThreadQueuedFollowUp } from "../../persistence/Services/ProjectionThreadQueuedFollowUps.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -74,6 +77,13 @@ const ProjectionCheckpointDbRowSchema = ProjectionCheckpoint.mapFields( files: Schema.fromJsonString(Schema.Array(OrchestrationCheckpointFile)), }), ); +const ProjectionThreadQueuedFollowUpDbRowSchema = ProjectionThreadQueuedFollowUp.mapFields( + Struct.assign({ + attachments: Schema.fromJsonString(Schema.Array(ChatAttachment)), + terminalContexts: Schema.fromJsonString(Schema.Array(OrchestrationQueuedTerminalContext)), + modelSelection: Schema.fromJsonString(ModelSelection), + }), +); const ProjectionLatestTurnDbRowSchema = Schema.Struct({ threadId: ProjectionThread.fields.threadId, turnId: TurnId, @@ -94,6 +104,7 @@ const REQUIRED_SNAPSHOT_PROJECTORS = [ ORCHESTRATION_PROJECTOR_NAMES.threadProposedPlans, ORCHESTRATION_PROJECTOR_NAMES.threadActivities, ORCHESTRATION_PROJECTOR_NAMES.threadSessions, + ORCHESTRATION_PROJECTOR_NAMES.queuedFollowUps, ORCHESTRATION_PROJECTOR_NAMES.checkpoints, ] as const; @@ -265,6 +276,29 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { `, }); + const listQueuedFollowUpRows = SqlSchema.findAll({ + Request: Schema.Void, + Result: ProjectionThreadQueuedFollowUpDbRowSchema, + execute: () => + sql` + SELECT + follow_up_id AS "followUpId", + thread_id AS "threadId", + queue_position AS "queuePosition", + created_at AS "createdAt", + updated_at AS "updatedAt", + prompt, + attachments_json AS "attachments", + terminal_contexts_json AS "terminalContexts", + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + last_send_error AS "lastSendError" + FROM projection_thread_queued_follow_ups + ORDER BY thread_id ASC, queue_position ASC, created_at ASC, follow_up_id ASC + `, + }); + const listCheckpointRows = SqlSchema.findAll({ Request: Schema.Void, Result: ProjectionCheckpointDbRowSchema, @@ -330,6 +364,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { proposedPlanRows, activityRows, sessionRows, + queuedFollowUpRows, checkpointRows, latestTurnRows, stateRows, @@ -382,6 +417,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listQueuedFollowUpRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getSnapshot:listQueuedFollowUps:query", + "ProjectionSnapshotQuery.getSnapshot:listQueuedFollowUps:decodeRows", + ), + ), + ), listCheckpointRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -411,6 +454,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { const messagesByThread = new Map>(); const proposedPlansByThread = new Map>(); const activitiesByThread = new Map>(); + const queuedFollowUpsByThread = new Map>(); const checkpointsByThread = new Map>(); const sessionsByThread = new Map(); const latestTurnByThread = new Map(); @@ -489,6 +533,23 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { checkpointsByThread.set(row.threadId, threadCheckpoints); } + for (const row of queuedFollowUpRows) { + updatedAt = maxIso(updatedAt, row.updatedAt); + const threadQueuedFollowUps = queuedFollowUpsByThread.get(row.threadId) ?? []; + threadQueuedFollowUps.push({ + id: row.followUpId, + createdAt: row.createdAt, + prompt: row.prompt, + attachments: row.attachments, + terminalContexts: row.terminalContexts, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + lastSendError: row.lastSendError, + }); + queuedFollowUpsByThread.set(row.threadId, threadQueuedFollowUps); + } + for (const row of latestTurnRows) { updatedAt = maxIso(updatedAt, row.requestedAt); if (row.startedAt !== null) { @@ -565,6 +626,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { deletedAt: row.deletedAt, messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], + queuedFollowUps: queuedFollowUpsByThread.get(row.threadId) ?? [], activities: activitiesByThread.get(row.threadId) ?? [], checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, diff --git a/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.test.ts b/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.test.ts new file mode 100644 index 0000000000..bcdca39609 --- /dev/null +++ b/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.test.ts @@ -0,0 +1,510 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + EventId, + type OrchestrationEvent, + type OrchestrationThreadActivity, + type OrchestrationSessionStatus, + ProjectId, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import { Effect, Exit, Layer, ManagedRuntime, Scope, Stream } from "effect"; +import { afterEach, describe, expect, it } from "vitest"; + +import { + OrchestrationEngineService, + type OrchestrationEngineShape, +} from "../Services/OrchestrationEngine.ts"; +import { QueuedFollowUpReactor } from "../Services/QueuedFollowUpReactor.ts"; +import { QueuedFollowUpReactorLive } from "./QueuedFollowUpReactor.ts"; + +const NOW_ISO = "2026-03-28T12:00:00.000Z"; + +function makeReadModel(input?: { + sessionStatus?: OrchestrationSessionStatus | null; + lastSendError?: string | null; + queuedPrompts?: ReadonlyArray; + queuedAttachments?: ReadonlyArray< + OrchestrationReadModel["threads"][number]["queuedFollowUps"][number]["attachments"] + >; + queuedTerminalContexts?: ReadonlyArray< + OrchestrationReadModel["threads"][number]["queuedFollowUps"][number]["terminalContexts"] + >; + latestTurnState?: OrchestrationReadModel["threads"][number]["latestTurn"]; + activities?: ReadonlyArray; +}): OrchestrationReadModel { + const queuedPrompts = input?.queuedPrompts ?? ["send this next"]; + return { + snapshotSequence: 1, + updatedAt: NOW_ISO, + projects: [ + { + id: ProjectId.makeUnsafe("project-1"), + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + scripts: [], + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + deletedAt: null, + }, + ], + threads: [ + { + id: ThreadId.makeUnsafe("thread-1"), + projectId: ProjectId.makeUnsafe("project-1"), + title: "Thread", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: DEFAULT_RUNTIME_MODE, + branch: null, + worktreePath: null, + createdAt: NOW_ISO, + updatedAt: NOW_ISO, + archivedAt: null, + deletedAt: null, + messages: [], + queuedFollowUps: queuedPrompts.map((prompt, index) => ({ + id: `follow-up-${index + 1}`, + createdAt: NOW_ISO, + prompt, + attachments: input?.queuedAttachments?.[index] ?? [], + terminalContexts: input?.queuedTerminalContexts?.[index] ?? [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + lastSendError: input?.lastSendError ?? null, + })), + proposedPlans: [], + activities: [...(input?.activities ?? [])], + checkpoints: [], + latestTurn: input?.latestTurnState ?? null, + session: + input?.sessionStatus === null + ? null + : { + threadId: ThreadId.makeUnsafe("thread-1"), + status: input?.sessionStatus ?? "ready", + providerName: "codex", + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + }; +} + +describe("QueuedFollowUpReactor", () => { + let runtime: ManagedRuntime.ManagedRuntime | null = null; + + afterEach(async () => { + if (runtime) { + await runtime.dispose(); + } + runtime = null; + }); + + it("dispatches the queued head and removes it when the thread is sendable", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => Effect.succeed(makeReadModel()), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + expect(dispatched.map((command) => command.type)).toEqual([ + "thread.turn.start", + "thread.queued-follow-up.remove", + ]); + const turnStart = dispatched[0]; + expect(turnStart?.type).toBe("thread.turn.start"); + if (turnStart?.type !== "thread.turn.start") { + throw new Error("Expected first command to be thread.turn.start"); + } + expect(turnStart.message.text).toBe("send this next"); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("injects queued terminal contexts into the dispatched prompt", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => + Effect.succeed( + makeReadModel({ + queuedPrompts: ["Investigate this"], + queuedTerminalContexts: [ + [ + { + id: "ctx-1", + threadId: ThreadId.makeUnsafe("thread-1"), + createdAt: NOW_ISO, + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 3, + lineEnd: 4, + text: "alpha\nbeta", + }, + ], + ], + }), + ), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + const turnStart = dispatched[0]; + expect(turnStart?.type).toBe("thread.turn.start"); + if (turnStart?.type !== "thread.turn.start") { + throw new Error("Expected first command to be thread.turn.start"); + } + expect(turnStart.message.text).toContain("Investigate this"); + expect(turnStart.message.text).toContain(""); + expect(turnStart.message.text).toContain("- Terminal 1 lines 3-4:"); + expect(turnStart.message.text).toContain("3 | alpha"); + expect(turnStart.message.text).toContain("4 | beta"); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("uses the image-only fallback prompt for queued image-only sends", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => + Effect.succeed( + makeReadModel({ + queuedPrompts: [""], + queuedAttachments: [ + [ + { + type: "image", + id: "thread-1-att-1", + name: "queued.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ], + ], + }), + ), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + const turnStart = dispatched[0]; + expect(turnStart?.type).toBe("thread.turn.start"); + if (turnStart?.type !== "thread.turn.start") { + throw new Error("Expected first command to be thread.turn.start"); + } + expect(turnStart.message.text).toBe( + "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]", + ); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("records a send failure and keeps the queued item when dispatch fails", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => Effect.succeed(makeReadModel()), + readEvents: () => Stream.empty, + dispatch: (command) => { + dispatched.push(command); + if (command.type === "thread.turn.start") { + return Effect.fail({ _tag: "InvalidCommand" } as never); + } + return Effect.succeed({ sequence: dispatched.length }); + }, + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + expect(dispatched.map((command) => command.type)).toEqual([ + "thread.turn.start", + "thread.queued-follow-up.send-failed", + ]); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("does not dispatch while the thread session is still running", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => Effect.succeed(makeReadModel({ sessionStatus: "running" })), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + expect(dispatched).toEqual([]); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("does not dispatch while a pending approval is open", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => + Effect.succeed( + makeReadModel({ + activities: [ + { + id: EventId.makeUnsafe("activity-approval-open"), + kind: "approval.requested", + tone: "info", + summary: "Approval required", + turnId: null, + createdAt: NOW_ISO, + payload: { + requestId: "approval-request-1", + requestKind: "command", + }, + }, + ], + }), + ), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + expect(dispatched).toEqual([]); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("does not dispatch while a pending user-input request is open", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => + Effect.succeed( + makeReadModel({ + activities: [ + { + id: EventId.makeUnsafe("activity-user-input-open"), + kind: "user-input.requested", + tone: "info", + summary: "Need more input", + turnId: null, + createdAt: NOW_ISO, + payload: { + requestId: "user-input-request-1", + questions: [ + { + id: "question-1", + header: "Pick one", + question: "Which option?", + options: [ + { + label: "A", + description: "Option A", + }, + ], + }, + ], + }, + }, + ], + }), + ), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + expect(dispatched).toEqual([]); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("does not dispatch the rest of the queue before the previous queued send settles", async () => { + const dispatched: OrchestrationCommand[] = []; + let readModel = makeReadModel({ + queuedPrompts: ["first", "second", "third"], + }); + const threadEvent = { + eventId: EventId.makeUnsafe("evt-queued-follow-up-reactor"), + sequence: 1, + type: "thread.queued-follow-up-enqueued", + aggregateKind: "thread", + aggregateId: ThreadId.makeUnsafe("thread-1"), + occurredAt: NOW_ISO, + commandId: CommandId.makeUnsafe("cmd-queued-follow-up-reactor"), + causationEventId: null, + correlationId: "corr-queued-follow-up-reactor", + payload: { + createdAt: NOW_ISO, + threadId: ThreadId.makeUnsafe("thread-1"), + followUp: readModel.threads[0]!.queuedFollowUps[0]!, + }, + metadata: {}, + } as unknown as OrchestrationEvent; + const engine: OrchestrationEngineShape = { + getReadModel: () => Effect.succeed(readModel), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + if (command.type === "thread.queued-follow-up.remove") { + const nextQueuedFollowUps = readModel.threads[0]!.queuedFollowUps.filter( + (followUp) => followUp.id !== command.followUpId, + ); + readModel = { + ...readModel, + threads: [ + { + ...readModel.threads[0]!, + queuedFollowUps: nextQueuedFollowUps, + }, + ], + }; + } + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.fromIterable([threadEvent, threadEvent]), + }; + + runtime = ManagedRuntime.make( + QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + ), + ); + + const reactor = await runtime.runPromise(Effect.service(QueuedFollowUpReactor)); + const scope = await Effect.runPromise(Scope.make("sequential")); + + await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + await runtime.runPromise(reactor.drain); + + expect(dispatched.map((command) => command.type)).toEqual([ + "thread.turn.start", + "thread.queued-follow-up.remove", + ]); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); +}); diff --git a/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.ts b/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.ts new file mode 100644 index 0000000000..b7eb1ad233 --- /dev/null +++ b/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.ts @@ -0,0 +1,205 @@ +import { CommandId, MessageId, type OrchestrationEvent, type ThreadId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + buildQueuedFollowUpMessageText, + canDispatchQueuedFollowUp, +} from "@t3tools/shared/orchestration"; +import { Cause, Effect, Exit, Layer, Stream } from "effect"; + +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { + QueuedFollowUpReactor, + type QueuedFollowUpReactorShape, +} from "../Services/QueuedFollowUpReactor.ts"; + +const serverCommandId = (tag: string): CommandId => + CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`); + +const make = Effect.gen(function* () { + const orchestrationEngine = yield* OrchestrationEngineService; + const inFlightFollowUpIds = new Set(); + const pendingQueuedDispatchByThreadId = new Map(); + + const hasQueuedDispatchSettled = Effect.fnUntraced(function* (threadId: ThreadId) { + const dispatchedAt = pendingQueuedDispatchByThreadId.get(threadId); + if (!dispatchedAt) { + return true; + } + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find( + (entry) => entry.id === threadId && entry.deletedAt === null, + ); + if (!thread) { + return true; + } + if (thread.session?.status === "starting" || thread.session?.status === "running") { + return false; + } + if (thread.latestTurn && thread.latestTurn.requestedAt >= dispatchedAt) { + return thread.latestTurn.completedAt !== null; + } + return thread.activities.some( + (activity) => + activity.createdAt >= dispatchedAt && activity.kind === "provider.turn.start.failed", + ); + }); + + const processThread = Effect.fnUntraced(function* (threadId: ThreadId) { + const readModel = yield* orchestrationEngine.getReadModel(); + const thread = readModel.threads.find( + (entry) => entry.id === threadId && entry.deletedAt === null, + ); + if (!thread) { + pendingQueuedDispatchByThreadId.delete(threadId); + return; + } + if (pendingQueuedDispatchByThreadId.has(threadId)) { + const settled = yield* hasQueuedDispatchSettled(threadId); + if (!settled) { + return; + } + pendingQueuedDispatchByThreadId.delete(threadId); + } + const queuedHead = thread.queuedFollowUps[0]; + if (!queuedHead) { + return; + } + if ( + !canDispatchQueuedFollowUp({ + session: thread.session, + activities: thread.activities, + queuedFollowUpCount: thread.queuedFollowUps.length, + queuedHeadHasError: queuedHead.lastSendError !== null, + }) + ) { + return; + } + if (inFlightFollowUpIds.has(queuedHead.id)) { + return; + } + + inFlightFollowUpIds.add(queuedHead.id); + yield* Effect.gen(function* () { + const turnStartCreatedAt = new Date().toISOString(); + const turnStartExit = yield* Effect.exit( + orchestrationEngine.dispatch({ + type: "thread.turn.start", + commandId: serverCommandId("queued-follow-up-turn-start"), + threadId, + message: { + messageId: MessageId.makeUnsafe(crypto.randomUUID()), + role: "user", + text: buildQueuedFollowUpMessageText({ + prompt: queuedHead.prompt, + terminalContexts: queuedHead.terminalContexts, + attachmentCount: queuedHead.attachments.length, + }), + attachments: queuedHead.attachments, + }, + modelSelection: queuedHead.modelSelection, + runtimeMode: queuedHead.runtimeMode, + interactionMode: queuedHead.interactionMode, + createdAt: turnStartCreatedAt, + }), + ); + + if (Exit.isFailure(turnStartExit)) { + yield* orchestrationEngine + .dispatch({ + type: "thread.queued-follow-up.send-failed", + commandId: serverCommandId("queued-follow-up-send-failed"), + threadId, + followUpId: queuedHead.id, + lastSendError: Cause.pretty(turnStartExit.cause), + createdAt: new Date().toISOString(), + }) + .pipe( + Effect.catchCause((nestedCause) => + Effect.logWarning("queued follow-up reactor failed to persist send failure", { + threadId, + followUpId: queuedHead.id, + cause: Cause.pretty(nestedCause), + }), + ), + ); + return; + } + + pendingQueuedDispatchByThreadId.set(threadId, turnStartCreatedAt); + const removeExit = yield* Effect.exit( + orchestrationEngine.dispatch({ + type: "thread.queued-follow-up.remove", + commandId: serverCommandId("queued-follow-up-remove"), + threadId, + followUpId: queuedHead.id, + createdAt: new Date().toISOString(), + }), + ); + + if (Exit.isFailure(removeExit)) { + yield* orchestrationEngine + .dispatch({ + type: "thread.queued-follow-up.send-failed", + commandId: serverCommandId("queued-follow-up-send-failed"), + threadId, + followUpId: queuedHead.id, + lastSendError: "Queued follow-up was sent but queue cleanup failed.", + createdAt: new Date().toISOString(), + }) + .pipe( + Effect.catchCause((nestedCause) => + Effect.logWarning("queued follow-up reactor failed to persist send failure", { + threadId, + followUpId: queuedHead.id, + cause: Cause.pretty(nestedCause), + }), + ), + ); + } + }).pipe(Effect.ensuring(Effect.sync(() => inFlightFollowUpIds.delete(queuedHead.id)))); + }); + + const worker = yield* makeDrainableWorker((threadId: ThreadId) => + processThread(threadId).pipe( + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + return Effect.logWarning("queued follow-up reactor failed to process thread", { + threadId, + cause: Cause.pretty(cause), + }); + }), + ), + ); + + const enqueueThread = (threadId: ThreadId) => worker.enqueue(threadId); + + const start: QueuedFollowUpReactorShape["start"] = Effect.gen(function* () { + const snapshot = yield* orchestrationEngine.getReadModel(); + yield* Effect.forEach( + snapshot.threads, + (thread) => + thread.deletedAt === null && thread.queuedFollowUps.length > 0 + ? enqueueThread(thread.id) + : Effect.void, + { concurrency: 1 }, + ); + + yield* Effect.forkScoped( + Stream.runForEach(orchestrationEngine.streamDomainEvents, (event: OrchestrationEvent) => { + if (event.aggregateKind !== "thread") { + return Effect.void; + } + return enqueueThread(event.aggregateId as ThreadId); + }), + ); + }).pipe(Effect.asVoid); + + return { + start, + drain: worker.drain, + } satisfies QueuedFollowUpReactorShape; +}); + +export const QueuedFollowUpReactorLive = Layer.effect(QueuedFollowUpReactor, make); diff --git a/apps/server/src/orchestration/Services/QueuedFollowUpReactor.ts b/apps/server/src/orchestration/Services/QueuedFollowUpReactor.ts new file mode 100644 index 0000000000..d05815c039 --- /dev/null +++ b/apps/server/src/orchestration/Services/QueuedFollowUpReactor.ts @@ -0,0 +1,12 @@ +import { ServiceMap } from "effect"; +import type { Effect, Scope } from "effect"; + +export interface QueuedFollowUpReactorShape { + readonly start: Effect.Effect; + readonly drain: Effect.Effect; +} + +export class QueuedFollowUpReactor extends ServiceMap.Service< + QueuedFollowUpReactor, + QueuedFollowUpReactorShape +>()("t3/orchestration/Services/QueuedFollowUpReactor") {} diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index 43d665a2c9..c76ffc6b8d 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -69,6 +69,7 @@ const readModel: OrchestrationReadModel = { archivedAt: null, latestTurn: null, messages: [], + queuedFollowUps: [], session: null, activities: [], proposedPlans: [], @@ -92,6 +93,7 @@ const readModel: OrchestrationReadModel = { archivedAt: null, latestTurn: null, messages: [], + queuedFollowUps: [], session: null, activities: [], proposedPlans: [], diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c70194befa..5617277b39 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -407,6 +407,123 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.queued-follow-up.enqueue": { + yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.queued-follow-up-enqueued", + payload: { + threadId: command.threadId, + followUp: command.followUp, + ...(command.targetIndex !== undefined ? { targetIndex: command.targetIndex } : {}), + createdAt: command.createdAt, + }, + }; + } + + case "thread.queued-follow-up.update": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingFollowUp = thread.queuedFollowUps.find( + (followUp) => followUp.id === command.followUp.id, + ); + if (!existingFollowUp) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued follow-up '${command.followUp.id}' does not exist on thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.queued-follow-up-updated", + payload: { + threadId: command.threadId, + followUp: command.followUp, + createdAt: command.createdAt, + }, + }; + } + + case "thread.queued-follow-up.remove": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingFollowUp = thread.queuedFollowUps.find( + (followUp) => followUp.id === command.followUpId, + ); + if (!existingFollowUp) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued follow-up '${command.followUpId}' does not exist on thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.queued-follow-up-removed", + payload: { + threadId: command.threadId, + followUpId: command.followUpId, + createdAt: command.createdAt, + }, + }; + } + + case "thread.queued-follow-up.reorder": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingIndex = thread.queuedFollowUps.findIndex( + (followUp) => followUp.id === command.followUpId, + ); + if (existingIndex < 0) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued follow-up '${command.followUpId}' does not exist on thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.queued-follow-up-reordered", + payload: { + threadId: command.threadId, + followUpId: command.followUpId, + targetIndex: command.targetIndex, + createdAt: command.createdAt, + }, + }; + } + case "thread.approval.respond": { yield* requireThread({ readModel, @@ -677,6 +794,69 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.queued-follow-up.send-failed": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingFollowUp = thread.queuedFollowUps.find( + (followUp) => followUp.id === command.followUpId, + ); + if (!existingFollowUp) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued follow-up '${command.followUpId}' does not exist on thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.queued-follow-up-send-failed", + payload: { + threadId: command.threadId, + followUpId: command.followUpId, + lastSendError: command.lastSendError, + createdAt: command.createdAt, + }, + }; + } + + case "thread.queued-follow-up.send-error-cleared": { + const thread = yield* requireThread({ + readModel, + command, + threadId: command.threadId, + }); + const existingFollowUp = thread.queuedFollowUps.find( + (followUp) => followUp.id === command.followUpId, + ); + if (!existingFollowUp) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Queued follow-up '${command.followUpId}' does not exist on thread '${command.threadId}'.`, + }); + } + return { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.queued-follow-up-send-error-cleared", + payload: { + threadId: command.threadId, + followUpId: command.followUpId, + createdAt: command.createdAt, + }, + }; + } + default: { command satisfies never; const fallback = command as never as { type: string }; diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 3dcdd19250..f26f6b34ca 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -91,6 +91,7 @@ describe("orchestration projector", () => { deletedAt: null, messages: [], proposedPlans: [], + queuedFollowUps: [], activities: [], checkpoints: [], session: null, @@ -132,6 +133,89 @@ describe("orchestration projector", () => { ).rejects.toBeDefined(); }); + it("tracks queued follow-ups in the in-memory thread snapshot", async () => { + const createdAt = "2026-03-28T12:00:00.000Z"; + const queuedAt = "2026-03-28T12:00:05.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-queue", + occurredAt: createdAt, + commandId: "cmd-create-queue", + payload: { + threadId: "thread-queue", + projectId: "project-1", + title: "queue demo", + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt, + updatedAt: createdAt, + }, + }), + ), + ); + + const afterQueue = await Effect.runPromise( + projectEvent( + afterCreate, + makeEvent({ + sequence: 2, + type: "thread.queued-follow-up-enqueued", + aggregateKind: "thread", + aggregateId: "thread-queue", + occurredAt: queuedAt, + commandId: "cmd-queue-enqueue", + payload: { + threadId: "thread-queue", + followUp: { + id: "follow-up-1", + createdAt: queuedAt, + prompt: "queue this next", + attachments: [], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + createdAt: queuedAt, + }, + }), + ), + ); + + expect(afterQueue.threads[0]?.queuedFollowUps).toEqual([ + { + id: "follow-up-1", + createdAt: queuedAt, + prompt: "queue this next", + attachments: [], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + ]); + }); + it("applies thread.archived and thread.unarchived events", async () => { const now = new Date().toISOString(); const later = new Date(Date.parse(now) + 1_000).toISOString(); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index deb8a6d44d..59321a5808 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -4,6 +4,12 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + ThreadQueuedFollowUpEnqueuedPayload, + ThreadQueuedFollowUpRemovedPayload, + ThreadQueuedFollowUpReorderedPayload, + ThreadQueuedFollowUpSendErrorClearedPayload, + ThreadQueuedFollowUpSendFailedPayload, + ThreadQueuedFollowUpUpdatedPayload, } from "@t3tools/contracts"; import { Effect, Schema } from "effect"; @@ -265,6 +271,7 @@ export function projectEvent( archivedAt: null, deletedAt: null, messages: [], + queuedFollowUps: [], activities: [], checkpoints: [], session: null, @@ -418,6 +425,180 @@ export function projectEvent( }; }); + case "thread.queued-follow-up-enqueued": + return decodeForEvent( + ThreadQueuedFollowUpEnqueuedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: nextBase.threads.map((thread) => { + if (thread.id !== payload.threadId) { + return thread; + } + const existingWithoutFollowUp = thread.queuedFollowUps.filter( + (followUp) => followUp.id !== payload.followUp.id, + ); + const targetIndex = + payload.targetIndex === undefined + ? existingWithoutFollowUp.length + : Math.max(0, Math.min(payload.targetIndex, existingWithoutFollowUp.length)); + return { + ...thread, + queuedFollowUps: [ + ...existingWithoutFollowUp.slice(0, targetIndex), + payload.followUp, + ...existingWithoutFollowUp.slice(targetIndex), + ], + updatedAt: event.occurredAt, + }; + }), + })), + ); + + case "thread.queued-follow-up-updated": + return decodeForEvent( + ThreadQueuedFollowUpUpdatedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: nextBase.threads.map((thread) => { + if (thread.id !== payload.threadId) { + return thread; + } + return { + ...thread, + queuedFollowUps: thread.queuedFollowUps.map((followUp) => + followUp.id === payload.followUp.id ? payload.followUp : followUp, + ), + updatedAt: event.occurredAt, + }; + }), + })), + ); + + case "thread.queued-follow-up-removed": + return decodeForEvent( + ThreadQueuedFollowUpRemovedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: nextBase.threads.map((thread) => { + if (thread.id !== payload.threadId) { + return thread; + } + return { + ...thread, + queuedFollowUps: thread.queuedFollowUps.filter( + (followUp) => followUp.id !== payload.followUpId, + ), + updatedAt: event.occurredAt, + }; + }), + })), + ); + + case "thread.queued-follow-up-reordered": + return decodeForEvent( + ThreadQueuedFollowUpReorderedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: nextBase.threads.map((thread) => { + if (thread.id !== payload.threadId) { + return thread; + } + const currentIndex = thread.queuedFollowUps.findIndex( + (followUp) => followUp.id === payload.followUpId, + ); + if (currentIndex < 0) { + return thread; + } + const boundedTargetIndex = Math.max( + 0, + Math.min(payload.targetIndex, thread.queuedFollowUps.length - 1), + ); + if (boundedTargetIndex === currentIndex) { + return thread; + } + const nextQueuedFollowUps = [...thread.queuedFollowUps]; + const [movedFollowUp] = nextQueuedFollowUps.splice(currentIndex, 1); + if (!movedFollowUp) { + return thread; + } + nextQueuedFollowUps.splice(boundedTargetIndex, 0, movedFollowUp); + return { + ...thread, + queuedFollowUps: nextQueuedFollowUps, + updatedAt: event.occurredAt, + }; + }), + })), + ); + + case "thread.queued-follow-up-send-failed": + return decodeForEvent( + ThreadQueuedFollowUpSendFailedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: nextBase.threads.map((thread) => { + if (thread.id !== payload.threadId) { + return thread; + } + return { + ...thread, + queuedFollowUps: thread.queuedFollowUps.map((followUp) => + followUp.id === payload.followUpId + ? { ...followUp, lastSendError: payload.lastSendError } + : followUp, + ), + updatedAt: event.occurredAt, + }; + }), + })), + ); + + case "thread.queued-follow-up-send-error-cleared": + return decodeForEvent( + ThreadQueuedFollowUpSendErrorClearedPayload, + event.payload, + event.type, + "payload", + ).pipe( + Effect.map((payload) => ({ + ...nextBase, + threads: nextBase.threads.map((thread) => { + if (thread.id !== payload.threadId) { + return thread; + } + return { + ...thread, + queuedFollowUps: thread.queuedFollowUps.map((followUp) => + followUp.id === payload.followUpId + ? { ...followUp, lastSendError: null } + : followUp, + ), + updatedAt: event.occurredAt, + }; + }), + })), + ); + case "thread.session-set": return Effect.gen(function* () { const payload = yield* decodeForEvent( diff --git a/apps/server/src/persistence/Layers/ProjectionThreadQueuedFollowUps.ts b/apps/server/src/persistence/Layers/ProjectionThreadQueuedFollowUps.ts new file mode 100644 index 0000000000..559f5c16c3 --- /dev/null +++ b/apps/server/src/persistence/Layers/ProjectionThreadQueuedFollowUps.ts @@ -0,0 +1,178 @@ +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { Effect, Layer, Schema, Struct } from "effect"; +import { + ChatAttachment, + ModelSelection, + OrchestrationQueuedTerminalContext, +} from "@t3tools/contracts"; + +import { toPersistenceSqlError } from "../Errors.ts"; +import { + DeleteProjectionThreadQueuedFollowUpsInput, + GetProjectionThreadQueuedFollowUpInput, + ListProjectionThreadQueuedFollowUpsInput, + ProjectionThreadQueuedFollowUp, + ProjectionThreadQueuedFollowUpRepository, + type ProjectionThreadQueuedFollowUpRepositoryShape, +} from "../Services/ProjectionThreadQueuedFollowUps.ts"; + +const ProjectionThreadQueuedFollowUpDbRowSchema = ProjectionThreadQueuedFollowUp.mapFields( + Struct.assign({ + attachments: Schema.fromJsonString(Schema.Array(ChatAttachment)), + terminalContexts: Schema.fromJsonString(Schema.Array(OrchestrationQueuedTerminalContext)), + modelSelection: Schema.fromJsonString(ModelSelection), + }), +); + +const makeProjectionThreadQueuedFollowUpRepository = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const listQueuedFollowUpRows = SqlSchema.findAll({ + Request: ListProjectionThreadQueuedFollowUpsInput, + Result: ProjectionThreadQueuedFollowUpDbRowSchema, + execute: ({ threadId }) => + sql` + SELECT + follow_up_id AS "followUpId", + thread_id AS "threadId", + queue_position AS "queuePosition", + created_at AS "createdAt", + updated_at AS "updatedAt", + prompt, + attachments_json AS "attachments", + terminal_contexts_json AS "terminalContexts", + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + last_send_error AS "lastSendError" + FROM projection_thread_queued_follow_ups + WHERE thread_id = ${threadId} + ORDER BY queue_position ASC, created_at ASC, follow_up_id ASC + `, + }); + + const getQueuedFollowUpRow = SqlSchema.findOneOption({ + Request: GetProjectionThreadQueuedFollowUpInput, + Result: ProjectionThreadQueuedFollowUpDbRowSchema, + execute: ({ followUpId }) => + sql` + SELECT + follow_up_id AS "followUpId", + thread_id AS "threadId", + queue_position AS "queuePosition", + created_at AS "createdAt", + updated_at AS "updatedAt", + prompt, + attachments_json AS "attachments", + terminal_contexts_json AS "terminalContexts", + model_selection_json AS "modelSelection", + runtime_mode AS "runtimeMode", + interaction_mode AS "interactionMode", + last_send_error AS "lastSendError" + FROM projection_thread_queued_follow_ups + WHERE follow_up_id = ${followUpId} + `, + }); + + const deleteQueuedFollowUpsByThreadId = SqlSchema.void({ + Request: DeleteProjectionThreadQueuedFollowUpsInput, + execute: ({ threadId }) => + sql` + DELETE FROM projection_thread_queued_follow_ups + WHERE thread_id = ${threadId} + `, + }); + + const insertQueuedFollowUpRow = SqlSchema.void({ + Request: ProjectionThreadQueuedFollowUp, + execute: (row) => + sql` + INSERT INTO projection_thread_queued_follow_ups ( + follow_up_id, + thread_id, + queue_position, + created_at, + updated_at, + prompt, + attachments_json, + terminal_contexts_json, + model_selection_json, + runtime_mode, + interaction_mode, + last_send_error + ) + VALUES ( + ${row.followUpId}, + ${row.threadId}, + ${row.queuePosition}, + ${row.createdAt}, + ${row.updatedAt}, + ${row.prompt}, + ${JSON.stringify(row.attachments)}, + ${JSON.stringify(row.terminalContexts)}, + ${JSON.stringify(row.modelSelection)}, + ${row.runtimeMode}, + ${row.interactionMode}, + ${row.lastSendError} + ) + `, + }); + + const listByThreadId: ProjectionThreadQueuedFollowUpRepositoryShape["listByThreadId"] = (input) => + listQueuedFollowUpRows(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadQueuedFollowUpRepository.listByThreadId:query"), + ), + ); + + const getById: ProjectionThreadQueuedFollowUpRepositoryShape["getById"] = (input) => + getQueuedFollowUpRow(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadQueuedFollowUpRepository.getById:query"), + ), + ); + + const replaceByThreadId: ProjectionThreadQueuedFollowUpRepositoryShape["replaceByThreadId"] = ( + input, + ) => + Effect.gen(function* () { + yield* deleteQueuedFollowUpsByThreadId({ threadId: input.threadId }).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadQueuedFollowUpRepository.replaceByThreadId:delete", + ), + ), + ); + yield* Effect.forEach(input.followUps, (followUp) => + insertQueuedFollowUpRow(followUp).pipe( + Effect.mapError( + toPersistenceSqlError( + "ProjectionThreadQueuedFollowUpRepository.replaceByThreadId:insert", + ), + ), + ), + ).pipe(Effect.asVoid); + }); + + const deleteByThreadId: ProjectionThreadQueuedFollowUpRepositoryShape["deleteByThreadId"] = ( + input, + ) => + deleteQueuedFollowUpsByThreadId(input).pipe( + Effect.mapError( + toPersistenceSqlError("ProjectionThreadQueuedFollowUpRepository.deleteByThreadId:query"), + ), + ); + + return { + listByThreadId, + getById, + replaceByThreadId, + deleteByThreadId, + } satisfies ProjectionThreadQueuedFollowUpRepositoryShape; +}); + +export const ProjectionThreadQueuedFollowUpRepositoryLive = Layer.effect( + ProjectionThreadQueuedFollowUpRepository, + makeProjectionThreadQueuedFollowUpRepository, +); diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index c759665f06..7414b8a91e 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -31,6 +31,7 @@ import Migration0015 from "./Migrations/015_ProjectionTurnsSourceProposedPlan.ts import Migration0016 from "./Migrations/016_CanonicalizeModelSelections.ts"; import Migration0017 from "./Migrations/017_ProjectionThreadsArchivedAt.ts"; import Migration0018 from "./Migrations/018_ProjectionThreadsArchivedAtIndex.ts"; +import Migration0019 from "./Migrations/019_ProjectionThreadQueuedFollowUps.ts"; /** * Migration loader with all migrations defined inline. @@ -61,6 +62,7 @@ export const migrationEntries = [ [16, "CanonicalizeModelSelections", Migration0016], [17, "ProjectionThreadsArchivedAt", Migration0017], [18, "ProjectionThreadsArchivedAtIndex", Migration0018], + [19, "ProjectionThreadQueuedFollowUps", Migration0019], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/019_ProjectionThreadQueuedFollowUps.ts b/apps/server/src/persistence/Migrations/019_ProjectionThreadQueuedFollowUps.ts new file mode 100644 index 0000000000..7b2171ab82 --- /dev/null +++ b/apps/server/src/persistence/Migrations/019_ProjectionThreadQueuedFollowUps.ts @@ -0,0 +1,29 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* sql` + CREATE TABLE projection_thread_queued_follow_ups ( + follow_up_id TEXT PRIMARY KEY, + thread_id TEXT NOT NULL, + queue_position INTEGER NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + prompt TEXT NOT NULL, + attachments_json TEXT NOT NULL, + terminal_contexts_json TEXT NOT NULL, + model_selection_json TEXT NOT NULL, + runtime_mode TEXT NOT NULL, + interaction_mode TEXT NOT NULL, + last_send_error TEXT, + FOREIGN KEY (thread_id) REFERENCES projection_threads(thread_id) ON DELETE CASCADE + ) + `; + + yield* sql` + CREATE INDEX idx_projection_thread_queued_follow_ups_thread_position + ON projection_thread_queued_follow_ups(thread_id, queue_position, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadQueuedFollowUps.ts b/apps/server/src/persistence/Services/ProjectionThreadQueuedFollowUps.ts new file mode 100644 index 0000000000..43256c127f --- /dev/null +++ b/apps/server/src/persistence/Services/ProjectionThreadQueuedFollowUps.ts @@ -0,0 +1,95 @@ +import { + IsoDateTime, + ModelSelection, + OrchestrationQueuedFollowUp, + OrchestrationQueuedTerminalContext, + RuntimeMode, + ProviderInteractionMode, + ThreadId, + TrimmedNonEmptyString, + NonNegativeInt, + ChatAttachment, +} from "@t3tools/contracts"; +import { Option, Schema, ServiceMap } from "effect"; +import type { Effect } from "effect"; + +import type { ProjectionRepositoryError } from "../Errors.ts"; + +export const ProjectionThreadQueuedFollowUp = Schema.Struct({ + followUpId: TrimmedNonEmptyString, + threadId: ThreadId, + queuePosition: NonNegativeInt, + createdAt: IsoDateTime, + updatedAt: IsoDateTime, + prompt: Schema.String, + attachments: Schema.Array(ChatAttachment), + terminalContexts: Schema.Array(OrchestrationQueuedTerminalContext), + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + lastSendError: Schema.NullOr(TrimmedNonEmptyString), +}); +export type ProjectionThreadQueuedFollowUp = typeof ProjectionThreadQueuedFollowUp.Type; + +export const ListProjectionThreadQueuedFollowUpsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type ListProjectionThreadQueuedFollowUpsInput = + typeof ListProjectionThreadQueuedFollowUpsInput.Type; + +export const GetProjectionThreadQueuedFollowUpInput = Schema.Struct({ + followUpId: TrimmedNonEmptyString, +}); +export type GetProjectionThreadQueuedFollowUpInput = + typeof GetProjectionThreadQueuedFollowUpInput.Type; + +export const ReplaceProjectionThreadQueuedFollowUpsInput = Schema.Struct({ + threadId: ThreadId, + followUps: Schema.Array(ProjectionThreadQueuedFollowUp), +}); +export type ReplaceProjectionThreadQueuedFollowUpsInput = + typeof ReplaceProjectionThreadQueuedFollowUpsInput.Type; + +export const DeleteProjectionThreadQueuedFollowUpsInput = Schema.Struct({ + threadId: ThreadId, +}); +export type DeleteProjectionThreadQueuedFollowUpsInput = + typeof DeleteProjectionThreadQueuedFollowUpsInput.Type; + +export function projectionQueuedFollowUpToContract( + row: ProjectionThreadQueuedFollowUp, +): OrchestrationQueuedFollowUp { + return { + id: row.followUpId, + createdAt: row.createdAt, + prompt: row.prompt, + attachments: row.attachments, + terminalContexts: row.terminalContexts, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + lastSendError: row.lastSendError, + }; +} + +export interface ProjectionThreadQueuedFollowUpRepositoryShape { + readonly listByThreadId: ( + input: ListProjectionThreadQueuedFollowUpsInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly getById: ( + input: GetProjectionThreadQueuedFollowUpInput, + ) => Effect.Effect, ProjectionRepositoryError>; + readonly replaceByThreadId: ( + input: ReplaceProjectionThreadQueuedFollowUpsInput, + ) => Effect.Effect; + readonly deleteByThreadId: ( + input: DeleteProjectionThreadQueuedFollowUpsInput, + ) => Effect.Effect; +} + +export class ProjectionThreadQueuedFollowUpRepository extends ServiceMap.Service< + ProjectionThreadQueuedFollowUpRepository, + ProjectionThreadQueuedFollowUpRepositoryShape +>()( + "t3/persistence/Services/ProjectionThreadQueuedFollowUps/ProjectionThreadQueuedFollowUpRepository", +) {} diff --git a/apps/server/src/serverLayers.ts b/apps/server/src/serverLayers.ts index a8c1a13f7f..0502107029 100644 --- a/apps/server/src/serverLayers.ts +++ b/apps/server/src/serverLayers.ts @@ -15,6 +15,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma import { OrchestrationProjectionPipelineLive } from "./orchestration/Layers/ProjectionPipeline"; import { OrchestrationProjectionSnapshotQueryLive } from "./orchestration/Layers/ProjectionSnapshotQuery"; import { ProviderRuntimeIngestionLive } from "./orchestration/Layers/ProviderRuntimeIngestion"; +import { QueuedFollowUpReactorLive } from "./orchestration/Layers/QueuedFollowUpReactor"; import { RuntimeReceiptBusLive } from "./orchestration/Layers/RuntimeReceiptBus"; import { ProviderUnsupportedError } from "./provider/Errors"; import { makeClaudeAdapterLive } from "./provider/Layers/ClaudeAdapter"; @@ -122,10 +123,14 @@ export function makeServerRuntimeServicesLayer() { const checkpointReactorLayer = CheckpointReactorLive.pipe( Layer.provideMerge(runtimeServicesLayer), ); + const queuedFollowUpReactorLayer = QueuedFollowUpReactorLive.pipe( + Layer.provideMerge(runtimeServicesLayer), + ); const orchestrationReactorLayer = OrchestrationReactorLive.pipe( Layer.provideMerge(runtimeIngestionLayer), Layer.provideMerge(providerCommandReactorLayer), Layer.provideMerge(checkpointReactorLayer), + Layer.provideMerge(queuedFollowUpReactorLayer), ); const terminalLayer = TerminalManagerLive.pipe(Layer.provide(makeRuntimePtyAdapterLayer())); diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 826b9ad6fd..39230132bd 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -10,6 +10,7 @@ import { createServer } from "./wsServer"; import WebSocket from "ws"; import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config"; import { makeServerProviderLayer, makeServerRuntimeServicesLayer } from "./serverLayers"; +import { resolveAttachmentPathById } from "./attachmentStore"; import { DEFAULT_TERMINAL_ID, @@ -62,6 +63,15 @@ const asProviderItemId = (value: string): ProviderItemId => ProviderItemId.makeU const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value); const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value); +interface FetchResponseLike { + status: number; + headers: { + get(name: string): string | null; + }; + text(): Promise; + arrayBuffer(): Promise; +} + const defaultOpenService: OpenShape = { openBrowser: () => Effect.void, openInEditor: () => Effect.void, @@ -87,6 +97,8 @@ const defaultProviderRegistryService: ProviderRegistryShape = { }; const defaultServerSettings = DEFAULT_SERVER_SETTINGS; +const TINY_PNG_DATA_URL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO9W6R8AAAAASUVORK5CYII="; class MockTerminalManager implements TerminalManagerShape { private readonly sessions = new Map(); @@ -483,6 +495,24 @@ describe("WebSocket Server", () => { return dir; } + async function removeTempDir(dir: string): Promise { + for (let attempt = 0; attempt < 20; attempt += 1) { + try { + fs.rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + const code = + typeof error === "object" && error !== null && "code" in error + ? String((error as { code?: unknown }).code) + : null; + if ((code !== "EPERM" && code !== "EBUSY") || attempt === 19) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } + } + } + async function createTestServer( options: { persistenceLayer?: Layer.Layer< @@ -592,7 +622,7 @@ describe("WebSocket Server", () => { await closeTestServer(); server = null; for (const dir of tempDirs.splice(0, tempDirs.length)) { - fs.rmSync(dir, { recursive: true, force: true }); + await removeTempDir(dir); } vi.restoreAllMocks(); }); @@ -625,7 +655,9 @@ describe("WebSocket Server", () => { const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); - const response = await fetch(`http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`); + const response = (await fetch( + `http://127.0.0.1:${port}/attachments/thread-a/message-a/0.png`, + )) as unknown as FetchResponseLike; expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("image/png"); const bytes = Buffer.from(await response.arrayBuffer()); @@ -649,9 +681,9 @@ describe("WebSocket Server", () => { const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); - const response = await fetch( + const response = (await fetch( `http://127.0.0.1:${port}/attachments/thread%20folder/message%20folder/file%20name.png`, - ); + )) as unknown as FetchResponseLike; expect(response.status).toBe(200); expect(response.headers.get("content-type")).toContain("image/png"); const bytes = Buffer.from(await response.arrayBuffer()); @@ -668,7 +700,7 @@ describe("WebSocket Server", () => { const port = typeof addr === "object" && addr !== null ? addr.port : 0; expect(port).toBeGreaterThan(0); - const response = await fetch(`http://127.0.0.1:${port}/`); + const response = (await fetch(`http://127.0.0.1:${port}/`)) as unknown as FetchResponseLike; expect(response.status).toBe(200); expect(await response.text()).toContain("static-root"); }); @@ -1381,6 +1413,126 @@ describe("WebSocket Server", () => { expect(domainEvent.payload.text).toBe("hello from runtime"); }); + it("normalizes queued follow-up image attachments into persisted server attachments", async () => { + const baseDir = makeTempDir("t3code-ws-queued-attachments-"); + const { attachmentsDir } = deriveServerPathsSync(baseDir, undefined); + const workspaceRoot = path.join(baseDir, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + + server = await createTestServer({ + cwd: "/test", + baseDir, + }); + const addr = server.address(); + const port = typeof addr === "object" && addr !== null ? addr.port : 0; + + const [ws] = await connectAndAwaitWelcome(port); + connections.push(ws); + + const createdAt = new Date().toISOString(); + const createProjectResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "project.create", + commandId: "cmd-queued-attachment-project-create", + projectId: "project-queued-attachment", + title: "Queued Attachment Project", + workspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); + + const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.create", + commandId: "cmd-queued-attachment-thread-create", + threadId: "thread-queued-attachment", + projectId: "project-queued-attachment", + title: "Queued Attachment Thread", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + createdAt, + }); + expect(createThreadResponse.error).toBeUndefined(); + + const enqueueResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.queued-follow-up.enqueue", + commandId: "cmd-queued-attachment-enqueue", + threadId: "thread-queued-attachment", + followUp: { + id: "follow-up-queued-attachment-1", + createdAt, + prompt: "Queue with image", + attachments: [ + { + type: "image", + name: "queued.png", + mimeType: "image/png", + sizeBytes: 68, + dataUrl: TINY_PNG_DATA_URL, + }, + ], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + createdAt, + }); + expect(enqueueResponse.error).toBeUndefined(); + + const snapshotResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.getSnapshot); + expect(snapshotResponse.error).toBeUndefined(); + const snapshot = snapshotResponse.result as { + threads: Array<{ + id: string; + queuedFollowUps: Array<{ + attachments: Array<{ + id: string; + name: string; + mimeType: string; + sizeBytes: number; + }>; + }>; + }>; + }; + const queuedAttachment = snapshot.threads.find( + (thread) => thread.id === "thread-queued-attachment", + )?.queuedFollowUps[0]?.attachments[0]; + + expect(queuedAttachment).toEqual({ + type: "image", + id: expect.any(String), + name: "queued.png", + mimeType: "image/png", + sizeBytes: expect.any(Number), + }); + + const persistedPath = resolveAttachmentPathById({ + attachmentsDir, + attachmentId: queuedAttachment!.id, + }); + expect(persistedPath).toBeTruthy(); + expect(fs.existsSync(persistedPath!)).toBe(true); + + const attachmentResponse = (await fetch( + `http://127.0.0.1:${port}/attachments/${queuedAttachment!.id}`, + )) as unknown as FetchResponseLike; + expect(attachmentResponse.status).toBe(200); + expect(attachmentResponse.headers.get("content-type")).toBe("image/png"); + }); + it("routes terminal RPC methods and broadcasts terminal events", async () => { const cwd = makeTempDir("t3code-ws-terminal-cwd-"); const terminalManager = new MockTerminalManager(); diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 7290660cf4..31310017d5 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,8 +12,10 @@ import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; import { CommandId, + type ClientChatAttachment, DEFAULT_PROVIDER_INTERACTION_MODE, type ClientOrchestrationCommand, + type OrchestrationQueuedFollowUp, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, @@ -312,6 +314,80 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { readonly command: ClientOrchestrationCommand; }) { + const normalizeClientAttachments = Effect.fnUntraced(function* ( + threadId: ThreadId, + attachments: ReadonlyArray, + ) { + return yield* Effect.forEach( + attachments, + (attachment) => + Effect.gen(function* () { + if ("id" in attachment) { + return attachment; + } + + const parsed = parseBase64DataUrl(attachment.dataUrl); + if (!parsed || !parsed.mimeType.startsWith("image/")) { + return yield* new RouteRequestError({ + message: `Invalid image attachment payload for '${attachment.name}'.`, + }); + } + + const bytes = Buffer.from(parsed.base64, "base64"); + if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { + return yield* new RouteRequestError({ + message: `Image attachment '${attachment.name}' is empty or too large.`, + }); + } + + const attachmentId = createAttachmentId(threadId); + if (!attachmentId) { + return yield* new RouteRequestError({ + message: "Failed to create a safe attachment id.", + }); + } + + const persistedAttachment = { + type: "image" as const, + id: attachmentId, + name: attachment.name, + mimeType: parsed.mimeType.toLowerCase(), + sizeBytes: bytes.byteLength, + }; + + const attachmentPath = resolveAttachmentPath({ + attachmentsDir: serverConfig.attachmentsDir, + attachment: persistedAttachment, + }); + if (!attachmentPath) { + return yield* new RouteRequestError({ + message: `Failed to resolve persisted path for '${attachment.name}'.`, + }); + } + + yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to create attachment directory for '${attachment.name}'.`, + }), + ), + ); + yield* fileSystem.writeFile(attachmentPath, bytes).pipe( + Effect.mapError( + () => + new RouteRequestError({ + message: `Failed to persist attachment '${attachment.name}'.`, + }), + ), + ); + + return persistedAttachment; + }), + { concurrency: 1 }, + ); + }); + const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); const workspaceStat = yield* fileSystem @@ -345,73 +421,36 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< } if (input.command.type !== "thread.turn.start") { + if (input.command.type === "thread.queued-follow-up.enqueue") { + return { + ...input.command, + followUp: { + ...input.command.followUp, + attachments: (yield* normalizeClientAttachments( + input.command.threadId, + input.command.followUp.attachments, + )) as OrchestrationQueuedFollowUp["attachments"], + }, + } satisfies OrchestrationCommand; + } + if (input.command.type === "thread.queued-follow-up.update") { + return { + ...input.command, + followUp: { + ...input.command.followUp, + attachments: (yield* normalizeClientAttachments( + input.command.threadId, + input.command.followUp.attachments, + )) as OrchestrationQueuedFollowUp["attachments"], + }, + } satisfies OrchestrationCommand; + } return input.command as OrchestrationCommand; } const turnStartCommand = input.command; - - const normalizedAttachments = yield* Effect.forEach( + const normalizedAttachments = yield* normalizeClientAttachments( + turnStartCommand.threadId, turnStartCommand.message.attachments, - (attachment) => - Effect.gen(function* () { - const parsed = parseBase64DataUrl(attachment.dataUrl); - if (!parsed || !parsed.mimeType.startsWith("image/")) { - return yield* new RouteRequestError({ - message: `Invalid image attachment payload for '${attachment.name}'.`, - }); - } - - const bytes = Buffer.from(parsed.base64, "base64"); - if (bytes.byteLength === 0 || bytes.byteLength > PROVIDER_SEND_TURN_MAX_IMAGE_BYTES) { - return yield* new RouteRequestError({ - message: `Image attachment '${attachment.name}' is empty or too large.`, - }); - } - - const attachmentId = createAttachmentId(turnStartCommand.threadId); - if (!attachmentId) { - return yield* new RouteRequestError({ - message: "Failed to create a safe attachment id.", - }); - } - - const persistedAttachment = { - type: "image" as const, - id: attachmentId, - name: attachment.name, - mimeType: parsed.mimeType.toLowerCase(), - sizeBytes: bytes.byteLength, - }; - - const attachmentPath = resolveAttachmentPath({ - attachmentsDir: serverConfig.attachmentsDir, - attachment: persistedAttachment, - }); - if (!attachmentPath) { - return yield* new RouteRequestError({ - message: `Failed to resolve persisted path for '${attachment.name}'.`, - }); - } - - yield* fileSystem.makeDirectory(path.dirname(attachmentPath), { recursive: true }).pipe( - Effect.mapError( - () => - new RouteRequestError({ - message: `Failed to create attachment directory for '${attachment.name}'.`, - }), - ), - ); - yield* fileSystem.writeFile(attachmentPath, bytes).pipe( - Effect.mapError( - () => - new RouteRequestError({ - message: `Failed to persist attachment '${attachment.name}'.`, - }), - ), - ); - - return persistedAttachment; - }), - { concurrency: 1 }, ); return { diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..b86ff0c32f 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -261,6 +261,7 @@ function createSnapshotForTargetUser(options: { archivedAt: null, deletedAt: null, messages, + queuedFollowUps: [], activities: [], proposedPlans: [], checkpoints: [], @@ -319,6 +320,7 @@ function addThreadToSnapshot( archivedAt: null, deletedAt: null, messages: [], + queuedFollowUps: [], activities: [], proposedPlans: [], checkpoints: [], @@ -435,6 +437,45 @@ function createSnapshotWithLongProposedPlan(): OrchestrationReadModel { }; } +function updateFixtureThread( + updater: ( + thread: OrchestrationReadModel["threads"][number], + ) => OrchestrationReadModel["threads"][number], +): void { + fixture.snapshot = { + ...fixture.snapshot, + threads: fixture.snapshot.threads.map((thread) => + thread.id === THREAD_ID ? updater(thread) : thread, + ), + }; + useStore.getState().syncServerReadModel(fixture.snapshot); +} + +function getQueuedFollowUpPrompts(): string[] { + return ( + useStore + .getState() + .threads.find((thread) => thread.id === THREAD_ID) + ?.queuedFollowUps?.map((followUp) => followUp.prompt) ?? [] + ); +} + +function getQueuedFollowUpEnqueueRequests(): Array< + WsRequestEnvelope["body"] & { command: { type: string } } +> { + return wsRequests.flatMap((request) => { + const command = (request as { command?: { type?: string } }).command; + if ( + request._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand || + !command || + command.type !== "thread.queued-follow-up.enqueue" + ) { + return []; + } + return [request as WsRequestEnvelope["body"] & { command: { type: string } }]; + }); +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const customResult = customWsRpcResolver?.(body); if (customResult !== undefined) { @@ -444,6 +485,123 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (tag === ORCHESTRATION_WS_METHODS.getSnapshot) { return fixture.snapshot; } + if (tag === ORCHESTRATION_WS_METHODS.dispatchCommand) { + const command = (body as { command?: Record }).command; + switch (command?.type) { + case "thread.queued-follow-up.enqueue": { + const followUp = + command.followUp as OrchestrationReadModel["threads"][number]["queuedFollowUps"][number]; + const rawTargetIndex = + typeof command.targetIndex === "number" ? command.targetIndex : undefined; + updateFixtureThread((thread) => { + const withoutExisting = thread.queuedFollowUps.filter( + (entry) => entry.id !== followUp.id, + ); + const targetIndex = + rawTargetIndex === undefined + ? withoutExisting.length + : Math.max(0, Math.min(rawTargetIndex, withoutExisting.length)); + return { + ...thread, + queuedFollowUps: [ + ...withoutExisting.slice(0, targetIndex), + { ...followUp, lastSendError: followUp.lastSendError ?? null }, + ...withoutExisting.slice(targetIndex), + ], + }; + }); + return { sequence: 1 }; + } + case "thread.queued-follow-up.remove": { + const followUpId = typeof command.followUpId === "string" ? command.followUpId : null; + if (followUpId) { + updateFixtureThread((thread) => ({ + ...thread, + queuedFollowUps: thread.queuedFollowUps.filter((entry) => entry.id !== followUpId), + })); + } + return { sequence: 1 }; + } + case "thread.queued-follow-up.reorder": { + const followUpId = typeof command.followUpId === "string" ? command.followUpId : null; + const rawTargetIndex = + typeof command.targetIndex === "number" ? Math.floor(command.targetIndex) : null; + if (followUpId !== null && rawTargetIndex !== null) { + updateFixtureThread((thread) => { + const currentIndex = thread.queuedFollowUps.findIndex( + (entry) => entry.id === followUpId, + ); + if (currentIndex < 0) { + return thread; + } + const boundedTargetIndex = Math.max( + 0, + Math.min(rawTargetIndex, thread.queuedFollowUps.length - 1), + ); + if (boundedTargetIndex === currentIndex) { + return thread; + } + const nextQueuedFollowUps = [...thread.queuedFollowUps]; + const [movedFollowUp] = nextQueuedFollowUps.splice(currentIndex, 1); + if (!movedFollowUp) { + return thread; + } + nextQueuedFollowUps.splice(boundedTargetIndex, 0, movedFollowUp); + return { + ...thread, + queuedFollowUps: nextQueuedFollowUps, + }; + }); + } + return { sequence: 1 }; + } + case "thread.queued-follow-up.update": { + const followUp = + command.followUp as OrchestrationReadModel["threads"][number]["queuedFollowUps"][number]; + if (followUp?.id) { + updateFixtureThread((thread) => ({ + ...thread, + queuedFollowUps: thread.queuedFollowUps.map((entry) => + entry.id === followUp.id + ? { ...entry, ...followUp, lastSendError: followUp.lastSendError ?? null } + : entry, + ), + })); + } + return { sequence: 1 }; + } + case "thread.turn.start": + case "thread.meta.update": + case "thread.create": + case "thread.delete": + case "thread.interaction-mode.set": + case "thread.runtime-mode.set": + case "thread.approval.respond": + case "thread.user-input.respond": + case "thread.checkpoint.revert": + case "thread.session.stop": + case "project.create": + case "project.meta.update": + case "project.delete": + return { sequence: 1 }; + case "thread.turn.interrupt": { + updateFixtureThread((thread) => ({ + ...thread, + session: thread.session + ? { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: isoAt(9_999), + } + : null, + })); + return { sequence: 1 }; + } + default: + break; + } + } if (tag === WS_METHODS.serverGetConfig) { return fixture.serverConfig; } @@ -520,12 +678,23 @@ const worker = setupWorker( const method = request.body?._tag; if (typeof method !== "string") return; wsRequests.push(request.body); - client.send( - JSON.stringify({ - id: request.id, - result: resolveWsRpc(request.body), - }), - ); + try { + client.send( + JSON.stringify({ + id: request.id, + result: resolveWsRpc(request.body), + }), + ); + } catch (error) { + client.send( + JSON.stringify({ + id: request.id, + error: { + message: error instanceof Error ? error.message : String(error), + }, + }), + ); + } }); }), http.get("*/attachments/:attachmentId", () => @@ -621,6 +790,179 @@ async function waitForSendButton(): Promise { ); } +async function waitForComposerSubmitButton(label: string): Promise { + return waitForElement( + () => + document.querySelector(`button[type="submit"][aria-label="${label}"]`) ?? + Array.from(document.querySelectorAll('button[type="submit"]')).find( + (button) => button.textContent?.trim() === label, + ) ?? + null, + `Unable to find ${label} composer submit button.`, + ); +} + +async function waitForQueuedFollowUpsPanel(): Promise { + return waitForElement( + () => document.querySelector('[data-testid="queued-follow-ups-panel"]'), + "Unable to find queued follow-ups panel.", + ); +} + +async function openQueuedFollowUpActionsMenu(index: number): Promise { + const button = await waitForElement( + () => + document.querySelectorAll( + 'button[aria-label^="More queued follow-up actions"]', + )[index] ?? null, + `Unable to find queued follow-up actions button at index ${index}.`, + ); + button.click(); + return button; +} + +async function dragQueuedFollowUp(options: { + fromPrompt: string; + toPrompt: string; + position: "before" | "after"; +}): Promise { + const fromItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-testid^="queued-follow-up-"]')).find( + (element) => element.textContent?.includes(options.fromPrompt), + ) ?? null, + `Unable to find queued follow-up row for ${options.fromPrompt}.`, + ); + const toItem = await waitForElement( + () => + Array.from(document.querySelectorAll('[data-testid^="queued-follow-up-"]')).find( + (element) => element.textContent?.includes(options.toPrompt), + ) ?? null, + `Unable to find queued follow-up row for ${options.toPrompt}.`, + ); + const dragHandle = fromItem.querySelector('[draggable="true"]'); + if (!dragHandle) { + throw new Error(`Unable to find drag handle for queued follow-up ${options.fromPrompt}.`); + } + + const dataTransfer = new DataTransfer(); + const targetBounds = toItem.getBoundingClientRect(); + const clientY = options.position === "before" ? targetBounds.top + 2 : targetBounds.bottom - 2; + + dragHandle.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); + await waitForLayout(); + toItem.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + clientY, + }), + ); + await waitForLayout(); + toItem.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + clientY, + }), + ); + await waitForLayout(); + dragHandle.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + dataTransfer, + }), + ); +} + +async function waitForDraftPrompt(prompt: string): Promise { + await vi.waitFor( + () => { + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt ?? "").toBe( + prompt, + ); + }, + { timeout: 8_000, interval: 16 }, + ); +} + +async function setComposerPrompt(prompt: string): Promise { + useComposerDraftStore.getState().setPrompt(THREAD_ID, prompt); + await waitForDraftPrompt(prompt); + await vi.waitFor( + () => { + expect(document.body.textContent ?? "").toContain(prompt); + }, + { timeout: 8_000, interval: 16 }, + ); + await waitForLayout(); +} + +async function queueFollowUpFromComposer(prompt: string): Promise { + await setComposerPrompt(prompt); + const submitButton = await waitForComposerSubmitButton("Queue follow-up"); + await vi.waitFor( + () => { + expect(submitButton.disabled).toBe(false); + }, + { timeout: 8_000, interval: 16 }, + ); + submitButton.click(); + await waitForDraftPrompt(""); + await waitForLayout(); +} + +function setClientSettings(settings: Partial): void { + localStorage.setItem("t3code:client-settings:v1", JSON.stringify(settings)); +} + +function getTurnStartRequests(): Array { + return wsRequests.flatMap((request) => { + const command = (request as { command?: { type?: string } }).command; + if ( + request._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand || + !command || + command.type !== "thread.turn.start" + ) { + return []; + } + return [request as WsRequestEnvelope["body"] & { command: { type: string } }]; + }); +} + +function getInterruptRequests(): Array { + return wsRequests.flatMap((request) => { + const command = (request as { command?: { type?: string } }).command; + if ( + request._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand || + !command || + command.type !== "thread.turn.interrupt" + ) { + return []; + } + return [request as WsRequestEnvelope["body"] & { command: { type: string } }]; + }); +} + +function getDispatchCommandTypes(): string[] { + return wsRequests.flatMap((request) => { + if (request._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand) { + return []; + } + const command = (request as { command?: { type?: unknown } }).command; + return typeof command?.type === "string" ? [command.type] : []; + }); +} + async function waitForInteractionModeButton( expectedLabel: "Chat" | "Plan", ): Promise { @@ -1760,6 +2102,681 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("keeps the running stop button available while drafting a follow-up", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-stop-while-followup" as MessageId, + targetText: "stop while follow-up target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("draft a follow-up"); + await waitForComposerSubmitButton("Steer follow-up"); + + const stopButton = await waitForElement( + () => document.querySelector('button[aria-label="Stop generation"]'), + "Unable to find stop generation button while drafting a follow-up.", + ); + + expect(getComputedStyle(stopButton).cursor).toBe("pointer"); + } finally { + await mounted.cleanup(); + } + }); + + it("persists the running follow-up behavior setting across remounts", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const snapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-setting-persist" as MessageId, + targetText: "follow-up setting persist target", + sessionStatus: "running", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toHaveLength(0); + expect( + JSON.parse(localStorage.getItem("t3code:client-settings:v1") ?? "{}").followUpBehavior, + ).toBe("queue"); + }, + { timeout: 8_000, interval: 16 }, + ); + await setComposerPrompt("queued setting persists"); + await waitForComposerSubmitButton("Queue follow-up"); + } finally { + await mounted.cleanup(); + } + + const remounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + await setComposerPrompt("queued setting persists"); + await waitForComposerSubmitButton("Queue follow-up"); + } finally { + await remounted.cleanup(); + } + }); + + it("steers follow-ups by default while a turn is running", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-steer-default" as MessageId, + targetText: "follow-up steer default target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("steer this run"); + const submitButton = await waitForComposerSubmitButton("Steer follow-up"); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(document.body.textContent ?? "").toContain("steer this run"); + }, + { timeout: 8_000, interval: 16 }, + ); + expect(getQueuedFollowUpPrompts()).toEqual([]); + } finally { + await mounted.cleanup(); + } + }); + + it("interrupts the active run before sending a steered follow-up", async () => { + let sawInterrupt = false; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-steer-interrupt" as MessageId, + targetText: "follow-up steer interrupt target", + sessionStatus: "running", + }), + resolveRpc: (body) => { + if (body._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand) { + return undefined; + } + const command = (body as { command?: { type?: string } }).command; + if (!command) { + return undefined; + } + if (command.type === "thread.turn.interrupt") { + sawInterrupt = true; + updateFixtureThread((thread) => ({ + ...thread, + session: thread.session + ? { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: isoAt(9_999), + } + : null, + })); + return { sequence: 1 }; + } + if (command.type === "thread.turn.start") { + expect(sawInterrupt).toBe(true); + return { sequence: 1 }; + } + return undefined; + }, + }); + + try { + await setComposerPrompt("please steer now"); + const submitButton = await waitForComposerSubmitButton("Steer follow-up"); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getInterruptRequests()).toHaveLength(1); + expect(getTurnStartRequests()).toHaveLength(1); + const commandTypes = getDispatchCommandTypes(); + expect(commandTypes).toEqual( + expect.arrayContaining(["thread.turn.interrupt", "thread.turn.start"]), + ); + expect(commandTypes.indexOf("thread.turn.interrupt")).toBeLessThan( + commandTypes.indexOf("thread.turn.start"), + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("renders queued follow-ups from the server-backed thread snapshot", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const runningSnapshot = createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-auto-send" as MessageId, + targetText: "follow-up auto-send target", + sessionStatus: "running", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: runningSnapshot, + }); + + try { + await setComposerPrompt("queued head"); + await waitForComposerSubmitButton("Queue follow-up"); + + await queueFollowUpFromComposer("queued head"); + await queueFollowUpFromComposer("queued tail"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queued head", "queued tail"]); + }, + { timeout: 8_000, interval: 16 }, + ); + expect(getTurnStartRequests()).toHaveLength(0); + } finally { + await mounted.cleanup(); + } + }); + + it("does not enqueue duplicate follow-ups on rapid repeated submit", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-no-duplicate-queue" as MessageId, + targetText: "follow-up duplicate queue target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("rapid queue"); + const submitButton = await waitForComposerSubmitButton("Queue follow-up"); + + submitButton.click(); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["rapid queue"]); + expect(getQueuedFollowUpEnqueueRequests()).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("supports steering and deleting queued follow-ups from the panel", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-actions" as MessageId, + targetText: "follow-up panel actions target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("panel first"); + await waitForComposerSubmitButton("Queue follow-up"); + + await queueFollowUpFromComposer("panel first"); + await queueFollowUpFromComposer("panel second"); + + const panel = await waitForQueuedFollowUpsPanel(); + const steerButton = Array.from(panel.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Steer", + ); + expect(steerButton).toBeTruthy(); + steerButton?.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual(["panel second"]); + expect(document.body.textContent ?? "").toContain("panel first"); + }, + { timeout: 8_000, interval: 16 }, + ); + + const deleteButton = await waitForElement( + () => + document.querySelector( + 'button[aria-label^="Delete queued follow-up"]', + ), + "Unable to find delete queued follow-up button.", + ); + deleteButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("interrupts the active run before steering a queued follow-up from the panel", async () => { + let sawInterrupt = false; + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-steer-interrupt" as MessageId, + targetText: "follow-up panel steer interrupt target", + sessionStatus: "running", + }), + resolveRpc: (body) => { + if (body._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand) { + return undefined; + } + const command = (body as { command?: { type?: string; followUpId?: string } }).command; + if (!command) { + return undefined; + } + if (command.type === "thread.turn.interrupt") { + sawInterrupt = true; + updateFixtureThread((thread) => ({ + ...thread, + session: thread.session + ? { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: isoAt(9_998), + } + : null, + })); + return { sequence: 1 }; + } + if (command.type === "thread.turn.start") { + expect(sawInterrupt).toBe(true); + return { sequence: 1 }; + } + return undefined; + }, + }); + + try { + await queueFollowUpFromComposer("interrupt queued steer"); + + const panel = await waitForQueuedFollowUpsPanel(); + const steerButton = Array.from(panel.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Steer", + ); + expect(steerButton).toBeTruthy(); + steerButton?.click(); + + await vi.waitFor( + () => { + expect(getInterruptRequests()).toHaveLength(1); + expect(getTurnStartRequests()).toHaveLength(1); + const commandTypes = getDispatchCommandTypes(); + expect(commandTypes).toEqual( + expect.arrayContaining([ + "thread.queued-follow-up.remove", + "thread.turn.interrupt", + "thread.turn.start", + ]), + ); + const interruptIndex = commandTypes.indexOf("thread.turn.interrupt"); + const turnStartIndex = commandTypes.indexOf("thread.turn.start"); + const removeIndex = commandTypes.indexOf("thread.queued-follow-up.remove"); + expect(interruptIndex).toBeGreaterThanOrEqual(0); + expect(turnStartIndex).toBeGreaterThan(interruptIndex); + expect(removeIndex).toBeGreaterThan(turnStartIndex); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("marks a ready-state steered queued follow-up as failed when queue cleanup fails", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-steer-remove-failure" as MessageId, + targetText: "follow-up panel steer remove failure target", + sessionStatus: "ready", + }), + resolveRpc: (body) => { + if (body._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand) { + return undefined; + } + const command = (body as { command?: { type?: string } }).command; + if (!command) { + return undefined; + } + if (command.type === "thread.queued-follow-up.remove") { + throw new Error("queued cleanup failed"); + } + return undefined; + }, + }); + + try { + updateFixtureThread((thread) => ({ + ...thread, + queuedFollowUps: [ + { + id: "queued-ready-steer-failure", + createdAt: isoAt(8_500), + prompt: "ready steer failure", + attachments: [], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + ], + })); + + const panel = await waitForQueuedFollowUpsPanel(); + const steerButton = Array.from(panel.querySelectorAll("button")).find( + (button) => button.textContent?.trim() === "Steer", + ); + expect(steerButton).toBeTruthy(); + steerButton?.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getDispatchCommandTypes()).toEqual( + expect.arrayContaining([ + "thread.turn.start", + "thread.queued-follow-up.remove", + "thread.queued-follow-up.update", + ]), + ); + expect(getQueuedFollowUpPrompts()).toEqual(["ready steer failure"]); + expect(document.body.textContent ?? "").toContain( + "Queued follow-up was sent but queue cleanup failed.", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("restores a queued follow-up into the composer from the panel", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit" as MessageId, + targetText: "follow-up panel edit target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("queued edit item"); + await waitForComposerSubmitButton("Queue follow-up"); + await queueFollowUpFromComposer("queued edit item"); + + await openQueuedFollowUpActionsMenu(0); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find edit queued follow-up button.", + ); + editButton.dispatchEvent( + new MouseEvent("click", { + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([]); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toBe( + "queued edit item", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("requeues an edited follow-up back near its original queued position", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit-position" as MessageId, + targetText: "follow-up panel edit order target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("queue first"); + await queueFollowUpFromComposer("queue second"); + await queueFollowUpFromComposer("queue third"); + + await openQueuedFollowUpActionsMenu(1); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find the queued follow-up edit button.", + ); + editButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queue first", "queue third"]); + expect(useComposerDraftStore.getState().draftsByThreadId[THREAD_ID]?.prompt).toBe( + "queue second", + ); + }, + { timeout: 8_000, interval: 16 }, + ); + + await queueFollowUpFromComposer("queue second edited"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([ + "queue first", + "queue second edited", + "queue third", + ]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("appends a new queued follow-up after requeueing an edited item", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-edit-append" as MessageId, + targetText: "follow-up panel edit append target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("queue first"); + await queueFollowUpFromComposer("queue second"); + await queueFollowUpFromComposer("queue third"); + + await openQueuedFollowUpActionsMenu(1); + const editButton = await waitForElement( + () => + document.querySelector('button[aria-label^="Edit queued follow-up"]'), + "Unable to find the queued follow-up edit button.", + ); + editButton.click(); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["queue first", "queue third"]); + }, + { timeout: 8_000, interval: 16 }, + ); + + await queueFollowUpFromComposer("queue second edited"); + await queueFollowUpFromComposer("queue fourth"); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual([ + "queue first", + "queue second edited", + "queue third", + "queue fourth", + ]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("lets queued follow-ups reorder by dragging from the panel handle", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-move" as MessageId, + targetText: "follow-up panel move target", + sessionStatus: "running", + }), + }); + + try { + await queueFollowUpFromComposer("move first"); + await queueFollowUpFromComposer("move second"); + await queueFollowUpFromComposer("move third"); + + await dragQueuedFollowUp({ + fromPrompt: "move second", + toPrompt: "move first", + position: "before", + }); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["move second", "move first", "move third"]); + }, + { timeout: 8_000, interval: 16 }, + ); + + await dragQueuedFollowUp({ + fromPrompt: "move first", + toPrompt: "move third", + position: "after", + }); + + await vi.waitFor( + () => { + expect(getQueuedFollowUpPrompts()).toEqual(["move second", "move third", "move first"]); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("uses Ctrl+Shift+Enter to submit the opposite follow-up behavior once", async () => { + setClientSettings({ + followUpBehavior: "queue", + }); + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-shortcut-opposite" as MessageId, + targetText: "follow-up shortcut target", + sessionStatus: "running", + }), + }); + + try { + await setComposerPrompt("shortcut steer once"); + await waitForComposerSubmitButton("Queue follow-up"); + + const composerEditor = await waitForComposerEditor(); + composerEditor.dispatchEvent( + new KeyboardEvent("keydown", { + key: "Enter", + ctrlKey: true, + shiftKey: true, + bubbles: true, + cancelable: true, + }), + ); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + expect(getQueuedFollowUpPrompts()).toEqual([]); + expect(document.body.textContent ?? "").toContain("shortcut steer once"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + it("keeps the new thread selected after clicking the new-thread button", async () => { const mounted = await mountChatView({ viewport: DEFAULT_VIEWPORT, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bf72ec0b84..def678e061 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,7 +1,15 @@ import { ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vitest"; -import { buildExpiredTerminalContextToastCopy, deriveComposerSendState } from "./ChatView.logic"; +import { + buildExpiredTerminalContextToastCopy, + buildQueuedFollowUpDraft, + canAutoDispatchQueuedFollowUp, + deriveComposerSendState, + followUpBehaviorShortcutLabel, + resolveFollowUpBehavior, + shouldInvertFollowUpBehaviorFromKeyEvent, +} from "./ChatView.logic"; describe("deriveComposerSendState", () => { it("treats expired terminal pills as non-sendable content", () => { @@ -67,3 +75,117 @@ describe("buildExpiredTerminalContextToastCopy", () => { }); }); }); + +describe("follow-up behavior helpers", () => { + it("inverts the configured behavior when requested", () => { + expect(resolveFollowUpBehavior("steer", false)).toBe("steer"); + expect(resolveFollowUpBehavior("steer", true)).toBe("queue"); + expect(resolveFollowUpBehavior("queue", true)).toBe("steer"); + }); + + it("detects the opposite-submit keyboard shortcut across platforms", () => { + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: true, + metaKey: false, + shiftKey: true, + altKey: false, + }, + "Win32", + ), + ).toBe(true); + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: false, + metaKey: true, + shiftKey: true, + altKey: false, + }, + "MacIntel", + ), + ).toBe(true); + expect( + shouldInvertFollowUpBehaviorFromKeyEvent( + { + ctrlKey: false, + metaKey: false, + shiftKey: true, + altKey: false, + }, + "Win32", + ), + ).toBe(false); + expect(followUpBehaviorShortcutLabel("MacIntel")).toBe("Cmd+Shift+Enter"); + expect(followUpBehaviorShortcutLabel("Win32")).toBe("Ctrl+Shift+Enter"); + }); + + it("builds a queued follow-up snapshot and auto-dispatch rules", () => { + const snapshot = buildQueuedFollowUpDraft({ + prompt: "next step", + attachments: [], + terminalContexts: [ + { + id: "ctx-1", + threadId: ThreadId.makeUnsafe("thread-1"), + terminalId: "default", + terminalLabel: "Terminal 1", + lineStart: 1, + lineEnd: 1, + text: "hello", + createdAt: "2026-03-27T12:00:00.000Z", + }, + ], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: "2026-03-27T12:00:00.000Z", + }); + + expect(snapshot.id).toBeTruthy(); + expect(snapshot.terminalContexts[0]?.text).toBe("hello"); + expect( + canAutoDispatchQueuedFollowUp({ + phase: "ready", + queuedFollowUpCount: 2, + queuedHeadHasError: false, + isConnecting: false, + isSendBusy: false, + isRevertingCheckpoint: false, + hasThreadError: false, + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBe(true); + expect( + canAutoDispatchQueuedFollowUp({ + phase: "running", + queuedFollowUpCount: 2, + queuedHeadHasError: false, + isConnecting: false, + isSendBusy: false, + isRevertingCheckpoint: false, + hasThreadError: false, + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBe(false); + expect( + canAutoDispatchQueuedFollowUp({ + phase: "ready", + queuedFollowUpCount: 1, + queuedHeadHasError: true, + isConnecting: false, + isSendBusy: false, + isRevertingCheckpoint: false, + hasThreadError: false, + hasPendingApproval: false, + hasPendingUserInput: false, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 0a27fb203e..b21d0a6957 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -1,13 +1,25 @@ -import { ProjectId, type ModelSelection, type ThreadId } from "@t3tools/contracts"; -import { type ChatMessage, type Thread } from "../types"; +import { + ProjectId, + ProviderInteractionMode, + RuntimeMode, + type ModelSelection, + type ThreadId, +} from "@t3tools/contracts"; +import { type FollowUpBehavior } from "@t3tools/contracts/settings"; +import { type ChatMessage, type QueuedFollowUp, type Thread } from "../types"; import { randomUUID } from "~/lib/utils"; -import { type ComposerImageAttachment, type DraftThreadState } from "../composerDraftStore"; +import { + type ComposerImageAttachment, + type DraftThreadState, + type PersistedComposerImageAttachment, +} from "../composerDraftStore"; import { Schema } from "effect"; import { filterTerminalContextsWithText, stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; +import { isMacPlatform } from "../lib/utils"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "t3code"; @@ -30,6 +42,7 @@ export function buildLocalDraftThread( interactionMode: draftThread.interactionMode, session: null, messages: [], + queuedFollowUps: [], error, createdAt: draftThread.createdAt, archivedAt: null, @@ -161,3 +174,106 @@ export function buildExpiredTerminalContextToastCopy( description: "Re-add it if you want that terminal output included.", }; } + +export function resolveFollowUpBehavior( + followUpBehavior: FollowUpBehavior, + invert: boolean, +): FollowUpBehavior { + if (!invert) { + return followUpBehavior; + } + return followUpBehavior === "queue" ? "steer" : "queue"; +} + +export function shouldInvertFollowUpBehaviorFromKeyEvent( + event: Pick, + platform = navigator.platform, +): boolean { + if (!event.shiftKey || event.altKey) { + return false; + } + if (isMacPlatform(platform)) { + return event.metaKey && !event.ctrlKey; + } + return event.ctrlKey && !event.metaKey; +} + +export function followUpBehaviorShortcutLabel(platform = navigator.platform): string { + return isMacPlatform(platform) ? "Cmd+Shift+Enter" : "Ctrl+Shift+Enter"; +} + +export interface QueuedFollowUpDraftSnapshot { + id: string; + createdAt: string; + prompt: string; + attachments: PersistedComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; +} + +export function buildQueuedFollowUpDraft(input: { + prompt: string; + attachments: ReadonlyArray; + terminalContexts: ReadonlyArray; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + createdAt: string; +}): QueuedFollowUpDraftSnapshot { + return { + id: randomUUID(), + createdAt: input.createdAt, + prompt: input.prompt, + attachments: [...input.attachments], + terminalContexts: input.terminalContexts.map((context) => ({ ...context })), + modelSelection: input.modelSelection, + runtimeMode: input.runtimeMode, + interactionMode: input.interactionMode, + }; +} + +export function canAutoDispatchQueuedFollowUp(input: { + phase: "disconnected" | "connecting" | "ready" | "running"; + queuedFollowUpCount: number; + queuedHeadHasError: boolean; + isConnecting: boolean; + isSendBusy: boolean; + isRevertingCheckpoint: boolean; + hasThreadError: boolean; + hasPendingApproval: boolean; + hasPendingUserInput: boolean; +}): boolean { + return ( + input.phase === "ready" && + input.queuedFollowUpCount > 0 && + !input.queuedHeadHasError && + !input.isConnecting && + !input.isSendBusy && + !input.isRevertingCheckpoint && + !input.hasThreadError && + !input.hasPendingApproval && + !input.hasPendingUserInput + ); +} + +export function describeQueuedFollowUp( + followUp: Pick, +): string { + const trimmedPrompt = stripInlineTerminalContextPlaceholders(followUp.prompt).trim(); + if (trimmedPrompt.length > 0) { + return trimmedPrompt; + } + if (followUp.attachments.length > 0) { + return followUp.attachments.length === 1 + ? "1 image attached" + : `${followUp.attachments.length} images attached`; + } + if (followUp.terminalContexts.length > 0) { + return followUp.terminalContexts.length === 1 + ? (followUp.terminalContexts[0]?.terminalLabel ?? "1 terminal context") + : `${followUp.terminalContexts.length} terminal contexts`; + } + return "Follow-up"; +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1d926bf308..74ad1e9b79 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,5 +1,6 @@ import { type ApprovalRequestId, + type ClientChatAttachment, DEFAULT_MODEL_BY_PROVIDER, type ClaudeCodeEffort, type MessageId, @@ -22,6 +23,7 @@ import { RuntimeMode, } from "@t3tools/contracts"; import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { IMAGE_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/orchestration"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useDebouncedValue } from "@tanstack/react-pacer"; @@ -75,7 +77,9 @@ import { DEFAULT_RUNTIME_MODE, DEFAULT_THREAD_TERMINAL_ID, MAX_TERMINALS_PER_GROUP, + type ChatAttachment, type ChatMessage, + type QueuedFollowUp, type TurnDiffSummary, } from "../types"; import { basenameOfPath } from "../vscode-icons"; @@ -87,6 +91,8 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { BotIcon, + Clock3Icon, + CornerDownRightIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, @@ -126,6 +132,7 @@ import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, + hydrateComposerImagesFromPersistedAttachments, type PersistedComposerImageAttachment, useComposerDraftStore, useEffectiveComposerModelState, @@ -155,6 +162,7 @@ import { CompactComposerControlsMenu } from "./chat/CompactComposerControlsMenu" import { ComposerPendingApprovalPanel } from "./chat/ComposerPendingApprovalPanel"; import { ComposerPendingUserInputPanel } from "./chat/ComposerPendingUserInputPanel"; import { ComposerPlanFollowUpBanner } from "./chat/ComposerPlanFollowUpBanner"; +import { ComposerQueuedFollowUpsPanel } from "./chat/ComposerQueuedFollowUpsPanel"; import { getComposerProviderState, renderProviderTraitsMenuContent, @@ -164,30 +172,33 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, + buildQueuedFollowUpDraft, buildLocalDraftThread, buildTemporaryWorktreeBranchName, cloneComposerImageForRetry, collectUserMessageBlobPreviewUrls, deriveComposerSendState, + followUpBehaviorShortcutLabel, LAST_INVOKED_SCRIPT_BY_PROJECT_KEY, LastInvokedScriptByProjectSchema, PullRequestDialogState, readFileAsDataUrl, + resolveFollowUpBehavior, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, SendPhase, + shouldInvertFollowUpBehaviorFromKeyEvent, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; -const IMAGE_ONLY_BOOTSTRAP_PROMPT = - "[User attached one or more images without additional text. Respond using the conversation context and the attached image(s).]"; const EMPTY_ACTIVITIES: OrchestrationThreadActivity[] = []; const EMPTY_KEYBINDINGS: ResolvedKeybindingsConfig = []; const EMPTY_PROJECT_ENTRIES: ProjectEntry[] = []; const EMPTY_AVAILABLE_EDITORS: EditorId[] = []; const EMPTY_PROVIDERS: ServerProvider[] = []; +const EMPTY_QUEUED_FOLLOW_UPS: QueuedFollowUp[] = []; const EMPTY_PENDING_USER_INPUT_ANSWERS: Record = {}; function formatOutgoingPrompt(params: { @@ -245,6 +256,99 @@ interface PendingPullRequestSetupRequest { scriptId: string; } +type FollowUpSubmissionAttachment = + | PersistedComposerImageAttachment + | (ChatAttachment & { previewUrl?: string | undefined }); + +interface FollowUpSubmissionSnapshot { + id: string; + createdAt: string; + prompt: string; + attachments: FollowUpSubmissionAttachment[]; + terminalContexts: TerminalContextDraft[]; + modelSelection: ModelSelection; + runtimeMode: RuntimeMode; + interactionMode: ProviderInteractionMode; + lastSendError?: string | null; +} + +interface PendingSteerSubmission { + threadId: ThreadId; + snapshot: FollowUpSubmissionSnapshot; + source: "composer" | "queued-follow-up"; + queuedFollowUpId?: string; +} + +function isUploadedFollowUpAttachment( + attachment: FollowUpSubmissionAttachment, +): attachment is PersistedComposerImageAttachment { + return "dataUrl" in attachment; +} + +function toCommandAttachment(attachment: FollowUpSubmissionAttachment): ClientChatAttachment { + if (isUploadedFollowUpAttachment(attachment)) { + return { + type: "image", + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: attachment.dataUrl, + }; + } + return { + type: "image", + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }; +} + +function toDraftPersistedAttachment( + attachment: FollowUpSubmissionAttachment, +): PersistedComposerImageAttachment | null { + return isUploadedFollowUpAttachment(attachment) ? attachment : null; +} + +async function responseBlobToDataUrl(blob: Blob): Promise { + const bytes = new Uint8Array(await blob.arrayBuffer()); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return `data:${blob.type || "application/octet-stream"};base64,${btoa(binary)}`; +} + +async function hydratePersistedAttachmentForDraft( + attachment: ChatAttachment, +): Promise { + if (!attachment.previewUrl) { + throw new Error(`Queued attachment '${attachment.name}' is missing a preview URL.`); + } + const response = await fetch(attachment.previewUrl, { credentials: "same-origin" }); + if (!response.ok) { + throw new Error(`Failed to load queued attachment '${attachment.name}'.`); + } + const blob = await response.blob(); + return { + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + dataUrl: await responseBlobToDataUrl(blob), + }; +} + +function persistedModelOptionsFromSelection( + modelSelection: ModelSelection, +): Parameters[0]["modelOptions"] { + return modelSelection.options + ? { + [modelSelection.provider]: modelSelection.options, + } + : null; +} + export default function ChatView({ threadId }: ChatViewProps) { const threads = useStore((store) => store.threads); const projects = useStore((store) => store.projects); @@ -324,6 +428,9 @@ export default function ChatView({ threadId }: ChatViewProps) { const [isDragOverComposer, setIsDragOverComposer] = useState(false); const [expandedImage, setExpandedImage] = useState(null); const [optimisticUserMessages, setOptimisticUserMessages] = useState([]); + const [hiddenTimelineMessageIds, setHiddenTimelineMessageIds] = useState>( + () => new Set(), + ); const optimisticUserMessagesRef = useRef(optimisticUserMessages); optimisticUserMessagesRef.current = optimisticUserMessages; const composerTerminalContextsRef = useRef(composerTerminalContexts); @@ -396,6 +503,8 @@ export default function ChatView({ threadId }: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewHandoffTimeoutByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + const pendingSteerSubmissionRef = useRef(null); + const [pendingSteerSubmissionVersion, setPendingSteerSubmissionVersion] = useState(0); const dragDepthRef = useRef(0); const terminalOpenByThreadRef = useRef>({}); const setMessagesScrollContainerRef = useCallback((element: HTMLDivElement | null) => { @@ -419,6 +528,10 @@ export default function ChatView({ threadId }: ChatViewProps) { }, [setComposerDraftPrompt, threadId], ); + const setPendingSteerSubmission = useCallback((submission: PendingSteerSubmission | null) => { + pendingSteerSubmissionRef.current = submission; + setPendingSteerSubmissionVersion((current) => current + 1); + }, []); const addComposerImage = useCallback( (image: ComposerImageAttachment) => { addComposerDraftImage(threadId, image); @@ -485,6 +598,7 @@ export default function ChatView({ threadId }: ChatViewProps) { [draftThread, fallbackDraftProject?.defaultModelSelection, localDraftError, threadId], ); const activeThread = serverThread ?? localDraftThread; + const queuedFollowUps = activeThread?.queuedFollowUps ?? EMPTY_QUEUED_FOLLOW_UPS; const runtimeMode = composerDraft.runtimeMode ?? activeThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = @@ -667,6 +781,7 @@ export default function ChatView({ threadId }: ChatViewProps) { const isSendBusy = sendPhase !== "idle"; const isPreparingWorktree = sendPhase === "preparing-worktree"; const isWorking = phase === "running" || isSendBusy || isConnecting || isRevertingCheckpoint; + const followUpBehavior = settings.followUpBehavior; const nowIso = new Date(nowTick).toISOString(); const activeWorkStartedAt = deriveActiveWorkStartedAt( activeLatestTurn, @@ -753,11 +868,33 @@ export default function ChatView({ threadId }: ChatViewProps) { hasActionableProposedPlan(activeProposedPlan); const activePendingApproval = pendingApprovals[0] ?? null; const isComposerApprovalState = activePendingApproval !== null; + const canUseRunningFollowUps = + phase === "running" && !isComposerApprovalState && pendingUserInputs.length === 0; const hasComposerHeader = isComposerApprovalState || pendingUserInputs.length > 0 || (showPlanFollowUpPrompt && activeProposedPlan !== null); const composerFooterHasWideActions = showPlanFollowUpPrompt || activePendingProgress !== null; + const runningFollowUpActionLabel = + followUpBehavior === "queue" ? "Queue follow-up" : "Steer follow-up"; + const runningFollowUpShortcutRows = + followUpBehavior === "queue" + ? [ + { label: "Queue", shortcut: "Enter", active: true }, + { + label: "Steer", + shortcut: followUpBehaviorShortcutLabel(), + active: false, + }, + ] + : [ + { label: "Steer", shortcut: "Enter", active: true }, + { + label: "Queue", + shortcut: followUpBehaviorShortcutLabel(), + active: false, + }, + ]; const lastSyncedPendingInputRef = useRef<{ requestId: string | null; questionId: string | null; @@ -823,6 +960,9 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; }, [clearAttachmentPreviewHandoffs]); + useEffect(() => { + setHiddenTimelineMessageIds(new Set()); + }, [activeThread?.id]); const handoffAttachmentPreviews = useCallback((messageId: MessageId, previewUrls: string[]) => { if (previewUrls.length === 0) return; @@ -907,15 +1047,30 @@ export default function ChatView({ threadId }: ChatViewProps) { }); if (optimisticUserMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return hiddenTimelineMessageIds.size === 0 + ? serverMessagesWithPreviewHandoff + : serverMessagesWithPreviewHandoff.filter( + (message) => !hiddenTimelineMessageIds.has(message.id), + ); } - const serverIds = new Set(serverMessagesWithPreviewHandoff.map((message) => message.id)); + const visibleServerMessages = + hiddenTimelineMessageIds.size === 0 + ? serverMessagesWithPreviewHandoff + : serverMessagesWithPreviewHandoff.filter( + (message) => !hiddenTimelineMessageIds.has(message.id), + ); + const serverIds = new Set(visibleServerMessages.map((message) => message.id)); const pendingMessages = optimisticUserMessages.filter((message) => !serverIds.has(message.id)); if (pendingMessages.length === 0) { - return serverMessagesWithPreviewHandoff; + return visibleServerMessages; } - return [...serverMessagesWithPreviewHandoff, ...pendingMessages]; - }, [serverMessages, attachmentPreviewHandoffByMessageId, optimisticUserMessages]); + return [...visibleServerMessages, ...pendingMessages]; + }, [ + serverMessages, + attachmentPreviewHandoffByMessageId, + hiddenTimelineMessageIds, + optimisticUserMessages, + ]); const timelineEntries = useMemo( () => deriveTimelineEntries(timelineMessages, activeThread?.proposedPlans ?? [], workLogEntries), @@ -2428,8 +2583,8 @@ export default function ChatView({ threadId }: ChatViewProps) { [activeThread, isConnecting, isRevertingCheckpoint, isSendBusy, phase, setThreadError], ); - const onSend = async (e?: { preventDefault: () => void }) => { - e?.preventDefault(); + const onSend = async (input?: { preventDefault?: () => void; keyboardEvent?: KeyboardEvent }) => { + input?.preventDefault?.(); const api = readNativeApi(); if (!api || !activeThread || isSendBusy || isConnecting || sendInFlightRef.current) return; if (activePendingProgress) { @@ -2463,6 +2618,113 @@ export default function ChatView({ threadId }: ChatViewProps) { }); return; } + if (canUseRunningFollowUps && isServerThread) { + const createdAt = new Date().toISOString(); + const effectiveBehavior = resolveFollowUpBehavior( + followUpBehavior, + Boolean( + input?.keyboardEvent && shouldInvertFollowUpBehaviorFromKeyEvent(input.keyboardEvent), + ), + ); + if (effectiveBehavior === "queue") { + sendInFlightRef.current = true; + beginSendPhase("sending-turn"); + } + let followUpSnapshot: FollowUpSubmissionSnapshot | null; + try { + followUpSnapshot = await createFollowUpSnapshotFromComposer(createdAt); + } catch (err) { + if (effectiveBehavior === "queue") { + sendInFlightRef.current = false; + resetSendPhase(); + } + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to prepare follow-up.", + ); + return; + } + if (!followUpSnapshot) { + if (effectiveBehavior === "queue") { + sendInFlightRef.current = false; + resetSendPhase(); + } + return; + } + + if (effectiveBehavior === "queue") { + const queuedFollowUpEdit = composerDraft.queuedFollowUpEdit; + const targetIndex = (() => { + if (!queuedFollowUpEdit) { + return undefined; + } + if (queuedFollowUpEdit.nextFollowUpId) { + const nextIndex = queuedFollowUps.findIndex( + (queuedFollowUp) => queuedFollowUp.id === queuedFollowUpEdit.nextFollowUpId, + ); + if (nextIndex >= 0) { + return nextIndex; + } + } + if (queuedFollowUpEdit.previousFollowUpId) { + const previousIndex = queuedFollowUps.findIndex( + (queuedFollowUp) => queuedFollowUp.id === queuedFollowUpEdit.previousFollowUpId, + ); + if (previousIndex >= 0) { + return previousIndex + 1; + } + } + return Math.max(0, Math.min(queuedFollowUpEdit.queueIndex, queuedFollowUps.length)); + })(); + try { + await api.orchestration.dispatchCommand({ + type: "thread.queued-follow-up.enqueue", + commandId: newCommandId(), + threadId: activeThread.id, + followUp: { + ...followUpSnapshot, + id: queuedFollowUpEdit?.followUpId ?? followUpSnapshot.id, + attachments: followUpSnapshot.attachments.map(toCommandAttachment), + lastSendError: null, + }, + ...(targetIndex !== undefined ? { targetIndex } : {}), + createdAt, + }); + promptRef.current = ""; + clearComposerDraftContent(activeThread.id, { revokeImagePreviewUrls: true }); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + } catch (err) { + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to queue follow-up.", + ); + } finally { + sendInFlightRef.current = false; + resetSendPhase(); + } + return; + } + + promptRef.current = ""; + clearComposerDraftContent(activeThread.id, { revokeImagePreviewUrls: true }); + setComposerHighlightedItemId(null); + setComposerCursor(0); + setComposerTrigger(null); + + setPendingSteerSubmission({ + threadId: activeThread.id, + snapshot: followUpSnapshot, + source: "composer", + }); + const interrupted = await requestThreadInterrupt(activeThread.id); + if (!interrupted) { + setPendingSteerSubmission(null); + restoreFollowUpSnapshotToComposer(followUpSnapshot); + } + return; + } const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) @@ -2757,15 +3019,34 @@ export default function ChatView({ threadId }: ChatViewProps) { } }; + const requestThreadInterrupt = useCallback( + async (threadIdToInterrupt: ThreadId) => { + const api = readNativeApi(); + if (!api) { + return false; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.turn.interrupt", + commandId: newCommandId(), + threadId: threadIdToInterrupt, + createdAt: new Date().toISOString(), + }); + return true; + } catch (err) { + setThreadError( + threadIdToInterrupt, + err instanceof Error ? err.message : "Failed to interrupt active turn.", + ); + return false; + } + }, + [setThreadError], + ); + const onInterrupt = async () => { - const api = readNativeApi(); - if (!api || !activeThread) return; - await api.orchestration.dispatchCommand({ - type: "thread.turn.interrupt", - commandId: newCommandId(), - threadId: activeThread.id, - createdAt: new Date().toISOString(), - }); + if (!activeThread) return; + await requestThreadInterrupt(activeThread.id); }; const onRespondToApproval = useCallback( @@ -2915,6 +3196,324 @@ export default function ChatView({ threadId }: ChatViewProps) { setActivePendingUserInputQuestionIndex(Math.max(activePendingProgress.questionIndex - 1, 0)); }, [activePendingProgress, setActivePendingUserInputQuestionIndex]); + const createFollowUpSnapshotFromComposer = useCallback( + async (createdAt: string): Promise => { + const promptForSend = promptRef.current; + const { + trimmedPrompt, + sendableTerminalContexts, + expiredTerminalContextCount, + hasSendableContent, + } = deriveComposerSendState({ + prompt: promptForSend, + imageCount: composerImagesRef.current.length, + terminalContexts: composerTerminalContextsRef.current, + }); + if (!hasSendableContent) { + if (expiredTerminalContextCount > 0) { + const toastCopy = buildExpiredTerminalContextToastCopy( + expiredTerminalContextCount, + "empty", + ); + toastManager.add({ + type: "warning", + title: toastCopy.title, + description: toastCopy.description, + }); + } + return null; + } + const persistedAttachments = await Promise.all( + composerImagesRef.current.map(async (image) => ({ + id: image.id, + name: image.name, + mimeType: image.mimeType, + sizeBytes: image.sizeBytes, + dataUrl: await readFileAsDataUrl(image.file), + })), + ); + return buildQueuedFollowUpDraft({ + prompt: trimmedPrompt, + attachments: persistedAttachments, + terminalContexts: sendableTerminalContexts, + modelSelection: selectedModelSelection, + runtimeMode, + interactionMode, + createdAt, + }); + }, + [interactionMode, runtimeMode, selectedModelSelection], + ); + + const restoreFollowUpSnapshotToComposer = useCallback( + (snapshot: FollowUpSubmissionSnapshot) => { + const hydratedAttachments = snapshot.attachments.flatMap((attachment) => { + const draftAttachment = toDraftPersistedAttachment(attachment); + return draftAttachment ? [draftAttachment] : []; + }); + setComposerDraftPrompt(threadId, snapshot.prompt); + useComposerDraftStore.setState((state) => { + const currentDraft = state.draftsByThreadId[threadId]; + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + ...(currentDraft ?? composerDraft), + queuedFollowUpEdit: null, + prompt: snapshot.prompt, + images: hydrateComposerImagesFromPersistedAttachments(hydratedAttachments), + nonPersistedImageIds: [], + persistedAttachments: hydratedAttachments, + terminalContexts: snapshot.terminalContexts.map((context) => ({ ...context })), + modelSelectionByProvider: { + ...(currentDraft?.modelSelectionByProvider ?? + composerDraft.modelSelectionByProvider), + [snapshot.modelSelection.provider]: snapshot.modelSelection, + }, + activeProvider: snapshot.modelSelection.provider, + runtimeMode: snapshot.runtimeMode, + interactionMode: snapshot.interactionMode, + }, + }, + }; + }); + promptRef.current = snapshot.prompt; + setComposerCursor(collapseExpandedComposerCursor(snapshot.prompt, snapshot.prompt.length)); + setComposerTrigger(detectComposerTrigger(snapshot.prompt, snapshot.prompt.length)); + }, + [composerDraft, setComposerDraftPrompt, threadId], + ); + + const dispatchServerThreadSnapshot = useCallback( + async (input: { + threadId: ThreadId; + snapshot: FollowUpSubmissionSnapshot; + errorMessage: string; + suppressOptimisticMessage?: boolean; + hideServerMessage?: boolean; + sourceProposedPlan?: { + threadId: ThreadId; + planId: string; + }; + onAfterDispatch?: () => void; + }) => { + const api = readNativeApi(); + if (!api) { + return false; + } + const snapshotProvider = input.snapshot.modelSelection.provider; + const snapshotModel = input.snapshot.modelSelection.model; + const snapshotModels = getProviderModels(providerStatuses, snapshotProvider); + const snapshotProviderState = getComposerProviderState({ + provider: snapshotProvider, + model: snapshotModel, + models: snapshotModels, + prompt: input.snapshot.prompt, + modelOptions: persistedModelOptionsFromSelection(input.snapshot.modelSelection), + }); + const promptWithTerminalContexts = appendTerminalContextsToPrompt( + input.snapshot.prompt, + input.snapshot.terminalContexts, + ); + const outgoingMessageText = formatOutgoingPrompt({ + provider: snapshotProvider, + model: snapshotModel, + models: snapshotModels, + effort: snapshotProviderState.promptEffort, + text: promptWithTerminalContexts || IMAGE_ONLY_BOOTSTRAP_PROMPT, + }); + const optimisticAttachments = input.snapshot.attachments.map((attachment) => { + const previewUrl = isUploadedFollowUpAttachment(attachment) + ? attachment.dataUrl + : attachment.previewUrl; + return { + type: "image" as const, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + ...(previewUrl ? { previewUrl } : {}), + }; + }); + const messageIdForSend = newMessageId(); + const dispatchCreatedAt = new Date().toISOString(); + + sendInFlightRef.current = true; + beginSendPhase("sending-turn"); + setThreadError(input.threadId, null); + if (input.hideServerMessage) { + setHiddenTimelineMessageIds((existing) => new Set(existing).add(messageIdForSend)); + } + if (!input.suppressOptimisticMessage) { + setOptimisticUserMessages((existing) => [ + ...existing, + { + id: messageIdForSend, + role: "user", + text: outgoingMessageText, + ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), + createdAt: dispatchCreatedAt, + streaming: false, + }, + ]); + } + shouldAutoScrollRef.current = true; + forceStickToBottom(); + + try { + await persistThreadSettingsForNextTurn({ + threadId: input.threadId, + createdAt: dispatchCreatedAt, + modelSelection: input.snapshot.modelSelection, + runtimeMode: input.snapshot.runtimeMode, + interactionMode: input.snapshot.interactionMode, + }); + setComposerDraftInteractionMode(input.threadId, input.snapshot.interactionMode); + await api.orchestration.dispatchCommand({ + type: "thread.turn.start", + commandId: newCommandId(), + threadId: input.threadId, + message: { + messageId: messageIdForSend, + role: "user", + text: outgoingMessageText, + attachments: input.snapshot.attachments.map(toCommandAttachment), + }, + modelSelection: input.snapshot.modelSelection, + runtimeMode: input.snapshot.runtimeMode, + interactionMode: input.snapshot.interactionMode, + ...(input.sourceProposedPlan ? { sourceProposedPlan: input.sourceProposedPlan } : {}), + createdAt: dispatchCreatedAt, + }); + input.onAfterDispatch?.(); + sendInFlightRef.current = false; + return true; + } catch (err) { + if (input.hideServerMessage) { + setHiddenTimelineMessageIds((existing) => { + const next = new Set(existing); + next.delete(messageIdForSend); + return next; + }); + } + if (!input.suppressOptimisticMessage) { + setOptimisticUserMessages((existing) => + existing.filter((message) => message.id !== messageIdForSend), + ); + } + setThreadError(input.threadId, err instanceof Error ? err.message : input.errorMessage); + sendInFlightRef.current = false; + resetSendPhase(); + return false; + } + }, + [ + beginSendPhase, + forceStickToBottom, + persistThreadSettingsForNextTurn, + providerStatuses, + resetSendPhase, + setComposerDraftInteractionMode, + setThreadError, + ], + ); + + const finalizeSteeredQueuedFollowUp = useCallback( + async (pending: PendingSteerSubmission) => { + if (pending.source !== "queued-follow-up" || !pending.queuedFollowUpId) { + return; + } + const api = readNativeApi(); + if (!api) { + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.queued-follow-up.remove", + commandId: newCommandId(), + threadId: pending.threadId, + followUpId: pending.queuedFollowUpId, + createdAt: new Date().toISOString(), + }); + } catch (err) { + setThreadError( + pending.threadId, + err instanceof Error ? err.message : "Failed to remove queued follow-up.", + ); + try { + await api.orchestration.dispatchCommand({ + type: "thread.queued-follow-up.update", + commandId: newCommandId(), + threadId: pending.threadId, + followUp: { + ...pending.snapshot, + id: pending.queuedFollowUpId, + attachments: pending.snapshot.attachments.map(toCommandAttachment), + lastSendError: "Queued follow-up was sent but queue cleanup failed.", + }, + createdAt: new Date().toISOString(), + }); + } catch { + // Best effort. The UI error above preserves operator visibility if the queue marker + // could not be persisted. + } + } + }, + [setThreadError], + ); + + useEffect(() => { + const pendingSteerSubmission = pendingSteerSubmissionRef.current; + if (!pendingSteerSubmission || pendingSteerSubmission.threadId !== activeThread?.id) { + return; + } + if ( + phase === "running" || + isSendBusy || + isConnecting || + sendInFlightRef.current || + isComposerApprovalState || + pendingUserInputs.length > 0 + ) { + return; + } + + setPendingSteerSubmission(null); + void (async () => { + const dispatched = await dispatchServerThreadSnapshot({ + threadId: pendingSteerSubmission.threadId, + snapshot: pendingSteerSubmission.snapshot, + errorMessage: + pendingSteerSubmission.source === "queued-follow-up" + ? "Failed to steer queued follow-up." + : "Failed to send follow-up.", + suppressOptimisticMessage: false, + hideServerMessage: false, + }); + if (dispatched) { + if (pendingSteerSubmission.source === "queued-follow-up") { + await finalizeSteeredQueuedFollowUp(pendingSteerSubmission); + } + return; + } + if (pendingSteerSubmission.source !== "queued-follow-up") { + restoreFollowUpSnapshotToComposer(pendingSteerSubmission.snapshot); + } + })(); + }, [ + activeThread?.id, + dispatchServerThreadSnapshot, + finalizeSteeredQueuedFollowUp, + isComposerApprovalState, + isConnecting, + isSendBusy, + pendingSteerSubmissionVersion, + pendingUserInputs.length, + phase, + restoreFollowUpSnapshotToComposer, + setPendingSteerSubmission, + ]); + const onSubmitPlanFollowUp = useCallback( async ({ text, @@ -2940,107 +3539,231 @@ export default function ChatView({ threadId }: ChatViewProps) { return; } - const threadIdForSend = activeThread.id; - const messageIdForSend = newMessageId(); - const messageCreatedAt = new Date().toISOString(); - const outgoingMessageText = formatOutgoingPrompt({ - provider: selectedProvider, - model: selectedModel, - models: selectedProviderModels, - effort: selectedPromptEffort, - text: trimmed, - }); - - sendInFlightRef.current = true; - beginSendPhase("sending-turn"); - setThreadError(threadIdForSend, null); - setOptimisticUserMessages((existing) => [ - ...existing, - { - id: messageIdForSend, - role: "user", - text: outgoingMessageText, - createdAt: messageCreatedAt, - streaming: false, - }, - ]); - shouldAutoScrollRef.current = true; - forceStickToBottom(); - - try { - await persistThreadSettingsForNextTurn({ - threadId: threadIdForSend, - createdAt: messageCreatedAt, + await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: { + id: randomUUID(), + createdAt: new Date().toISOString(), + prompt: trimmed, + attachments: [], + terminalContexts: [], modelSelection: selectedModelSelection, runtimeMode, interactionMode: nextInteractionMode, + }, + errorMessage: "Failed to send plan follow-up.", + ...(nextInteractionMode === "default" && activeProposedPlan + ? { + sourceProposedPlan: { + threadId: activeThread.id, + planId: activeProposedPlan.id, + }, + } + : {}), + onAfterDispatch: () => { + if (nextInteractionMode === "default") { + planSidebarDismissedForTurnRef.current = null; + setPlanSidebarOpen(true); + } + }, + }); + }, + [ + activeThread, + activeProposedPlan, + dispatchServerThreadSnapshot, + isConnecting, + isSendBusy, + isServerThread, + runtimeMode, + selectedModelSelection, + ], + ); + + const onDeleteQueuedFollowUp = useCallback( + async (followUpId: string) => { + const api = readNativeApi(); + if (!api || !activeThread || !isServerThread) { + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.queued-follow-up.remove", + commandId: newCommandId(), + threadId: activeThread.id, + followUpId, + createdAt: new Date().toISOString(), }); + } catch (err) { + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to delete queued follow-up.", + ); + } + }, + [activeThread, isServerThread, setThreadError], + ); - // Keep the mode toggle and plan-follow-up banner in sync immediately - // while the same-thread implementation turn is starting. - setComposerDraftInteractionMode(threadIdForSend, nextInteractionMode); + const onReorderQueuedFollowUp = useCallback( + async (followUpId: string, targetIndex: number) => { + const api = readNativeApi(); + if (!api || !activeThread || !isServerThread) { + return; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.queued-follow-up.reorder", + commandId: newCommandId(), + threadId: activeThread.id, + followUpId, + targetIndex, + createdAt: new Date().toISOString(), + }); + } catch (err) { + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to reorder queued follow-up.", + ); + } + }, + [activeThread, isServerThread, setThreadError], + ); + const onEditQueuedFollowUp = useCallback( + async (followUpId: string) => { + const api = readNativeApi(); + if (!api || !activeThread || !isServerThread) { + return; + } + const followUp = queuedFollowUps.find((entry) => entry.id === followUpId); + if (!followUp) { + return; + } + const queueIndex = queuedFollowUps.findIndex((entry) => entry.id === followUpId); + const previousFollowUp = queueIndex > 0 ? queuedFollowUps[queueIndex - 1] : null; + const nextFollowUp = queuedFollowUps[queueIndex + 1] ?? null; + try { + const hydratedAttachments = await Promise.all( + followUp.attachments.map((attachment) => hydratePersistedAttachmentForDraft(attachment)), + ); await api.orchestration.dispatchCommand({ - type: "thread.turn.start", + type: "thread.queued-follow-up.remove", commandId: newCommandId(), - threadId: threadIdForSend, - message: { - messageId: messageIdForSend, - role: "user", - text: outgoingMessageText, - attachments: [], - }, - modelSelection: selectedModelSelection, - runtimeMode, - interactionMode: nextInteractionMode, - ...(nextInteractionMode === "default" && activeProposedPlan - ? { - sourceProposedPlan: { - threadId: activeThread.id, - planId: activeProposedPlan.id, + threadId: activeThread.id, + followUpId, + createdAt: new Date().toISOString(), + }); + setComposerDraftPrompt(threadId, followUp.prompt); + useComposerDraftStore.setState((state) => { + const currentDraft = state.draftsByThreadId[threadId] ?? composerDraft; + return { + draftsByThreadId: { + ...state.draftsByThreadId, + [threadId]: { + ...(currentDraft ?? composerDraft), + prompt: followUp.prompt, + images: hydrateComposerImagesFromPersistedAttachments(hydratedAttachments), + nonPersistedImageIds: [], + persistedAttachments: [...hydratedAttachments], + terminalContexts: followUp.terminalContexts.map((context) => ({ ...context })), + modelSelectionByProvider: { + ...(currentDraft?.modelSelectionByProvider ?? + composerDraft.modelSelectionByProvider), + [followUp.modelSelection.provider]: followUp.modelSelection, }, - } - : {}), - createdAt: messageCreatedAt, + activeProvider: followUp.modelSelection.provider, + runtimeMode: followUp.runtimeMode, + interactionMode: followUp.interactionMode, + queuedFollowUpEdit: { + followUpId: followUp.id, + queueIndex, + previousFollowUpId: previousFollowUp?.id ?? null, + nextFollowUpId: nextFollowUp?.id ?? null, + }, + }, + }, + }; }); - // Optimistically open the plan sidebar when implementing (not refining). - // "default" mode here means the agent is executing the plan, which produces - // step-tracking activities that the sidebar will display. - if (nextInteractionMode === "default") { - planSidebarDismissedForTurnRef.current = null; - setPlanSidebarOpen(true); - } - sendInFlightRef.current = false; } catch (err) { - setOptimisticUserMessages((existing) => - existing.filter((message) => message.id !== messageIdForSend), - ); setThreadError( - threadIdForSend, - err instanceof Error ? err.message : "Failed to send plan follow-up.", + activeThread.id, + err instanceof Error ? err.message : "Failed to edit queued follow-up.", ); - sendInFlightRef.current = false; - resetSendPhase(); + return; } + promptRef.current = followUp.prompt; + setComposerCursor(collapseExpandedComposerCursor(followUp.prompt, followUp.prompt.length)); + setComposerTrigger(detectComposerTrigger(followUp.prompt, followUp.prompt.length)); + window.requestAnimationFrame(() => { + composerEditorRef.current?.focusAt( + collapseExpandedComposerCursor(followUp.prompt, followUp.prompt.length), + ); + }); }, [ activeThread, - activeProposedPlan, - beginSendPhase, - forceStickToBottom, + composerDraft, + isServerThread, + queuedFollowUps, + setComposerDraftPrompt, + setThreadError, + threadId, + ], + ); + + const onSteerQueuedFollowUp = useCallback( + async (followUpId: string) => { + const api = readNativeApi(); + if ( + !api || + !activeThread || + !isServerThread || + isSendBusy || + isConnecting || + sendInFlightRef.current + ) { + return; + } + const followUp = queuedFollowUps.find((entry) => entry.id === followUpId); + if (!followUp) { + return; + } + const pendingSubmission: PendingSteerSubmission = { + threadId: activeThread.id, + snapshot: followUp, + source: "queued-follow-up", + queuedFollowUpId: followUp.id, + }; + if (phase === "running") { + setPendingSteerSubmission(pendingSubmission); + const interrupted = await requestThreadInterrupt(activeThread.id); + if (!interrupted) { + setPendingSteerSubmission(null); + } + return; + } + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: followUp, + errorMessage: "Failed to steer queued follow-up.", + suppressOptimisticMessage: false, + hideServerMessage: false, + }); + if (dispatched) { + await finalizeSteeredQueuedFollowUp(pendingSubmission); + } + }, + [ + activeThread, + dispatchServerThreadSnapshot, + finalizeSteeredQueuedFollowUp, isConnecting, isSendBusy, isServerThread, - persistThreadSettingsForNextTurn, - resetSendPhase, - runtimeMode, - selectedPromptEffort, - selectedModelSelection, - selectedProvider, - selectedProviderModels, - setComposerDraftInteractionMode, - setThreadError, - selectedModel, + phase, + queuedFollowUps, + requestThreadInterrupt, + setPendingSteerSubmission, ], ); @@ -3483,8 +4206,8 @@ export default function ChatView({ threadId }: ChatViewProps) { } } - if (key === "Enter" && !event.shiftKey) { - void onSend(); + if (key === "Enter" && (!event.shiftKey || shouldInvertFollowUpBehaviorFromKeyEvent(event))) { + void onSend({ keyboardEvent: event }); return true; } return false; @@ -3662,9 +4385,18 @@ export default function ChatView({ threadId }: ChatViewProps) { className="mx-auto w-full min-w-0 max-w-3xl" data-chat-composer-form="true" > + { + void onSteerQueuedFollowUp(followUpId); + }} + />
) : phase === "running" ? ( - + } + /> + +
+
+ {runningFollowUpShortcutRows.map((row) => ( +
+ + {row.label} + + + {row.shortcut} + +
+ ))} +
+
+
+ + ) : null} + + + + ) : pendingUserInputs.length === 0 ? ( showPlanFollowUpPrompt ? ( prompt.trim().length > 0 ? ( diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 338d9f7bf1..6b5d5f26f0 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -896,6 +896,8 @@ function ComposerPromptEditorInner({ const initialCursor = clampCollapsedComposerCursor(value, cursor); const terminalContextsSignature = terminalContextSignature(terminalContexts); const terminalContextsSignatureRef = useRef(terminalContextsSignature); + const controlledValueRef = useRef(value); + const controlledTerminalContextIdsRef = useRef(terminalContexts.map((context) => context.id)); const snapshotRef = useRef({ value, cursor: initialCursor, @@ -912,6 +914,11 @@ function ComposerPromptEditorInner({ onChangeRef.current = onChange; }, [onChange]); + useEffect(() => { + controlledValueRef.current = value; + controlledTerminalContextIdsRef.current = terminalContexts.map((context) => context.id); + }, [terminalContexts, value]); + useEffect(() => { editor.setEditable(!disabled); }, [disabled, editor]); @@ -961,23 +968,25 @@ function ComposerPromptEditorInner({ (nextCursor: number) => { const rootElement = editor.getRootElement(); if (!rootElement) return; - const boundedCursor = clampCollapsedComposerCursor(snapshotRef.current.value, nextCursor); + const controlledValue = controlledValueRef.current; + const controlledTerminalContextIds = controlledTerminalContextIdsRef.current; + const boundedCursor = clampCollapsedComposerCursor(controlledValue, nextCursor); rootElement.focus(); editor.update(() => { $setSelectionAtComposerOffset(boundedCursor); }); snapshotRef.current = { - value: snapshotRef.current.value, + value: controlledValue, cursor: boundedCursor, - expandedCursor: expandCollapsedComposerCursor(snapshotRef.current.value, boundedCursor), - terminalContextIds: snapshotRef.current.terminalContextIds, + expandedCursor: expandCollapsedComposerCursor(controlledValue, boundedCursor), + terminalContextIds: controlledTerminalContextIds, }; onChangeRef.current( - snapshotRef.current.value, + controlledValue, boundedCursor, snapshotRef.current.expandedCursor, false, - snapshotRef.current.terminalContextIds, + controlledTerminalContextIds, ); }, [editor], @@ -1025,12 +1034,8 @@ function ComposerPromptEditorInner({ }, focusAt, focusAtEnd: () => { - focusAt( - collapseExpandedComposerCursor( - snapshotRef.current.value, - snapshotRef.current.value.length, - ), - ); + const controlledValue = controlledValueRef.current; + focusAt(collapseExpandedComposerCursor(controlledValue, controlledValue.length)); }, readSnapshot, }), diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index 5d6ec94a50..f815926f57 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -115,6 +115,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { updatedAt: NOW_ISO, }, ], + queuedFollowUps: [], activities: [], proposedPlans: [], checkpoints: [], diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx index eee6f885e9..64a01bc34e 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx @@ -33,6 +33,7 @@ async function mountMenu(props?: { modelSelection?: ModelSelection; prompt?: str activeProvider: provider, runtimeMode: null, interactionMode: null, + queuedFollowUpEdit: null, }; useComposerDraftStore.setState({ draftsByThreadId, diff --git a/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx new file mode 100644 index 0000000000..6e0f0ef4b3 --- /dev/null +++ b/apps/web/src/components/chat/ComposerQueuedFollowUpsPanel.tsx @@ -0,0 +1,230 @@ +import type * as React from "react"; +import { memo, useEffect, useRef, useState } from "react"; +import { CornerDownRightIcon, EllipsisIcon, PencilIcon, Trash2Icon } from "lucide-react"; +import { type QueuedFollowUp } from "../../types"; +import { describeQueuedFollowUp } from "../ChatView.logic"; +import { Button } from "../ui/button"; + +function resolveDropPosition( + event: Pick, "clientY" | "currentTarget">, +): "before" | "after" { + const bounds = event.currentTarget.getBoundingClientRect(); + return event.clientY <= bounds.top + bounds.height / 2 ? "before" : "after"; +} + +function resolveTargetIndex( + currentIndex: number, + hoveredIndex: number, + position: "before" | "after", +): number { + if (position === "before") { + return currentIndex < hoveredIndex ? hoveredIndex - 1 : hoveredIndex; + } + return currentIndex < hoveredIndex ? hoveredIndex : hoveredIndex + 1; +} + +function QueuedFollowUpSummaryIcon() { + return ( + + ); +} + +function DragGripDots() { + return ( +