diff --git a/apps/server/integration/OrchestrationEngineHarness.integration.ts b/apps/server/integration/OrchestrationEngineHarness.integration.ts index e66a214fb7..6a949147cb 100644 --- a/apps/server/integration/OrchestrationEngineHarness.integration.ts +++ b/apps/server/integration/OrchestrationEngineHarness.integration.ts @@ -38,6 +38,7 @@ import { ProjectionPendingApprovalRepository } from "../src/persistence/Services import { ProviderUnsupportedError } from "../src/provider/Errors.ts"; import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapterRegistry.ts"; import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; +import { ProviderRegistryLive } from "../src/provider/Layers/ProviderRegistry.ts"; import { ServerSettingsService } from "../src/serverSettings.ts"; import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; import { makeCodexAdapterLive } from "../src/provider/Layers/CodexAdapter.ts"; @@ -48,6 +49,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 +167,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 +214,8 @@ export interface OrchestrationIntegrationHarness { interface MakeOrchestrationIntegrationHarnessOptions { readonly provider?: ProviderKind; readonly realCodex?: boolean; + readonly rootDir?: string; + readonly autoStartReactor?: boolean; } export const makeOrchestrationIntegrationHarness = ( @@ -236,16 +241,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( @@ -318,13 +332,18 @@ 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), + Layer.provideMerge(ProviderRegistryLive), Layer.provideMerge(ServerSettingsService.layerTest()), Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)), Layer.provideMerge(NodeServices.layer), @@ -359,9 +378,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), @@ -492,6 +521,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..7b4ce2ccbd 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,94 @@ 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* Effect.addFinalizer(() => + Effect.sync(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + }), + ); + + 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 d60f0cf722..4d23fe4aaf 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", () => { @@ -49,6 +50,15 @@ describe("OrchestrationReactor", () => { drain: Effect.void, }), ), + Layer.provideMerge( + Layer.succeed(QueuedFollowUpReactor, { + start: () => + Effect.sync(() => { + started.push("queued-follow-up-reactor"); + }), + drain: Effect.void, + }), + ), ), ); @@ -60,6 +70,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 99d30c57a2..9de086ace7 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.fn("start")(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..11b95a8252 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -921,6 +921,488 @@ 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( + "prunes replaced queued follow-up attachments on enqueue upsert and bumps thread recency", + () => + 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 = "2026-03-29T02:00:00.000Z"; + const later = "2026-03-29T02:00:05.000Z"; + const threadId = ThreadId.makeUnsafe("thread-queued-upsert"); + const originalAttachmentId = "thread-queued-upsert-00000000-0000-4000-8000-000000000001"; + const replacementAttachmentId = + "thread-queued-upsert-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-upsert-1"), + aggregateKind: "project", + aggregateId: ProjectId.makeUnsafe("project-queued-upsert"), + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-upsert-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-upsert-1"), + metadata: {}, + payload: { + projectId: ProjectId.makeUnsafe("project-queued-upsert"), + title: "Project Queued Upsert", + workspaceRoot: "/tmp/project-queued-upsert", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-queued-upsert-2"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-upsert-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-upsert-2"), + metadata: {}, + payload: { + threadId, + projectId: ProjectId.makeUnsafe("project-queued-upsert"), + title: "Thread Queued Upsert", + 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-upsert-3"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-queued-upsert-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-upsert-3"), + metadata: {}, + payload: { + threadId, + createdAt: now, + followUp: { + id: "queued-follow-up-upsert", + createdAt: now, + prompt: "original queued prompt", + attachments: [ + { + type: "image", + id: originalAttachmentId, + name: "original.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + }, + }); + + const originalAttachmentPath = path.join(attachmentsDir, `${originalAttachmentId}.png`); + const replacementAttachmentPath = path.join( + attachmentsDir, + `${replacementAttachmentId}.png`, + ); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(originalAttachmentPath, "original"); + yield* fileSystem.writeFileString(replacementAttachmentPath, "replacement"); + assert.isTrue(yield* exists(originalAttachmentPath)); + assert.isTrue(yield* exists(replacementAttachmentPath)); + + yield* appendAndProject({ + type: "thread.queued-follow-up-enqueued", + eventId: EventId.makeUnsafe("evt-queued-upsert-4"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: later, + commandId: CommandId.makeUnsafe("cmd-queued-upsert-4"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-queued-upsert-4"), + metadata: {}, + payload: { + threadId, + createdAt: later, + followUp: { + id: "queued-follow-up-upsert", + createdAt: now, + prompt: "replacement queued prompt", + attachments: [ + { + type: "image", + id: replacementAttachmentId, + name: "replacement.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + }, + }); + + assert.isFalse(yield* exists(originalAttachmentPath)); + assert.isTrue(yield* exists(replacementAttachmentPath)); + + const threadRows = yield* sql<{ readonly updatedAt: string }>` + SELECT updated_at AS "updatedAt" + FROM projection_threads + WHERE thread_id = ${threadId} + `; + assert.deepEqual(threadRows, [{ updatedAt: later }]); + }), + ); + 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 f0f2ccabee..3d30a69333 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", @@ -77,6 +83,10 @@ interface AttachmentSideEffects { readonly prunedThreadRelativePaths: Map>; } +interface ProjectionAttachmentOwnerLike { + readonly attachments?: ReadonlyArray | null | undefined; +} + const materializeAttachmentsForProjection = Effect.fn("materializeAttachmentsForProjection")( (input: { readonly attachments: ReadonlyArray }) => Effect.succeed(input.attachments.length === 0 ? [] : input.attachments), @@ -214,17 +224,17 @@ function retainProjectionProposedPlansAfterRevert( ); } -function collectThreadAttachmentRelativePaths( +function collectProjectedAttachmentRelativePaths( threadId: string, - messages: ReadonlyArray, + items: ReadonlyArray, ): Set { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { return new Set(); } const relativePaths = new Set(); - for (const message of messages) { - for (const attachment of message.attachments ?? []) { + for (const item of items) { + for (const attachment of item.attachments ?? []) { if (attachment.type !== "image") { continue; } @@ -238,6 +248,13 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } +function mergeThreadAttachmentRelativePaths( + messagePaths: ReadonlySet, + queuedFollowUpPaths: ReadonlySet, +): Set { + return new Set([...messagePaths, ...queuedFollowUpPaths]); +} + const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( sideEffects: AttachmentSideEffects, ) { @@ -368,6 +385,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti 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; @@ -684,7 +703,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }).pipe(Effect.asVoid); attachmentSideEffects.prunedThreadRelativePaths.set( event.payload.threadId, - collectThreadAttachmentRelativePaths(event.payload.threadId, keptRows), + collectProjectedAttachmentRelativePaths(event.payload.threadId, keptRows), ); return; } @@ -1070,6 +1089,213 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti } }); + const applyQueuedFollowUpsProjection: ProjectorDefinition["apply"] = Effect.fn( + "applyQueuedFollowUpsProjection", + )(function* (event, attachmentSideEffects) { + const replaceThreadQueue = ( + threadId: ProjectionThreadQueuedFollowUp["threadId"], + followUps: ReadonlyArray, + ) => + projectionThreadQueuedFollowUpRepository.replaceByThreadId({ + threadId, + followUps: followUps.map((followUp, index) => ({ + ...followUp, + queuePosition: index, + })), + }); + const syncQueuedFollowUpAttachmentPaths = Effect.fn("syncQueuedFollowUpAttachmentPaths")( + function* ( + threadId: ProjectionThreadQueuedFollowUp["threadId"], + followUps: ReadonlyArray, + ) { + const messageRows = yield* projectionThreadMessageRepository.listByThreadId({ + threadId, + }); + attachmentSideEffects.prunedThreadRelativePaths.set( + threadId, + mergeThreadAttachmentRelativePaths( + collectProjectedAttachmentRelativePaths(threadId, messageRows), + collectProjectedAttachmentRelativePaths(threadId, followUps), + ), + ); + }, + ); + const touchProjectedThreadUpdatedAt = Effect.fn("touchProjectedThreadUpdatedAt")(function* ( + threadId: ProjectionThreadQueuedFollowUp["threadId"], + updatedAt: string, + ) { + const existingThread = yield* projectionThreadRepository.getById({ threadId }); + if (Option.isNone(existingThread)) { + return; + } + yield* projectionThreadRepository.upsert({ + ...existingThread.value, + updatedAt, + }); + }); + + 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)); + const nextQueuedFollowUps = [ + ...withoutExisting.slice(0, targetIndex), + nextFollowUp, + ...withoutExisting.slice(targetIndex), + ]; + yield* replaceThreadQueue(event.payload.threadId, nextQueuedFollowUps); + yield* syncQueuedFollowUpAttachmentPaths(event.payload.threadId, nextQueuedFollowUps); + yield* touchProjectedThreadUpdatedAt(event.payload.threadId, event.occurredAt); + 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); + yield* touchProjectedThreadUpdatedAt(event.payload.threadId, event.occurredAt); + 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); + yield* touchProjectedThreadUpdatedAt(event.payload.threadId, event.occurredAt); + 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); + yield* touchProjectedThreadUpdatedAt(event.payload.threadId, event.occurredAt); + 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, + ), + ); + yield* touchProjectedThreadUpdatedAt(event.payload.threadId, event.occurredAt); + 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, + ), + ); + yield* touchProjectedThreadUpdatedAt(event.payload.threadId, event.occurredAt); + 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, + collectProjectedAttachmentRelativePaths(event.payload.threadId, messageRows), + ); + } + return; + } + + default: + return; + } + }); + const applyCheckpointsProjection: ProjectorDefinition["apply"] = () => Effect.void; const applyPendingApprovalsProjection: ProjectorDefinition["apply"] = Effect.fn( @@ -1181,6 +1407,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti name: ORCHESTRATION_PROJECTOR_NAMES.threadSessions, apply: applyThreadSessionsProjection, }, + { + name: ORCHESTRATION_PROJECTOR_NAMES.queuedFollowUps, + apply: applyQueuedFollowUpsProjection, + }, { name: ORCHESTRATION_PROJECTOR_NAMES.threadTurns, apply: applyThreadTurnsProjection, @@ -1298,6 +1528,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..9628534793 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,10 @@ 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 { + projectionQueuedFollowUpToContract, + ProjectionThreadQueuedFollowUp, +} from "../../persistence/Services/ProjectionThreadQueuedFollowUps.ts"; import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts"; import { ProjectionSnapshotQuery, @@ -74,6 +80,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 +107,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 +279,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 +367,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { proposedPlanRows, activityRows, sessionRows, + queuedFollowUpRows, checkpointRows, latestTurnRows, stateRows, @@ -382,6 +420,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 +457,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 +536,13 @@ 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(projectionQueuedFollowUpToContract(row)); + queuedFollowUpsByThread.set(row.threadId, threadQueuedFollowUps); + } + for (const row of latestTurnRows) { updatedAt = maxIso(updatedAt, row.requestedAt); if (row.startedAt !== null) { @@ -565,6 +619,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..d7a7a7badb --- /dev/null +++ b/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.test.ts @@ -0,0 +1,735 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + EventId, + type OrchestrationEvent, + type OrchestrationThreadActivity, + type OrchestrationSessionStatus, + ProjectId, + type ServerProvider, + ThreadId, + type OrchestrationCommand, + type OrchestrationReadModel, + type TurnId, +} 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 { ProviderRegistry } from "../../provider/Services/ProviderRegistry.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"] + >; + queuedModelSelections?: ReadonlyArray< + OrchestrationReadModel["threads"][number]["queuedFollowUps"][number]["modelSelection"] + >; + queuedRuntimeModes?: ReadonlyArray< + OrchestrationReadModel["threads"][number]["queuedFollowUps"][number]["runtimeMode"] + >; + queuedInteractionModes?: ReadonlyArray< + OrchestrationReadModel["threads"][number]["queuedFollowUps"][number]["interactionMode"] + >; + latestTurnState?: OrchestrationReadModel["threads"][number]["latestTurn"]; + activities?: ReadonlyArray; + threadRuntimeMode?: OrchestrationReadModel["threads"][number]["runtimeMode"]; + threadInteractionMode?: OrchestrationReadModel["threads"][number]["interactionMode"]; +}): 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: input?.threadInteractionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: input?.threadRuntimeMode ?? 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: input?.queuedModelSelections?.[index] ?? { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: input?.queuedRuntimeModes?.[index] ?? DEFAULT_RUNTIME_MODE, + interactionMode: + input?.queuedInteractionModes?.[index] ?? 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: input?.threadRuntimeMode ?? DEFAULT_RUNTIME_MODE, + activeTurnId: null, + lastError: null, + updatedAt: NOW_ISO, + }, + }, + ], + }; +} + +const DEFAULT_PROVIDERS: ReadonlyArray = [ + { + provider: "codex", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + authStatus: "authenticated", + checkedAt: NOW_ISO, + models: [ + { + slug: "gpt-5.3-codex", + name: "GPT-5.3 Codex", + isCustom: false, + capabilities: { + reasoningEffortLevels: [], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: [], + }, + }, + ], + }, + { + provider: "claudeAgent", + enabled: true, + installed: true, + version: "1.0.0", + status: "ready", + authStatus: "authenticated", + checkedAt: NOW_ISO, + models: [ + { + slug: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + isCustom: false, + capabilities: { + reasoningEffortLevels: [ + { value: "ultrathink", label: "Ultrathink" }, + { value: "high", label: "High" }, + ], + supportsFastMode: false, + supportsThinkingToggle: false, + contextWindowOptions: [], + promptInjectedEffortLevels: ["ultrathink"], + }, + }, + ], + }, +]; + +function provideQueuedFollowUpReactorTestServices( + engine: OrchestrationEngineShape, + providers: ReadonlyArray = DEFAULT_PROVIDERS, +) { + return QueuedFollowUpReactorLive.pipe( + Layer.provide(Layer.succeed(OrchestrationEngineService, engine)), + Layer.provide( + Layer.succeed(ProviderRegistry, { + getProviders: Effect.succeed(providers), + refresh: () => Effect.succeed(providers), + streamChanges: Stream.empty, + }), + ), + ); +} + +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(provideQueuedFollowUpReactorTestServices(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: 6, + text: "\n\nalpha\nbeta", + }, + ], + ], + }), + ), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make(provideQueuedFollowUpReactorTestServices(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-6:"); + expect(turnStart.message.text).toContain("5 | alpha"); + expect(turnStart.message.text).toContain("6 | 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(provideQueuedFollowUpReactorTestServices(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(provideQueuedFollowUpReactorTestServices(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(provideQueuedFollowUpReactorTestServices(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(provideQueuedFollowUpReactorTestServices(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(provideQueuedFollowUpReactorTestServices(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(provideQueuedFollowUpReactorTestServices(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)); + }); + + it("blocks redispatch when both queue cleanup and send-failed persistence fail", async () => { + const dispatched: OrchestrationCommand[] = []; + let readModel = makeReadModel({ + queuedPrompts: ["first"], + latestTurnState: { + turnId: "latest-turn-1" as TurnId, + state: "completed", + requestedAt: "2026-03-28T12:00:01.000Z", + startedAt: "2026-03-28T12:00:01.100Z", + completedAt: "2026-03-28T12:00:02.000Z", + assistantMessageId: null, + }, + }); + const threadEvent = { + eventId: EventId.makeUnsafe("evt-queued-follow-up-reactor-double-failure"), + 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-double-failure"), + causationEventId: null, + correlationId: "corr-queued-follow-up-reactor-double-failure", + 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.turn.start") { + readModel = { + ...readModel, + threads: [ + { + ...readModel.threads[0]!, + latestTurn: { + turnId: "latest-turn-2" as TurnId, + state: "completed", + requestedAt: "2026-03-28T12:00:03.000Z", + startedAt: "2026-03-28T12:00:03.100Z", + completedAt: "2026-03-28T12:00:04.000Z", + assistantMessageId: null, + }, + }, + ], + }; + return { sequence: dispatched.length }; + } + if ( + command.type === "thread.queued-follow-up.remove" || + command.type === "thread.queued-follow-up.send-failed" + ) { + throw new Error(`${command.type} failed`); + } + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.fromIterable([threadEvent, threadEvent]), + }; + + runtime = ManagedRuntime.make(provideQueuedFollowUpReactorTestServices(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", + "thread.queued-follow-up.send-failed", + ]); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("sets queued runtime and interaction modes before auto-dispatching the queued head", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => + Effect.succeed( + makeReadModel({ + queuedPrompts: ["mode-sensitive follow-up"], + queuedRuntimeModes: ["approval-required"], + queuedInteractionModes: ["plan"], + threadRuntimeMode: "full-access", + threadInteractionMode: "default", + }), + ), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make(provideQueuedFollowUpReactorTestServices(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.runtime-mode.set", + "thread.interaction-mode.set", + "thread.turn.start", + "thread.queued-follow-up.remove", + ]); + + const turnStart = dispatched[2]; + expect(turnStart?.type).toBe("thread.turn.start"); + if (turnStart?.type !== "thread.turn.start") { + throw new Error("Expected third command to be thread.turn.start"); + } + expect(turnStart.runtimeMode).toBe("approval-required"); + expect(turnStart.interactionMode).toBe("plan"); + + await Effect.runPromise(Scope.close(scope, Exit.void)); + }); + + it("applies prompt-injected effort formatting during queued auto-dispatch", async () => { + const dispatched: OrchestrationCommand[] = []; + const engine: OrchestrationEngineShape = { + getReadModel: () => + Effect.succeed( + makeReadModel({ + queuedPrompts: ["Investigate this carefully"], + queuedModelSelections: [ + { + provider: "claudeAgent", + model: "claude-sonnet-4-6", + options: { effort: "ultrathink" }, + }, + ], + }), + ), + readEvents: () => Stream.empty, + dispatch: (command) => + Effect.sync(() => { + dispatched.push(command); + return { sequence: dispatched.length }; + }), + streamDomainEvents: Stream.empty, + }; + + runtime = ManagedRuntime.make(provideQueuedFollowUpReactorTestServices(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.find((command) => command.type === "thread.turn.start"); + expect(turnStart?.type).toBe("thread.turn.start"); + if (turnStart?.type !== "thread.turn.start") { + throw new Error("Expected a thread.turn.start command"); + } + expect(turnStart.message.text).toBe("Ultrathink:\nInvestigate this carefully"); + + 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..db9f0d9046 --- /dev/null +++ b/apps/server/src/orchestration/Layers/QueuedFollowUpReactor.ts @@ -0,0 +1,276 @@ +import { CommandId, MessageId, type OrchestrationEvent, type ThreadId } from "@t3tools/contracts"; +import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; +import { + buildQueuedFollowUpMessageText, + canDispatchQueuedFollowUp, +} from "@t3tools/shared/orchestration"; +import { formatOutgoingPrompt, promptEffortFromModelSelection } from "@t3tools/shared/model"; +import { Cause, Effect, Exit, Layer, Stream } from "effect"; + +import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; +import { ProviderRegistry } from "../../provider/Services/ProviderRegistry.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 providerRegistry = yield* ProviderRegistry; + const inFlightFollowUpIds = new Set(); + const pendingQueuedDispatchByThreadId = new Map(); + const blockedQueuedFollowUpIdsByThreadId = 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); + blockedQueuedFollowUpIdsByThreadId.delete(threadId); + return; + } + const blockedFollowUpId = blockedQueuedFollowUpIdsByThreadId.get(threadId); + if (blockedFollowUpId) { + const blockedQueuedFollowUpStillPresent = thread.queuedFollowUps.some( + (followUp) => followUp.id === blockedFollowUpId, + ); + if (blockedQueuedFollowUpStillPresent) { + return; + } + blockedQueuedFollowUpIdsByThreadId.delete(threadId); + } + 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 blockQueuedFollowUpAfterPersistenceFailure = Effect.fnUntraced(function* () { + blockedQueuedFollowUpIdsByThreadId.set(threadId, queuedHead.id); + yield* Effect.logWarning( + "queued follow-up reactor blocked a queued item after persistence failure", + { + threadId, + followUpId: queuedHead.id, + }, + ); + }); + + const turnStartCreatedAt = new Date().toISOString(); + const providers = yield* providerRegistry.getProviders; + const queuedProviderModels = + providers.find((provider) => provider.provider === queuedHead.modelSelection.provider) + ?.models ?? []; + const outgoingMessageText = formatOutgoingPrompt({ + provider: queuedHead.modelSelection.provider, + model: queuedHead.modelSelection.model, + models: queuedProviderModels, + effort: promptEffortFromModelSelection(queuedHead.modelSelection), + text: + buildQueuedFollowUpMessageText({ + prompt: queuedHead.prompt, + terminalContexts: queuedHead.terminalContexts, + attachmentCount: queuedHead.attachments.length, + }) || "", + }); + const turnStartExit = yield* Effect.exit( + Effect.gen(function* () { + if (thread.runtimeMode !== queuedHead.runtimeMode) { + yield* orchestrationEngine.dispatch({ + type: "thread.runtime-mode.set", + commandId: serverCommandId("queued-follow-up-runtime-mode-set"), + threadId, + runtimeMode: queuedHead.runtimeMode, + createdAt: turnStartCreatedAt, + }); + } + if (thread.interactionMode !== queuedHead.interactionMode) { + yield* orchestrationEngine.dispatch({ + type: "thread.interaction-mode.set", + commandId: serverCommandId("queued-follow-up-interaction-mode-set"), + threadId, + interactionMode: queuedHead.interactionMode, + createdAt: turnStartCreatedAt, + }); + } + yield* orchestrationEngine.dispatch({ + type: "thread.turn.start", + commandId: serverCommandId("queued-follow-up-turn-start"), + threadId, + message: { + messageId: MessageId.makeUnsafe(crypto.randomUUID()), + role: "user", + text: outgoingMessageText, + 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.gen(function* () { + yield* Effect.logWarning( + "queued follow-up reactor failed to persist send failure", + { + threadId, + followUpId: queuedHead.id, + cause: Cause.pretty(nestedCause), + }, + ); + yield* blockQueuedFollowUpAfterPersistenceFailure(); + }), + ), + ); + 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.gen(function* () { + yield* Effect.logWarning( + "queued follow-up reactor failed to persist send failure", + { + threadId, + followUpId: queuedHead.id, + cause: Cause.pretty(nestedCause), + }, + ); + yield* blockQueuedFollowUpAfterPersistenceFailure(); + }), + ), + ); + } + }).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..ce8e81fa89 --- /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 22f5bcb280..d560016b3c 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..d03552ba68 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,177 @@ 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("clears queued follow-ups from the in-memory snapshot on thread.reverted", async () => { + const createdAt = "2026-03-28T12:00:00.000Z"; + const queuedAt = "2026-03-28T12:00:05.000Z"; + const revertedAt = "2026-03-28T12:00:10.000Z"; + const model = createEmptyReadModel(createdAt); + + const afterCreate = await Effect.runPromise( + projectEvent( + model, + makeEvent({ + sequence: 1, + type: "thread.created", + aggregateKind: "thread", + aggregateId: "thread-queue-revert", + occurredAt: createdAt, + commandId: "cmd-create-queue-revert", + payload: { + threadId: "thread-queue-revert", + projectId: "project-1", + title: "queue revert 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-revert", + occurredAt: queuedAt, + commandId: "cmd-queue-revert-enqueue", + payload: { + threadId: "thread-queue-revert", + followUp: { + id: "follow-up-revert-1", + createdAt: queuedAt, + prompt: "queue this before revert", + attachments: [], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + createdAt: queuedAt, + }, + }), + ), + ); + + const afterRevert = await Effect.runPromise( + projectEvent( + afterQueue, + makeEvent({ + sequence: 3, + type: "thread.reverted", + aggregateKind: "thread", + aggregateId: "thread-queue-revert", + occurredAt: revertedAt, + commandId: "cmd-queue-revert", + payload: { + threadId: "thread-queue-revert", + turnCount: 0, + }, + }), + ), + ); + + expect(afterRevert.threads[0]?.queuedFollowUps).toEqual([]); + expect(afterRevert.threads[0]?.updatedAt).toBe(revertedAt); + }); + 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..193cd918c9 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( @@ -609,6 +790,7 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { checkpoints, messages, + queuedFollowUps: [], proposedPlans, activities, latestTurn, 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..af03039932 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, toSafeThreadAttachmentSegment } 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,385 @@ 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("rejects persisted attachment ids from a different thread", async () => { + const baseDir = makeTempDir("t3code-ws-queued-attachments-thread-scope-"); + 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-cross-thread-attachment-project-create", + projectId: "project-cross-thread-attachment", + title: "Cross Thread Attachment Project", + workspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); + + for (const threadId of ["thread-cross-a", "thread-cross-b"]) { + const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.create", + commandId: `cmd-${threadId}-create`, + threadId, + projectId: "project-cross-thread-attachment", + title: threadId, + 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-cross-thread-attachment-enqueue", + threadId: "thread-cross-a", + followUp: { + id: "follow-up-cross-thread-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 persistedAttachment = snapshot.threads.find((thread) => thread.id === "thread-cross-a") + ?.queuedFollowUps[0]?.attachments[0]; + + expect(persistedAttachment?.id).toBeTruthy(); + + const crossThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.turn.start", + commandId: "cmd-cross-thread-attachment-send", + threadId: "thread-cross-b", + message: { + messageId: "msg-cross-thread-attachment", + role: "user", + text: "use another thread attachment", + attachments: [ + { + type: "image", + id: persistedAttachment!.id, + name: persistedAttachment!.name, + mimeType: persistedAttachment!.mimeType, + sizeBytes: persistedAttachment!.sizeBytes, + }, + ], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }); + + expect(crossThreadResponse.result).toBeUndefined(); + expect(crossThreadResponse.error?.message).toContain("does not belong to this thread"); + }); + + it("rejects persisted attachment ids from a colliding normalized thread segment", async () => { + const baseDir = makeTempDir("t3code-ws-queued-attachments-thread-collision-"); + const workspaceRoot = path.join(baseDir, "workspace"); + fs.mkdirSync(workspaceRoot, { recursive: true }); + + const sharedPrefix = `thread-${"x".repeat(90)}`; + const threadA = `${sharedPrefix}-a`; + const threadB = `${sharedPrefix}-b`; + expect(toSafeThreadAttachmentSegment(threadA)).toBe(toSafeThreadAttachmentSegment(threadB)); + + 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-colliding-thread-attachment-project-create", + projectId: "project-colliding-thread-attachment", + title: "Colliding Thread Attachment Project", + workspaceRoot, + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }); + expect(createProjectResponse.error).toBeUndefined(); + + for (const threadId of [threadA, threadB]) { + const createThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.create", + commandId: `cmd-${threadId}-create`, + threadId, + projectId: "project-colliding-thread-attachment", + title: threadId, + 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-colliding-thread-attachment-enqueue", + threadId: threadA, + followUp: { + id: "follow-up-colliding-thread-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 persistedAttachment = snapshot.threads.find((thread) => thread.id === threadA) + ?.queuedFollowUps[0]?.attachments[0]; + + expect(persistedAttachment?.id).toBeTruthy(); + + const crossThreadResponse = await sendRequest(ws, ORCHESTRATION_WS_METHODS.dispatchCommand, { + type: "thread.turn.start", + commandId: "cmd-colliding-thread-attachment-send", + threadId: threadB, + message: { + messageId: "msg-colliding-thread-attachment", + role: "user", + text: "use another thread attachment", + attachments: [ + { + type: "image", + id: persistedAttachment!.id, + name: persistedAttachment!.name, + mimeType: persistedAttachment!.mimeType, + sizeBytes: persistedAttachment!.sizeBytes, + }, + ], + }, + runtimeMode: "full-access", + interactionMode: "default", + createdAt, + }); + + expect(crossThreadResponse.result).toBeUndefined(); + expect(crossThreadResponse.error?.message).toContain("does not belong to this thread"); + }); + 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 c04d913d52..5fb9e745fe 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -12,8 +12,11 @@ import type { Duplex } from "node:stream"; import Mime from "@effect/platform-node/Mime"; import { CommandId, + type ChatAttachment, + type ClientChatAttachment, DEFAULT_PROVIDER_INTERACTION_MODE, type ClientOrchestrationCommand, + type OrchestrationQueuedFollowUp, type OrchestrationCommand, ORCHESTRATION_WS_CHANNELS, ORCHESTRATION_WS_METHODS, @@ -104,6 +107,29 @@ export interface ServerShape { */ export class Server extends ServiceMap.Service()("t3/wsServer/Server") {} +interface PersistedAttachmentOwnerLike { + readonly attachments?: ReadonlyArray | undefined; +} + +interface ThreadPersistedAttachmentOwnershipLike { + readonly messages: ReadonlyArray; + readonly queuedFollowUps: ReadonlyArray; +} + +function collectPersistedAttachmentIdsForThread( + thread: ThreadPersistedAttachmentOwnershipLike, +): Set { + const attachmentIds = new Set(); + for (const item of [...thread.messages, ...thread.queuedFollowUps]) { + for (const attachment of item.attachments ?? []) { + if (attachment.type === "image") { + attachmentIds.add(attachment.id); + } + } + } + return attachmentIds; +} + const isServerNotRunningError = (error: Error): boolean => { const maybeCode = (error as NodeJS.ErrnoException).code; return ( @@ -308,51 +334,50 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< (cause) => new ServerLifecycleError({ operation: "serverSettingsRuntimeStart", cause }), ), ); + const orchestrationEngine = yield* OrchestrationEngineService; + const projectionReadModelQuery = yield* ProjectionSnapshotQuery; + const checkpointDiffQuery = yield* CheckpointDiffQuery; + const orchestrationReactor = yield* OrchestrationReactor; + const { openInEditor } = yield* Open; - const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { - readonly command: ClientOrchestrationCommand; - }) { - const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { - const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); - const workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!workspaceStat) { - return yield* new RouteRequestError({ - message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, - }); - } - if (workspaceStat.type !== "Directory") { - return yield* new RouteRequestError({ - message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, - }); - } - return normalizedWorkspaceRoot; - }); - - if (input.command.type === "project.create") { - return { - ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), - } satisfies OrchestrationCommand; - } - - if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { - return { - ...input.command, - workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), - } satisfies OrchestrationCommand; - } + const listThreadPersistedAttachmentIds = Effect.fnUntraced(function* (threadId: ThreadId) { + const snapshot = yield* projectionReadModelQuery.getSnapshot(); + const thread = snapshot.threads.find( + (entry) => entry.id === threadId && entry.deletedAt === null, + ); + return thread ? collectPersistedAttachmentIdsForThread(thread) : new Set(); + }); - if (input.command.type !== "thread.turn.start") { - return input.command as OrchestrationCommand; - } - const turnStartCommand = input.command; + const normalizeClientAttachments = Effect.fnUntraced(function* ( + threadId: ThreadId, + attachments: ReadonlyArray, + ) { + const threadPersistedAttachmentIds = attachments.some((attachment) => "id" in attachment) + ? yield* listThreadPersistedAttachmentIds(threadId) + : null; - const normalizedAttachments = yield* Effect.forEach( - turnStartCommand.message.attachments, + return yield* Effect.forEach( + attachments, (attachment) => Effect.gen(function* () { + if ("id" in attachment) { + if (!threadPersistedAttachmentIds?.has(attachment.id)) { + return yield* new RouteRequestError({ + message: `Persisted attachment '${attachment.name}' does not belong to this thread.`, + }); + } + const persistedPath = resolveAttachmentPathById({ + attachmentsDir: serverConfig.attachmentsDir, + attachmentId: attachment.id, + }); + if (!persistedPath) { + return yield* new RouteRequestError({ + message: `Persisted attachment '${attachment.name}' could not be found.`, + }); + } + return attachment; + } + const parsed = parseBase64DataUrl(attachment.dataUrl); if (!parsed || !parsed.mimeType.startsWith("image/")) { return yield* new RouteRequestError({ @@ -367,7 +392,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }); } - const attachmentId = createAttachmentId(turnStartCommand.threadId); + const attachmentId = createAttachmentId(threadId); if (!attachmentId) { return yield* new RouteRequestError({ message: "Failed to create a safe attachment id.", @@ -413,6 +438,75 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< }), { concurrency: 1 }, ); + }); + + const normalizeProjectWorkspaceRoot = Effect.fnUntraced(function* (workspaceRoot: string) { + const normalizedWorkspaceRoot = path.resolve(yield* expandHomePath(workspaceRoot.trim())); + const workspaceStat = yield* fileSystem + .stat(normalizedWorkspaceRoot) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (!workspaceStat) { + return yield* new RouteRequestError({ + message: `Project directory does not exist: ${normalizedWorkspaceRoot}`, + }); + } + if (workspaceStat.type !== "Directory") { + return yield* new RouteRequestError({ + message: `Project path is not a directory: ${normalizedWorkspaceRoot}`, + }); + } + return normalizedWorkspaceRoot; + }); + + const normalizeDispatchCommand = Effect.fnUntraced(function* (input: { + readonly command: ClientOrchestrationCommand; + }) { + if (input.command.type === "project.create") { + return { + ...input.command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + } satisfies OrchestrationCommand; + } + + if (input.command.type === "project.meta.update" && input.command.workspaceRoot !== undefined) { + return { + ...input.command, + workspaceRoot: yield* normalizeProjectWorkspaceRoot(input.command.workspaceRoot), + } satisfies OrchestrationCommand; + } + + 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* normalizeClientAttachments( + turnStartCommand.threadId, + turnStartCommand.message.attachments, + ); return { ...turnStartCommand, @@ -611,12 +705,6 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const listenOptions = host ? { host, port } : { port }; - const orchestrationEngine = yield* OrchestrationEngineService; - const projectionReadModelQuery = yield* ProjectionSnapshotQuery; - const checkpointDiffQuery = yield* CheckpointDiffQuery; - const orchestrationReactor = yield* OrchestrationReactor; - const { openInEditor } = yield* Open; - const subscriptionsScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(subscriptionsScope, Exit.void)); diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 06cbf0efbd..6527c09df7 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,137 @@ 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.queued-follow-up.send-failed": { + const followUpId = typeof command.followUpId === "string" ? command.followUpId : null; + const lastSendError = + typeof command.lastSendError === "string" ? command.lastSendError : null; + if (followUpId && lastSendError) { + updateFixtureThread((thread) => ({ + ...thread, + queuedFollowUps: thread.queuedFollowUps.map((entry) => + entry.id === followUpId ? { ...entry, lastSendError } : 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 +692,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 +804,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 +2116,845 @@ 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("preserves inline terminal markers when steering a running follow-up", async () => { + useComposerDraftStore.getState().addTerminalContext( + THREAD_ID, + createTerminalContext({ + id: "ctx-inline-follow-up", + terminalLabel: "Terminal 2", + lineStart: 40, + lineEnd: 41, + text: "npm run lint\nok", + }), + ); + useComposerDraftStore + .getState() + .setPrompt(THREAD_ID, `Check ${INLINE_TERMINAL_CONTEXT_PLACEHOLDER} now`); + + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-inline-terminal" as MessageId, + targetText: "follow-up inline terminal target", + sessionStatus: "running", + }), + }); + + try { + const submitButton = await waitForComposerSubmitButton("Steer follow-up"); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getTurnStartRequests()).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + + const turnStart = getTurnStartRequests()[0] as + | (WsRequestEnvelope["body"] & { + command: { type: string; message?: { text?: string } }; + }) + | undefined; + expect(turnStart?.command.message?.text).toContain("Check @terminal-2:40-41 now"); + expect(turnStart?.command.message?.text).toContain(""); + } 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: "ready", + }), + }); + + try { + updateFixtureThread((thread) => ({ + ...thread, + queuedFollowUps: [ + { + id: "panel-first", + createdAt: isoAt(8_700), + prompt: "panel first", + attachments: [], + terminalContexts: [], + modelSelection: { + provider: "codex", + model: "gpt-5", + }, + runtimeMode: "full-access", + interactionMode: "default", + lastSendError: null, + }, + { + id: "panel-second", + createdAt: isoAt(8_800), + prompt: "panel second", + 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(getQueuedFollowUpPrompts()).toEqual(["panel second"]); + expect(document.body.textContent ?? "").toContain("panel first"); + const commandTypes = getDispatchCommandTypes(); + expect(commandTypes).toEqual( + expect.arrayContaining(["thread.queued-follow-up.remove", "thread.turn.start"]), + ); + expect(commandTypes.indexOf("thread.turn.start")).toBeLessThan( + commandTypes.indexOf("thread.queued-follow-up.remove"), + ); + }, + { 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("lets the server claim a queued head after steering interrupts the active run", async () => { + 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") { + updateFixtureThread((thread) => ({ + ...thread, + session: thread.session + ? { + ...thread.session, + status: "ready", + activeTurnId: null, + updatedAt: isoAt(9_998), + } + : null, + })); + 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(0); + const commandTypes = getDispatchCommandTypes(); + expect(commandTypes).toEqual(expect.arrayContaining(["thread.turn.interrupt"])); + expect(commandTypes).not.toContain("thread.turn.start"); + expect(commandTypes).not.toContain("thread.queued-follow-up.remove"); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("does not issue duplicate steer interrupts while the first interrupt request is in flight", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-steer-in-flight-guard" as MessageId, + targetText: "follow-up steer in-flight guard target", + sessionStatus: "running", + }), + resolveRpc: (body) => { + if (body._tag !== ORCHESTRATION_WS_METHODS.dispatchCommand) { + return undefined; + } + const command = (body as { command?: { type?: string } }).command; + if (!command || command.type !== "thread.turn.interrupt") { + return undefined; + } + return new Promise((resolve) => { + window.setTimeout(() => resolve({ sequence: 1 }), 100); + }); + }, + }); + + try { + await setComposerPrompt("steer only once"); + const submitButton = await waitForComposerSubmitButton("Steer follow-up"); + submitButton.click(); + submitButton.click(); + + await vi.waitFor( + () => { + expect(getInterruptRequests()).toHaveLength(1); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("marks a ready-state steered queued follow-up as failed when send 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.turn.start") { + throw new Error("steered send 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.update"]), + ); + expect(getQueuedFollowUpPrompts()).toEqual(["ready steer failure"]); + const restoredFollowUp = fixture.snapshot.threads + .find((thread) => thread.id === THREAD_ID) + ?.queuedFollowUps.find((followUp) => followUp.id === "queued-ready-steer-failure"); + expect(restoredFollowUp?.lastSendError).toBe("Failed to steer queued follow-up."); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps queued image attachments owned by the thread until ready-state steer send succeeds", async () => { + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: createSnapshotForTargetUser({ + targetMessageId: "msg-user-follow-up-panel-steer-image-order" as MessageId, + targetText: "follow-up panel steer image order target", + sessionStatus: "ready", + }), + }); + + try { + updateFixtureThread((thread) => ({ + ...thread, + queuedFollowUps: [ + { + id: "queued-ready-steer-image", + createdAt: isoAt(8_600), + prompt: "", + attachments: [ + { + type: "image", + id: "thread-1-att-steer-image", + name: "queued.png", + mimeType: "image/png", + sizeBytes: 128, + }, + ], + 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([ + "thread.turn.start", + "thread.queued-follow-up.remove", + ]); + expect(getQueuedFollowUpPrompts()).toEqual([]); + }, + { 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 fbd332354a..7167f44420 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -1,7 +1,7 @@ import { type ApprovalRequestId, + type ClientChatAttachment, DEFAULT_MODEL_BY_PROVIDER, - type ClaudeCodeEffort, type MessageId, type ModelSelection, type ProjectScript, @@ -21,7 +21,8 @@ import { ProviderInteractionMode, RuntimeMode, } from "@t3tools/contracts"; -import { applyClaudePromptEffortPrefix, normalizeModelSlug } from "@t3tools/shared/model"; +import { formatOutgoingPrompt, normalizeModelSlug } from "@t3tools/shared/model"; +import { IMAGE_ONLY_BOOTSTRAP_PROMPT } from "@t3tools/shared/orchestration"; import { truncate } from "@t3tools/shared/String"; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; @@ -75,7 +76,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 +90,8 @@ import PlanSidebar from "./PlanSidebar"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { BotIcon, + Clock3Icon, + CornerDownRightIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon, @@ -115,17 +120,14 @@ import { import { SidebarTrigger } from "./ui/sidebar"; import { newCommandId, newMessageId, newThreadId } from "~/lib/utils"; import { readNativeApi } from "~/nativeApi"; -import { - getProviderModelCapabilities, - getProviderModels, - resolveSelectableProvider, -} from "../providerModels"; +import { getProviderModels, resolveSelectableProvider } from "../providerModels"; import { useSettings } from "../hooks/useSettings"; import { resolveAppModelSelection } from "../modelSelection"; import { isTerminalFocused } from "../lib/terminalFocus"; import { type ComposerImageAttachment, type DraftThreadEnvMode, + hydrateComposerImagesFromPersistedAttachments, type PersistedComposerImageAttachment, useComposerDraftStore, useEffectiveComposerModelState, @@ -135,6 +137,7 @@ import { appendTerminalContextsToPrompt, formatTerminalContextLabel, insertInlineTerminalContextPlaceholder, + materializeSendableInlineTerminalContextPrompt, removeInlineTerminalContextPlaceholder, type TerminalContextDraft, type TerminalContextSelection, @@ -155,6 +158,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,45 +168,36 @@ import { ProviderStatusBanner } from "./chat/ProviderStatusBanner"; import { ThreadErrorBanner } from "./chat/ThreadErrorBanner"; import { buildExpiredTerminalContextToastCopy, + buildQueuedFollowUpDraft, buildLocalDraftThread, buildTemporaryWorktreeBranchName, + canAutoDispatchQueuedFollowUp, 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: { - provider: ProviderKind; - model: string | null; - models: ReadonlyArray; - effort: string | null; - text: string; -}): string { - const caps = getProviderModelCapabilities(params.models, params.model, params.provider); - if (params.effort && caps.promptInjectedEffortLevels.includes(params.effort)) { - return applyClaudePromptEffortPrefix(params.text, params.effort as ClaudeCodeEffort | null); - } - return params.text; -} const COMPOSER_PATH_QUERY_DEBOUNCE_MS = 120; const SCRIPT_TERMINAL_COLS = 120; const SCRIPT_TERMINAL_ROWS = 30; @@ -245,6 +240,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 +412,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 +487,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 +512,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 +582,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 +765,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 +852,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 +944,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 +1031,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), @@ -2446,8 +2585,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) { @@ -2471,7 +2610,7 @@ export default function ChatView({ threadId }: ChatViewProps) { planMarkdown: activeProposedPlan.planMarkdown, }); promptRef.current = ""; - clearComposerDraftContent(activeThread.id); + clearComposerDraftContent(activeThread.id, { revokeImagePreviewUrls: true }); setComposerHighlightedItemId(null); setComposerCursor(0); setComposerTrigger(null); @@ -2481,6 +2620,120 @@ 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), + ), + ); + const shouldGuardFollowUpSend = + effectiveBehavior === "queue" || effectiveBehavior === "steer"; + if (shouldGuardFollowUpSend) { + sendInFlightRef.current = true; + beginSendPhase("sending-turn"); + } + let followUpSnapshot: FollowUpSubmissionSnapshot | null; + try { + followUpSnapshot = await createFollowUpSnapshotFromComposer(createdAt); + } catch (err) { + if (shouldGuardFollowUpSend) { + sendInFlightRef.current = false; + resetSendPhase(); + } + setThreadError( + activeThread.id, + err instanceof Error ? err.message : "Failed to prepare follow-up.", + ); + return; + } + if (!followUpSnapshot) { + if (shouldGuardFollowUpSend) { + 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); + + const pendingSubmission: PendingSteerSubmission = { + threadId: activeThread.id, + snapshot: followUpSnapshot, + source: "composer", + }; + setPendingSteerSubmission(pendingSubmission); + const interrupted = await requestThreadInterrupt(activeThread.id); + sendInFlightRef.current = false; + resetSendPhase(); + if (!interrupted) { + setPendingSteerSubmission(null); + restoreFollowUpSnapshotToComposer(followUpSnapshot); + } else { + setPendingSteerSubmission(pendingSubmission); + } + return; + } const standaloneSlashCommand = composerImages.length === 0 && sendableComposerTerminalContexts.length === 0 ? parseStandaloneComposerSlashCommand(trimmed) @@ -2776,15 +3029,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( @@ -2934,6 +3206,386 @@ 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 { 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: materializeSendableInlineTerminalContextPrompt( + promptForSend, + composerTerminalContextsRef.current, + ).trim(), + 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; + titleSeed?: string; + 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, + ...(input.titleSeed ? { titleSeed: input.titleSeed } : {}), + 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 markQueuedFollowUpSteerFailed = useCallback( + async (pending: PendingSteerSubmission, errorMessage: string) => { + 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.update", + commandId: newCommandId(), + threadId: pending.threadId, + followUp: { + ...pending.snapshot, + id: pending.queuedFollowUpId, + attachments: pending.snapshot.attachments.map(toCommandAttachment), + lastSendError: errorMessage, + }, + createdAt: new Date().toISOString(), + }); + } catch (err) { + setThreadError( + pending.threadId, + err instanceof Error ? err.message : "Failed to persist queued follow-up error.", + ); + } + }, + [setThreadError], + ); + + const removeQueuedFollowUpAfterSteer = useCallback( + async (pending: PendingSteerSubmission, errorMessage: string) => { + if (pending.source !== "queued-follow-up" || !pending.queuedFollowUpId) { + return true; + } + const api = readNativeApi(); + if (!api) { + return false; + } + try { + await api.orchestration.dispatchCommand({ + type: "thread.queued-follow-up.remove", + commandId: newCommandId(), + threadId: pending.threadId, + followUpId: pending.queuedFollowUpId, + createdAt: new Date().toISOString(), + }); + return true; + } catch (err) { + await markQueuedFollowUpSteerFailed(pending, errorMessage); + setThreadError( + pending.threadId, + err instanceof Error ? err.message : "Failed to remove queued follow-up.", + ); + return false; + } + }, + [markQueuedFollowUpSteerFailed, setThreadError], + ); + + useEffect(() => { + const pendingSteerSubmission = pendingSteerSubmissionRef.current; + if (!pendingSteerSubmission || pendingSteerSubmission.threadId !== activeThread?.id) { + return; + } + if (pendingSteerSubmission.source === "queued-follow-up") { + const pendingQueuedFollowUpId = pendingSteerSubmission.queuedFollowUpId; + const queuedHead = queuedFollowUps[0]; + const queuedPendingFollowUp = queuedFollowUps.find( + (followUp) => followUp.id === pendingQueuedFollowUpId, + ); + if (!queuedPendingFollowUp) { + setPendingSteerSubmission(null); + return; + } + if ( + queuedHead && + queuedHead.id === pendingQueuedFollowUpId && + canAutoDispatchQueuedFollowUp({ + phase, + queuedFollowUpCount: queuedFollowUps.length, + queuedHeadHasError: queuedHead.lastSendError !== null, + isConnecting, + isSendBusy, + isRevertingCheckpoint, + hasThreadError: activeThread.error !== null, + hasPendingApproval: pendingApprovals.length > 0, + hasPendingUserInput: pendingUserInputs.length > 0, + }) + ) { + setPendingSteerSubmission(null); + return; + } + } + if ( + phase === "running" || + isSendBusy || + isConnecting || + isRevertingCheckpoint || + 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 markQueuedFollowUpSteerFailed( + pendingSteerSubmission, + "Failed to steer queued follow-up.", + ); + } else { + restoreFollowUpSnapshotToComposer(pendingSteerSubmission.snapshot); + } + return; + } + if (pendingSteerSubmission.source === "queued-follow-up") { + await removeQueuedFollowUpAfterSteer( + pendingSteerSubmission, + "Queued follow-up was sent but queue cleanup failed.", + ); + } + })(); + }, [ + activeThread?.id, + dispatchServerThreadSnapshot, + isComposerApprovalState, + isConnecting, + isSendBusy, + isRevertingCheckpoint, + markQueuedFollowUpSteerFailed, + pendingSteerSubmissionVersion, + pendingApprovals.length, + pendingUserInputs.length, + phase, + queuedFollowUps, + removeQueuedFollowUpAfterSteer, + restoreFollowUpSnapshotToComposer, + setPendingSteerSubmission, + activeThread?.error, + ]); + const onSubmitPlanFollowUp = useCallback( async ({ text, @@ -2959,108 +3611,240 @@ 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.", + titleSeed: activeThread.title, + ...(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, - titleSeed: activeThread.title, - 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); + } else { + setPendingSteerSubmission(pendingSubmission); + } + return; + } + const dispatched = await dispatchServerThreadSnapshot({ + threadId: activeThread.id, + snapshot: followUp, + errorMessage: "Failed to steer queued follow-up.", + suppressOptimisticMessage: false, + hideServerMessage: false, + }); + if (!dispatched) { + await markQueuedFollowUpSteerFailed(pendingSubmission, "Failed to steer queued follow-up."); + return; + } + await removeQueuedFollowUpAfterSteer( + pendingSubmission, + "Queued follow-up was sent but queue cleanup failed.", + ); + }, + [ + activeThread, + dispatchServerThreadSnapshot, isConnecting, isSendBusy, isServerThread, - persistThreadSettingsForNextTurn, - resetSendPhase, - runtimeMode, - selectedPromptEffort, - selectedModelSelection, - selectedProvider, - selectedProviderModels, - setComposerDraftInteractionMode, - setThreadError, - selectedModel, + markQueuedFollowUpSteerFailed, + phase, + queuedFollowUps, + removeQueuedFollowUpAfterSteer, + requestThreadInterrupt, + setPendingSteerSubmission, ], ); @@ -3504,8 +4288,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; @@ -3683,9 +4467,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 ( +