diff --git a/apps/server/src/attachmentStore.test.ts b/apps/server/src/attachmentStore.test.ts index e21d9cf62cf..ba9f72321ad 100644 --- a/apps/server/src/attachmentStore.test.ts +++ b/apps/server/src/attachmentStore.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vite-plus/test"; import { createAttachmentId, + createForkedAttachmentId, parseThreadSegmentFromAttachmentId, resolveAttachmentPathById, } from "./attachmentStore.ts"; @@ -44,6 +45,13 @@ describe("attachmentStore", () => { expect(parseThreadSegmentFromAttachmentId(attachmentId)).toBe("thread-foo"); }); + it("rebinds a valid attachment uuid to the fork thread", () => { + expect( + createForkedAttachmentId("Thread Fork", "thread-source-00000000-0000-4000-8000-000000000001"), + ).toBe("thread-fork-00000000-0000-4000-8000-000000000001"); + expect(createForkedAttachmentId("thread-fork", "legacy-attachment")).toBeNull(); + }); + it("resolves attachment path by id using the extension that exists on disk", () => { const attachmentsDir = NodeFS.mkdtempSync( NodePath.join(NodeOS.tmpdir(), "t3code-attachment-store-"), diff --git a/apps/server/src/attachmentStore.ts b/apps/server/src/attachmentStore.ts index 3d5b531db21..7d220f967e0 100644 --- a/apps/server/src/attachmentStore.ts +++ b/apps/server/src/attachmentStore.ts @@ -42,6 +42,23 @@ export function createAttachmentId(threadId: string): string | null { return `${threadSegment}-${NodeCrypto.randomUUID()}`; } +export function createForkedAttachmentId( + threadId: string, + sourceAttachmentId: string, +): string | null { + const threadSegment = toSafeThreadAttachmentSegment(threadId); + if (!threadSegment) { + return null; + } + + const normalizedSourceId = normalizeAttachmentRelativePath(sourceAttachmentId); + const sourceUuid = normalizedSourceId?.match(ATTACHMENT_ID_PATTERN)?.[2]?.toLowerCase(); + if (!sourceUuid) { + return null; + } + return `${threadSegment}-${sourceUuid}`; +} + export function parseThreadSegmentFromAttachmentId(attachmentId: string): string | null { const normalizedId = normalizeAttachmentRelativePath(attachmentId); if (!normalizedId || normalizedId.includes("/") || normalizedId.includes(".")) { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 926182a3ef0..1938375e534 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -660,6 +660,200 @@ it.layer( ); }); +it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-fork-ownership-")))( + "OrchestrationProjectionPipeline", + (it) => { + it.effect("copies inherited attachments and preserves fork-owned history on revert", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sql = yield* SqlClient.SqlClient; + const { attachmentsDir } = yield* ServerConfig; + const now = "2026-01-01T00:00:00.000Z"; + const sourceThreadId = ThreadId.make("thread-source"); + const forkThreadId = ThreadId.make("thread-fork"); + const sourceAttachmentId = "thread-source-00000000-0000-4000-8000-000000000001"; + const forkAttachmentId = "thread-fork-00000000-0000-4000-8000-000000000001"; + const sourceAttachmentPath = path.join(attachmentsDir, `${sourceAttachmentId}.png`); + const forkAttachmentPath = path.join(attachmentsDir, `${forkAttachmentId}.png`); + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.make("evt-fork-owner-1"), + aggregateKind: "project", + aggregateId: ProjectId.make("project-fork-owner"), + occurredAt: now, + commandId: CommandId.make("cmd-fork-owner-1"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-fork-owner-1"), + metadata: {}, + payload: { + projectId: ProjectId.make("project-fork-owner"), + title: "Fork ownership", + workspaceRoot: "/tmp/project-fork-owner", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.make("evt-fork-owner-2"), + aggregateKind: "thread", + aggregateId: sourceThreadId, + occurredAt: now, + commandId: CommandId.make("cmd-fork-owner-2"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-fork-owner-2"), + metadata: {}, + payload: { + threadId: sourceThreadId, + projectId: ProjectId.make("project-fork-owner"), + title: "Source", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + + const savedForkEvent = yield* eventStore.append({ + type: "thread.forked", + eventId: EventId.make("evt-fork-owner-3"), + aggregateKind: "thread", + aggregateId: forkThreadId, + occurredAt: now, + commandId: CommandId.make("cmd-fork-owner-3"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-fork-owner-3"), + metadata: {}, + payload: { + threadId: forkThreadId, + projectId: ProjectId.make("project-fork-owner"), + title: "Fork", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + forkedFrom: { + threadId: sourceThreadId, + turnId: TurnId.make("source-turn"), + }, + inheritedMessages: [ + { + id: MessageId.make("thread-fork:fork:0"), + role: "user", + text: "inherited", + attachments: [ + { + type: "image", + id: forkAttachmentId, + name: "question.png", + mimeType: "image/png", + sizeBytes: 16, + }, + ], + turnId: TurnId.make("thread-fork:fork-turn:0"), + streaming: false, + createdAt: now, + updatedAt: now, + }, + ], + createdAt: now, + updatedAt: now, + }, + }); + + const firstProjection = yield* Effect.result( + projectionPipeline.projectEvent(savedForkEvent), + ); + assert.equal(firstProjection._tag, "Failure"); + const rolledBackForkRows = yield* sql<{ readonly count: number }>` + SELECT COUNT(*) AS "count" + FROM projection_thread_messages + WHERE thread_id = ${forkThreadId} + `; + assert.equal(rolledBackForkRows[0]?.count ?? 0, 0); + const rolledBackProjectionState = yield* sql<{ + readonly lastAppliedSequence: number; + }>` + SELECT last_applied_sequence AS "lastAppliedSequence" + FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threadMessages} + `; + assert.equal(rolledBackProjectionState[0]?.lastAppliedSequence, 2); + + yield* fileSystem.writeFileString(sourceAttachmentPath, "fork-owned-image"); + yield* projectionPipeline.projectEvent(savedForkEvent); + assert.equal(yield* fileSystem.readFileString(forkAttachmentPath), "fork-owned-image"); + + yield* appendAndProject({ + type: "thread.reverted", + eventId: EventId.make("evt-fork-owner-4"), + aggregateKind: "thread", + aggregateId: forkThreadId, + occurredAt: now, + commandId: CommandId.make("cmd-fork-owner-4"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-fork-owner-4"), + metadata: {}, + payload: { + threadId: forkThreadId, + turnCount: 0, + }, + }); + const forkRows = yield* sql<{ readonly messageId: string }>` + SELECT message_id AS "messageId" + FROM projection_thread_messages + WHERE thread_id = ${forkThreadId} + `; + assert.deepEqual( + forkRows.map((row) => row.messageId), + ["thread-fork:fork:0"], + ); + + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.make("evt-fork-owner-5"), + aggregateKind: "thread", + aggregateId: sourceThreadId, + occurredAt: now, + commandId: CommandId.make("cmd-fork-owner-5"), + causationEventId: null, + correlationId: CorrelationId.make("cmd-fork-owner-5"), + metadata: {}, + payload: { + threadId: sourceThreadId, + deletedAt: now, + }, + }); + + assert.isFalse(yield* exists(sourceAttachmentPath)); + assert.equal(yield* fileSystem.readFileString(forkAttachmentPath), "fork-owned-image"); + }), + ); + }, +); + it.layer( Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-attachments-rollback-")), )("OrchestrationProjectionPipeline", (it) => { diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200..d35dd28b26d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -13,7 +13,11 @@ import * as Path from "effect/Path"; import * as Stream from "effect/Stream"; import * as SqlClient from "effect/unstable/sql/SqlClient"; -import { toPersistenceSqlError, type ProjectionRepositoryError } from "../../persistence/Errors.ts"; +import { + ProjectionAttachmentMaterializationError, + toPersistenceSqlError, + type ProjectionRepositoryError, +} from "../../persistence/Errors.ts"; import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts"; import { ProjectionPendingApprovalRepository } from "../../persistence/Services/ProjectionPendingApprovals.ts"; import { ProjectionProjectRepository } from "../../persistence/Services/ProjectionProjects.ts"; @@ -50,6 +54,7 @@ import { } from "../Services/ProjectionPipeline.ts"; import { attachmentRelativePath, + createForkedAttachmentId, parseAttachmentIdFromRelativePath, parseThreadSegmentFromAttachmentId, toSafeThreadAttachmentSegment, @@ -102,6 +107,7 @@ interface ProjectorDefinition { } interface AttachmentSideEffects { + readonly copiedAttachmentRelativePaths: Map; readonly deletedThreadIds: Set; readonly prunedThreadRelativePaths: Map>; } @@ -229,7 +235,7 @@ function retainProjectionMessagesAfterRevert( } for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || message.messageId.startsWith(`${message.threadId}:fork:`)) { retainedMessageIds.add(message.messageId); continue; } @@ -351,6 +357,46 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } +const materializeForkedAttachments = Effect.fn("materializeForkedAttachments")(function* ( + sideEffects: AttachmentSideEffects, +) { + const serverConfig = yield* Effect.service(ServerConfig); + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + + const attachmentsRootDir = serverConfig.attachmentsDir; + + yield* Effect.forEach( + sideEffects.copiedAttachmentRelativePaths.entries(), + ([destinationRelativePath, sourceRelativePath]) => + destinationRelativePath === sourceRelativePath + ? Effect.void + : Effect.gen(function* () { + const destinationPath = path.join(attachmentsRootDir, destinationRelativePath); + if (yield* fileSystem.exists(destinationPath)) { + return; + } + const temporaryDestinationPath = `${destinationPath}.fork-copy.tmp`; + yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true }); + yield* fileSystem.copyFile( + path.join(attachmentsRootDir, sourceRelativePath), + temporaryDestinationPath, + ); + yield* fileSystem.rename(temporaryDestinationPath, destinationPath); + }).pipe( + Effect.mapError( + (cause) => + new ProjectionAttachmentMaterializationError({ + operation: "ProjectionPipeline.materializeForkedAttachments", + detail: `Could not copy '${sourceRelativePath}' to '${destinationRelativePath}'.`, + cause, + }), + ), + ), + { concurrency: 1 }, + ); +}); + const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( sideEffects: AttachmentSideEffects, ) { @@ -603,6 +649,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + forkedFromThreadId: null, + forkedFromTurnId: null, latestTurnId: null, createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, @@ -619,6 +667,37 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti }); return; + case "thread.forked": + yield* projectionThreadRepository.upsert({ + threadId: event.payload.threadId, + projectId: event.payload.projectId, + title: event.payload.title, + modelSelection: event.payload.modelSelection, + runtimeMode: event.payload.runtimeMode, + interactionMode: event.payload.interactionMode, + branch: event.payload.branch, + worktreePath: event.payload.worktreePath, + forkedFromThreadId: event.payload.forkedFrom.threadId, + forkedFromTurnId: event.payload.forkedFrom.turnId, + latestTurnId: null, + createdAt: event.payload.createdAt, + updatedAt: event.payload.updatedAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + snoozedUntil: null, + snoozedAt: null, + latestUserMessageAt: + event.payload.inheritedMessages + .toReversed() + .find((message) => message.role === "user")?.createdAt ?? null, + pendingApprovalCount: 0, + pendingUserInputCount: 0, + hasActionableProposedPlan: 0, + deletedAt: null, + }); + return; + case "thread.archived": { const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, @@ -879,6 +958,43 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadMessagesProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { + case "thread.forked": + yield* Effect.forEach( + event.payload.inheritedMessages, + (message) => { + const attachments = message.attachments?.map((attachment) => { + const sourceAttachmentId = createForkedAttachmentId( + event.payload.forkedFrom.threadId, + attachment.id, + ); + if (sourceAttachmentId !== null) { + const sourceAttachment = { + ...attachment, + id: sourceAttachmentId, + }; + attachmentSideEffects.copiedAttachmentRelativePaths.set( + attachmentRelativePath(attachment), + attachmentRelativePath(sourceAttachment), + ); + } + return attachment; + }); + return projectionThreadMessageRepository.upsert({ + messageId: message.id, + threadId: event.payload.threadId, + turnId: message.turnId, + role: message.role, + text: message.text, + ...(attachments !== undefined ? { attachments } : {}), + isStreaming: message.streaming, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }); + }, + { concurrency: 1, discard: true }, + ); + return; + case "thread.message-sent": { const existingMessage = yield* projectionThreadMessageRepository.getByMessageId({ messageId: event.payload.messageId, @@ -1580,12 +1696,14 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti event: OrchestrationEvent, ) { const attachmentSideEffects: AttachmentSideEffects = { + copiedAttachmentRelativePaths: new Map(), deletedThreadIds: new Set(), prunedThreadRelativePaths: new Map>(), }; yield* sql.withTransaction( projector.apply(event, attachmentSideEffects).pipe( + Effect.flatMap(() => materializeForkedAttachments(attachmentSideEffects)), Effect.flatMap(() => projectionStateRepository.upsert({ projector: projector.name, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..387adac7192 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -293,6 +293,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + forkedFrom: null, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -407,6 +408,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + forkedFrom: null, latestTurn: { turnId: asTurnId("turn-1"), state: "completed", @@ -447,6 +449,120 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { if (threadDetail._tag === "Some") { assert.deepEqual(threadDetail.value, snapshot.threads[0]); } + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + assert.deepEqual(commandReadModel.threads[0]?.messages, snapshot.threads[0]?.messages); + }), + ); + + it.effect("hydrates command history beyond the shell snapshot message cap", () => + Effect.gen(function* () { + const snapshotQuery = yield* ProjectionSnapshotQuery; + const sql = yield* SqlClient.SqlClient; + + yield* sql`DELETE FROM projection_thread_messages`; + yield* sql`DELETE FROM projection_threads`; + yield* sql`DELETE FROM projection_projects`; + + yield* sql` + INSERT INTO projection_projects ( + project_id, + title, + workspace_root, + default_model_selection_json, + scripts_json, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'project-command-history', + 'Command History', + '/tmp/project-command-history', + '{"provider":"codex","model":"gpt-5-codex"}', + '[]', + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at, + deleted_at + ) + VALUES ( + 'thread-command-history', + 'project-command-history', + 'Command History', + '{"provider":"codex","model":"gpt-5-codex"}', + 'full-access', + 'default', + NULL, + NULL, + 'history-turn-2001', + NULL, + 0, + 0, + 0, + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z', + NULL + ) + `; + + yield* sql` + WITH RECURSIVE sequence(value) AS ( + SELECT 1 + UNION ALL + SELECT value + 1 FROM sequence WHERE value < 2001 + ) + INSERT INTO projection_thread_messages ( + message_id, + thread_id, + turn_id, + role, + text, + is_streaming, + created_at, + updated_at + ) + SELECT + printf('history-message-%04d', value), + 'thread-command-history', + printf('history-turn-%04d', value), + 'assistant', + printf('history message %d', value), + 0, + '2026-02-24T00:00:00.000Z', + '2026-02-24T00:00:00.000Z' + FROM sequence + `; + + const commandReadModel = yield* snapshotQuery.getCommandReadModel(); + const thread = commandReadModel.threads.find( + (candidate) => candidate.id === ThreadId.make("thread-command-history"), + ); + + assert.isDefined(thread); + assert.lengthOf(thread.messages, 2001); + assert.equal(thread.messages[0]?.id, asMessageId("history-message-0001")); + assert.equal(thread.messages[2000]?.id, asMessageId("history-message-2001")); }), ); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..aa0621776bd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -224,6 +224,15 @@ function mapSessionRow( }; } +function mapForkedFrom(row: Schema.Schema.Type) { + return row.forkedFromThreadId == null + ? null + : { + threadId: row.forkedFromThreadId, + turnId: row.forkedFromTurnId ?? null, + }; +} + function mapProjectShellRow( row: Schema.Schema.Type, repositoryIdentity: OrchestrationProject["repositoryIdentity"], @@ -330,6 +339,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + forked_from_thread_id AS "forkedFromThreadId", + forked_from_turn_id AS "forkedFromTurnId", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -362,6 +373,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + forked_from_thread_id AS "forkedFromThreadId", + forked_from_turn_id AS "forkedFromTurnId", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -396,6 +409,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + forked_from_thread_id AS "forkedFromThreadId", + forked_from_turn_id AS "forkedFromTurnId", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -762,6 +777,8 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + forked_from_thread_id AS "forkedFromThreadId", + forked_from_turn_id AS "forkedFromTurnId", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -1198,6 +1215,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + forkedFrom: mapForkedFrom(row), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1256,6 +1274,14 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ), ), ), + listThreadMessageRows(undefined).pipe( + Effect.mapError( + toPersistenceSqlOrDecodeError( + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:query", + "ProjectionSnapshotQuery.getCommandReadModel:listThreadMessages:decodeRows", + ), + ), + ), listThreadProposedPlanRows(undefined).pipe( Effect.mapError( toPersistenceSqlOrDecodeError( @@ -1292,11 +1318,20 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ) .pipe( Effect.flatMap( - ([projectRows, threadRows, proposedPlanRows, sessionRows, latestTurnRows, stateRows]) => + ([ + projectRows, + threadRows, + messageRows, + proposedPlanRows, + sessionRows, + latestTurnRows, + stateRows, + ]) => Effect.sync(() => { let updatedAt: string | null = null; const projects: OrchestrationProject[] = []; const threads: OrchestrationThread[] = []; + const messagesByThread = new Map>(); for (let index = 0; index < projectRows.length; index += 1) { const row = projectRows[index]; @@ -1329,6 +1364,25 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { } updatedAt = maxIso(updatedAt, row.updatedAt); } + for (let index = 0; index < messageRows.length; index += 1) { + const row = messageRows[index]; + if (!row) { + continue; + } + updatedAt = maxIso(updatedAt, row.updatedAt); + const messages = messagesByThread.get(row.threadId) ?? []; + messages.push({ + id: row.messageId, + role: row.role, + text: row.text, + ...(row.attachments !== null ? { attachments: row.attachments } : {}), + turnId: row.turnId, + streaming: row.isStreaming === 1, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }); + messagesByThread.set(row.threadId, messages); + } for (let index = 0; index < sessionRows.length; index += 1) { const row = sessionRows[index]; if (!row) { @@ -1400,6 +1454,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + forkedFrom: mapForkedFrom(row), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1409,7 +1464,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { snoozedUntil: row.snoozedUntil, snoozedAt: row.snoozedAt, deletedAt: row.deletedAt, - messages: [], + messages: messagesByThread.get(row.threadId) ?? [], proposedPlans: proposedPlansByThread.get(row.threadId) ?? [], activities: [], checkpoints: [], @@ -1533,6 +1588,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + forkedFrom: mapForkedFrom(row), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1671,6 +1727,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + forkedFrom: mapForkedFrom(row), latestTurn: latestTurnByThread.get(row.threadId) ?? null, createdAt: row.createdAt, updatedAt: row.updatedAt, @@ -1915,6 +1972,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + forkedFrom: mapForkedFrom(threadRow.value), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, @@ -2013,6 +2071,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + forkedFrom: mapForkedFrom(threadRow.value), latestTurn: Option.isSome(latestTurnRow) ? mapLatestTurn(latestTurnRow.value) : null, createdAt: threadRow.value.createdAt, updatedAt: threadRow.value.updatedAt, diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c49646b7a4b..20abd67912e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -49,6 +49,7 @@ import { OrchestrationEngineLive } from "./OrchestrationEngine.ts"; import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts"; import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts"; import { + findCompletedTurnIndex, providerErrorLabel, providerErrorLabelFromInstanceHint, ProviderCommandReactorLive, @@ -67,6 +68,24 @@ const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalReques const asMessageId = (value: string): MessageId => MessageId.make(value); const asTurnId = (value: string): TurnId => TurnId.make(value); +describe("findCompletedTurnIndex", () => { + it("maps a selected T3 turn to its provider response position", () => { + const first = asTurnId("turn-1"); + const second = asTurnId("turn-2"); + expect( + findCompletedTurnIndex( + [ + { role: "assistant", turnId: first, streaming: false }, + { role: "assistant", turnId: first, streaming: false }, + { role: "assistant", turnId: second, streaming: true }, + { role: "assistant", turnId: second, streaming: false }, + ], + second, + ), + ).toBe(1); + }); +}); + const deriveServerPathsSync = (baseDir: string, devUrl: URL | undefined) => Effect.runSync(deriveServerPaths(baseDir, devUrl).pipe(Effect.provide(NodeServices.layer))); @@ -441,7 +460,7 @@ describe("ProviderCommandReactor", () => { const harness = await createHarness(); const now = "2026-01-01T00:00:00.000Z"; - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-turn-start-1"), @@ -477,6 +496,186 @@ describe("ProviderCommandReactor", () => { expect(thread?.session?.runtimeMode).toBe("approval-required"); }); + it("hydrates persisted source history and passes the completed turn position to native forks", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const sourceTurnId = asTurnId("source-turn-1"); + const forkThreadId = ThreadId.make("thread-fork"); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-source-assistant-complete"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("source-assistant-1"), + turnId: sourceTurnId, + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.fork", + commandId: CommandId.make("cmd-thread-fork"), + threadId: forkThreadId, + sourceThreadId: ThreadId.make("thread-1"), + sourceTurnId, + title: "Forked thread", + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-fork-turn-start"), + threadId: forkThreadId, + message: { + messageId: asMessageId("fork-user-message"), + role: "user", + text: "continue here", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + forkFrom: { + threadId: ThreadId.make("thread-1"), + sourceTurnId, + sourceTurnIndex: 0, + }, + }); + }); + + it("resolves inherited fork history to the provider-owning ancestor", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const sourceThreadId = ThreadId.make("thread-1"); + const sourceTurnId = asTurnId("source-turn-nested"); + const parentForkThreadId = ThreadId.make("thread-parent-fork"); + const nestedForkThreadId = ThreadId.make("thread-nested-fork"); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-nested-source-complete"), + threadId: sourceThreadId, + messageId: asMessageId("nested-source-assistant"), + turnId: sourceTurnId, + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.fork", + commandId: CommandId.make("cmd-parent-fork"), + threadId: parentForkThreadId, + sourceThreadId, + sourceTurnId, + title: "Parent fork", + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.fork", + commandId: CommandId.make("cmd-nested-fork"), + threadId: nestedForkThreadId, + sourceThreadId: parentForkThreadId, + sourceTurnId, + title: "Nested fork", + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-nested-fork-turn-start"), + threadId: nestedForkThreadId, + message: { + messageId: asMessageId("nested-fork-user-message"), + role: "user", + text: "continue from inherited history", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await waitFor(() => harness.startSession.mock.calls.length === 1); + expect(harness.startSession.mock.calls[0]?.[1]).toMatchObject({ + forkFrom: { + threadId: sourceThreadId, + sourceTurnId, + sourceTurnIndex: 0, + }, + }); + }); + + it("does not fall back to the latest provider response when the fork source is unavailable", async () => { + const harness = await createHarness(); + const now = "2026-01-01T00:00:00.000Z"; + const sourceTurnId = asTurnId("source-turn-1"); + const forkThreadId = ThreadId.make("thread-fork-missing-source"); + + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.make("cmd-missing-source-assistant-complete"), + threadId: ThreadId.make("thread-1"), + messageId: asMessageId("missing-source-assistant"), + turnId: sourceTurnId, + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.fork", + commandId: CommandId.make("cmd-missing-source-fork"), + threadId: forkThreadId, + sourceThreadId: ThreadId.make("thread-1"), + sourceTurnId, + title: "Forked thread", + createdAt: now, + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.delete", + commandId: CommandId.make("cmd-delete-fork-source"), + threadId: ThreadId.make("thread-1"), + }), + ); + await runtime!.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.make("cmd-missing-source-turn-start"), + threadId: forkThreadId, + message: { + messageId: asMessageId("missing-source-user-message"), + role: "user", + text: "continue here", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt: now, + }), + ); + + await harness.drain(); + expect(harness.startSession).not.toHaveBeenCalled(); + const readModel = await harness.readModel(); + const fork = readModel.threads.find((thread) => thread.id === forkThreadId); + expect(fork?.session?.status).toBe("error"); + expect(fork?.session?.lastError).toContain("source thread 'thread-1' is unavailable"); + }); + effectIt.effect("projects starting before a slow provider session finishes", () => Effect.gen(function* () { const releaseStart = yield* Deferred.make(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b6bff8c766a..82281a83c57 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -88,6 +88,32 @@ const HANDLED_TURN_START_KEY_TTL = Duration.minutes(30); const DEFAULT_RUNTIME_MODE: RuntimeMode = "full-access"; const DEFAULT_THREAD_TITLE = "New thread"; +export function findCompletedTurnIndex( + messages: ReadonlyArray<{ + readonly role: string; + readonly turnId: TurnId | null; + readonly streaming: boolean; + }>, + sourceTurnId: TurnId, +): number | undefined { + const completedTurnIds: TurnId[] = []; + for (const message of messages) { + if ( + message.role !== "assistant" || + message.turnId === null || + message.streaming || + completedTurnIds.some((turnId) => turnId === message.turnId) + ) { + continue; + } + completedTurnIds.push(message.turnId); + if (message.turnId === sourceTurnId) { + return completedTurnIds.length - 1; + } + } + return undefined; +} + export function providerErrorLabel(value: string | undefined): string { const normalized = value?.trim(); return normalized && normalized.length > 0 ? normalized : "unknown"; @@ -493,19 +519,91 @@ const make = Effect.gen(function* () { projects: project ? [project] : [], }); - const startProviderSession = (input?: { + const startProviderSession = Effect.fn("startProviderSession")(function* (input?: { readonly resumeCursor?: unknown; readonly provider?: ProviderDriverKind; - }) => - providerService.startSession(threadId, { + readonly includeForkSource?: boolean; + }) { + const forkedFrom = input?.includeForkSource === true ? thread.forkedFrom : null; + const forkSource = + forkedFrom != null + ? yield* Effect.gen(function* () { + const sourceTurnId = forkedFrom.turnId; + let sourceThreadId = forkedFrom.threadId; + let sourceThread = yield* resolveThread(sourceThreadId); + const visitedThreadIds = new Set(); + + if (sourceTurnId !== null) { + // An inherited message still belongs to the provider session that + // originally produced it. Walk fork-owned message ids back to that + // ancestor so nested forks use its durable binding and native turn id. + while (true) { + if (sourceThread === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: desiredInfo.driverKind, + method: "thread.turn.start", + detail: `Cannot fork from turn '${sourceTurnId}' because source thread '${sourceThreadId}' is unavailable.`, + }); + } + if (visitedThreadIds.has(sourceThread.id)) { + return yield* new ProviderAdapterRequestError({ + provider: desiredInfo.driverKind, + method: "thread.turn.start", + detail: `Cannot resolve fork lineage for turn '${sourceTurnId}' because it contains a cycle at thread '${sourceThread.id}'.`, + }); + } + visitedThreadIds.add(sourceThread.id); + + const sourceAssistantMessage = sourceThread.messages.find( + (message) => + message.role === "assistant" && + message.turnId === sourceTurnId && + !message.streaming, + ); + if (sourceAssistantMessage === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: desiredInfo.driverKind, + method: "thread.turn.start", + detail: `Cannot fork from turn '${sourceTurnId}' because its completed response was not found in source thread '${sourceThread.id}'.`, + }); + } + + const parentFork = sourceThread.forkedFrom; + if ( + !sourceAssistantMessage.id.startsWith(`${sourceThread.id}:fork:`) || + parentFork == null + ) { + break; + } + + sourceThreadId = parentFork.threadId; + sourceThread = yield* resolveThread(sourceThreadId); + } + } + + const sourceTurnIndex = + sourceThread !== undefined && sourceTurnId !== null + ? findCompletedTurnIndex(sourceThread.messages, sourceTurnId) + : undefined; + return { + threadId: sourceThreadId, + ...(sourceTurnId !== null ? { sourceTurnId } : {}), + ...(sourceTurnIndex !== undefined ? { sourceTurnIndex } : {}), + }; + }) + : undefined; + + return yield* providerService.startSession(threadId, { threadId, ...(preferredProvider ? { provider: preferredProvider } : {}), providerInstanceId: desiredInstanceId, ...(effectiveCwd ? { cwd: effectiveCwd } : {}), modelSelection: desiredModelSelection, ...(input?.resumeCursor !== undefined ? { resumeCursor: input.resumeCursor } : {}), + ...(forkSource !== undefined ? { forkFrom: forkSource } : {}), runtimeMode: desiredRuntimeMode, }); + }); const bindSessionToThread = (session: ProviderSession) => Effect.gen(function* () { @@ -603,7 +701,7 @@ const make = Effect.gen(function* () { return restartedSession.threadId; } - const startedSession = yield* startProviderSession(undefined); + const startedSession = yield* startProviderSession({ includeForkSource: true }); yield* bindSessionToThread(startedSession); return startedSession.threadId; }); diff --git a/apps/server/src/orchestration/Schemas.ts b/apps/server/src/orchestration/Schemas.ts index 3b558d24739..01c8d952bf0 100644 --- a/apps/server/src/orchestration/Schemas.ts +++ b/apps/server/src/orchestration/Schemas.ts @@ -3,6 +3,7 @@ import { ProjectMetaUpdatedPayload as ContractsProjectMetaUpdatedPayloadSchema, ProjectDeletedPayload as ContractsProjectDeletedPayloadSchema, ThreadCreatedPayload as ContractsThreadCreatedPayloadSchema, + ThreadForkedPayload as ContractsThreadForkedPayloadSchema, ThreadArchivedPayload as ContractsThreadArchivedPayloadSchema, ThreadSettledPayload as ContractsThreadSettledPayloadSchema, ThreadMetaUpdatedPayload as ContractsThreadMetaUpdatedPayloadSchema, @@ -32,6 +33,7 @@ export const ProjectMetaUpdatedPayload = ContractsProjectMetaUpdatedPayloadSchem export const ProjectDeletedPayload = ContractsProjectDeletedPayloadSchema; export const ThreadCreatedPayload = ContractsThreadCreatedPayloadSchema; +export const ThreadForkedPayload = ContractsThreadForkedPayloadSchema; export const ThreadArchivedPayload = ContractsThreadArchivedPayloadSchema; export const ThreadSettledPayload = ContractsThreadSettledPayloadSchema; export const ThreadMetaUpdatedPayload = ContractsThreadMetaUpdatedPayloadSchema; diff --git a/apps/server/src/orchestration/decider.fork.test.ts b/apps/server/src/orchestration/decider.fork.test.ts new file mode 100644 index 00000000000..d109279ea27 --- /dev/null +++ b/apps/server/src/orchestration/decider.fork.test.ts @@ -0,0 +1,348 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + MessageId, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const now = "2026-07-21T12:00:00.000Z"; +const sourceThreadId = ThreadId.make("thread-source"); +const forkThreadId = ThreadId.make("thread-fork"); +const turnOneId = TurnId.make("turn-1"); +const turnTwoId = TurnId.make("turn-2"); + +const seedReadModel = (): OrchestrationReadModel => ({ + ...createEmptyReadModel(now), + threads: [ + { + id: sourceThreadId, + projectId: ProjectId.make("project-1"), + title: "Source thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + branch: "main", + worktreePath: "/tmp/project", + forkedFrom: null, + latestTurn: { + turnId: turnTwoId, + state: "completed", + requestedAt: now, + startedAt: now, + completedAt: now, + assistantMessageId: MessageId.make("assistant-2"), + }, + createdAt: now, + updatedAt: now, + archivedAt: null, + settledOverride: null, + settledAt: null, + deletedAt: null, + messages: [ + { + id: MessageId.make("user-1"), + role: "user", + text: "First question", + attachments: [ + { + type: "image", + id: "thread-source-00000000-0000-4000-8000-000000000001", + name: "question.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: turnOneId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + { + id: MessageId.make("assistant-1"), + role: "assistant", + text: "First answer", + attachments: [], + turnId: turnOneId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + { + id: MessageId.make("user-2"), + role: "user", + text: "Second question", + attachments: [], + turnId: turnTwoId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + { + id: MessageId.make("assistant-2"), + role: "assistant", + text: "Second answer", + attachments: [], + turnId: turnTwoId, + streaming: false, + createdAt: now, + updatedAt: now, + }, + { + id: MessageId.make("assistant-streaming"), + role: "assistant", + text: "Unsettled answer", + attachments: [], + turnId: TurnId.make("turn-3"), + streaming: true, + createdAt: now, + updatedAt: now, + }, + ], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], +}); + +const forkCommand = (sourceTurnId: TurnId) => ({ + type: "thread.fork" as const, + commandId: CommandId.make("command-fork"), + threadId: forkThreadId, + sourceThreadId, + sourceTurnId, + title: "Forked thread", + createdAt: now, +}); + +type PlannedForkEvent = Omit< + Extract, + "sequence" +>; + +function requireForkEvent(result: unknown): PlannedForkEvent { + if ( + result === null || + typeof result !== "object" || + Array.isArray(result) || + !("type" in result) || + result.type !== "thread.forked" + ) { + throw new Error("Expected one thread.forked event."); + } + return result as PlannedForkEvent; +} + +it.layer(NodeServices.layer)("thread fork decider", (it) => { + it.effect("copies history through the requested turn and persists lineage", () => + Effect.gen(function* () { + const event = requireForkEvent( + yield* decideOrchestrationCommand({ + command: forkCommand(turnOneId), + readModel: seedReadModel(), + }), + ); + + expect(event.payload.forkedFrom).toEqual({ + threadId: sourceThreadId, + turnId: turnOneId, + }); + expect(event.payload.inheritedMessages.map((message) => message.text)).toEqual([ + "First question", + "First answer", + ]); + expect(event.payload.inheritedMessages.map((message) => message.turnId)).toEqual([ + turnOneId, + turnOneId, + ]); + expect(event.payload.inheritedMessages[0]?.attachments).toEqual([ + { + type: "image", + id: "thread-fork-00000000-0000-4000-8000-000000000001", + name: "question.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ]); + + const projected = yield* projectEvent(seedReadModel(), { + ...event, + sequence: 1, + eventId: EventId.make("event-fork"), + }); + expect(projected.threads.find((thread) => thread.id === forkThreadId)?.forkedFrom).toEqual({ + threadId: sourceThreadId, + turnId: turnOneId, + }); + }), + ); + + it.effect("preserves provider turn identity when forking inherited history again", () => + Effect.gen(function* () { + const readModel = seedReadModel(); + const firstFork = requireForkEvent( + yield* decideOrchestrationCommand({ + command: forkCommand(turnOneId), + readModel, + }), + ); + const projected = yield* projectEvent(readModel, { + ...firstFork, + sequence: 1, + eventId: EventId.make("event-first-fork"), + }); + const nestedThreadId = ThreadId.make("thread-nested-fork"); + const nestedFork = requireForkEvent( + yield* decideOrchestrationCommand({ + command: { + ...forkCommand(turnOneId), + commandId: CommandId.make("command-nested-fork"), + threadId: nestedThreadId, + sourceThreadId: forkThreadId, + }, + readModel: projected, + }), + ); + + expect(nestedFork.payload.inheritedMessages.map((message) => message.turnId)).toEqual([ + turnOneId, + turnOneId, + ]); + expect(nestedFork.payload.forkedFrom).toEqual({ + threadId: forkThreadId, + turnId: turnOneId, + }); + }), + ); + + it.effect("rejects a deleted source thread before creating fork lineage", () => + Effect.gen(function* () { + const readModel = seedReadModel(); + const source = readModel.threads[0]!; + const error = yield* decideOrchestrationCommand({ + command: forkCommand(turnOneId), + readModel: { + ...readModel, + threads: [ + { + ...source, + deletedAt: now, + }, + ], + }, + }).pipe(Effect.flip); + + expect(error.message).toContain( + "Source thread 'thread-source' is deleted and cannot be forked", + ); + }), + ); + + it.effect("rejects a fork at a running turn", () => + Effect.gen(function* () { + const readModel = seedReadModel(); + const source = readModel.threads[0]!; + const error = yield* decideOrchestrationCommand({ + command: forkCommand(turnTwoId), + readModel: { + ...readModel, + threads: [ + { + ...source, + latestTurn: { + ...source.latestTurn!, + state: "running", + completedAt: null, + }, + }, + ], + }, + }).pipe(Effect.flip); + + expect(error.message).toContain("still running and cannot be forked"); + }), + ); + + it.effect("rejects a failed turn without a completed assistant response", () => + Effect.gen(function* () { + const readModel = seedReadModel(); + const source = readModel.threads[0]!; + const failedTurnId = TurnId.make("turn-3"); + const error = yield* decideOrchestrationCommand({ + command: forkCommand(failedTurnId), + readModel: { + ...readModel, + threads: [ + { + ...source, + latestTurn: { + turnId: failedTurnId, + state: "error", + requestedAt: now, + startedAt: now, + completedAt: now, + assistantMessageId: null, + }, + }, + ], + }, + }).pipe(Effect.flip); + + expect(error.message).toContain("has no completed assistant response to fork"); + }), + ); + + it.effect("rejects attachments that cannot be assigned a fork-owned ID", () => + Effect.gen(function* () { + const readModel = seedReadModel(); + const source = readModel.threads[0]!; + const firstMessage = source.messages[0]!; + const error = yield* decideOrchestrationCommand({ + command: forkCommand(turnOneId), + readModel: { + ...readModel, + threads: [ + { + ...source, + messages: [ + { + ...firstMessage, + attachments: [ + { + type: "image", + id: "legacy-attachment", + name: "question.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + }, + ...source.messages.slice(1), + ], + }, + ], + }, + }).pipe(Effect.flip); + + expect(error.message).toContain( + "Cannot fork attachment 'legacy-attachment' into thread 'thread-fork'", + ); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e3..bb7fa3b0a98 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -1,5 +1,6 @@ import { EventId, + MessageId, type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, @@ -9,6 +10,7 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import type * as PlatformError from "effect/PlatformError"; +import { createForkedAttachmentId } from "../attachmentStore.ts"; import { OrchestrationCommandInvariantError } from "./Errors.ts"; import { listThreadsByProjectId, @@ -378,6 +380,117 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.fork": { + const sourceThread = yield* requireThread({ + readModel, + command, + threadId: command.sourceThreadId, + }); + if (sourceThread.deletedAt !== null) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Source thread '${sourceThread.id}' is deleted and cannot be forked.`, + }); + } + yield* requireThreadAbsent({ + readModel, + command, + threadId: command.threadId, + }); + + const sourceTurnId = command.sourceTurnId; + if (!sourceThread.messages.some((message) => message.turnId === sourceTurnId)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Turn '${sourceTurnId}' was not found in source thread '${sourceThread.id}'.`, + }); + } + const latestTurn = sourceThread.latestTurn; + if (latestTurn?.turnId === sourceTurnId && latestTurn.state === "running") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Turn '${sourceTurnId}' is still running and cannot be forked.`, + }); + } + if ( + !sourceThread.messages.some( + (message) => + message.turnId === sourceTurnId && message.role === "assistant" && !message.streaming, + ) + ) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Turn '${sourceTurnId}' has no completed assistant response to fork.`, + }); + } + + const cutoffIndex = sourceThread.messages.findLastIndex( + (message) => message.turnId === sourceTurnId, + ); + const sourceMessages = sourceThread.messages + .slice(0, cutoffIndex + 1) + .filter((message) => !message.streaming); + const inheritedMessages = []; + + for (let index = 0; index < sourceMessages.length; index += 1) { + const message = sourceMessages[index]; + if (!message) { + continue; + } + + const attachments = + message.attachments === undefined + ? undefined + : yield* Effect.forEach(message.attachments, (attachment) => { + const attachmentId = createForkedAttachmentId(command.threadId, attachment.id); + return attachmentId === null + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Cannot fork attachment '${attachment.id}' into thread '${command.threadId}'.`, + }), + ) + : Effect.succeed({ + ...attachment, + id: attachmentId, + }); + }); + + inheritedMessages.push({ + ...message, + id: MessageId.make(`${command.threadId}:fork:${index}`), + ...(attachments !== undefined ? { attachments } : {}), + }); + } + + return { + ...(yield* withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + })), + type: "thread.forked", + payload: { + threadId: command.threadId, + projectId: sourceThread.projectId, + title: command.title, + modelSelection: sourceThread.modelSelection, + runtimeMode: sourceThread.runtimeMode, + interactionMode: sourceThread.interactionMode, + branch: sourceThread.branch, + worktreePath: sourceThread.worktreePath, + forkedFrom: { + threadId: sourceThread.id, + turnId: sourceTurnId, + }, + inheritedMessages, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + } + case "thread.delete": { yield* requireThread({ readModel, diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 9c07a312023..8971428e9c8 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -6,6 +6,7 @@ import { ThreadId, type OrchestrationEvent, } from "@t3tools/contracts"; +import { it as effectIt } from "@effect/vitest"; import * as Effect from "effect/Effect"; import { describe, expect, it } from "vite-plus/test"; @@ -703,6 +704,102 @@ describe("orchestration projector", () => { expect(thread?.latestTurn?.turnId).toBe("turn-1"); }); + effectIt.effect("retains inherited fork messages when reverting fork-local turns", () => + Effect.gen(function* () { + const createdAt = "2026-02-25T10:00:00.000Z"; + const model = createEmptyReadModel(createdAt); + const events: ReadonlyArray = [ + makeEvent({ + sequence: 1, + type: "thread.forked", + aggregateKind: "thread", + aggregateId: "thread-fork", + occurredAt: createdAt, + commandId: "cmd-fork", + payload: { + threadId: "thread-fork", + projectId: "project-1", + title: "Fork", + modelSelection: { + provider: ProviderDriverKind.make("codex"), + model: "gpt-5.3-codex", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + forkedFrom: { + threadId: "thread-source", + turnId: "source-turn", + }, + inheritedMessages: [ + { + id: "thread-fork:fork:0", + role: "user", + text: "Inherited question", + turnId: "source-turn", + streaming: false, + createdAt, + updatedAt: createdAt, + }, + { + id: "thread-fork:fork:1", + role: "assistant", + text: "Inherited answer", + turnId: "source-turn", + streaming: false, + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + updatedAt: createdAt, + }, + }), + makeEvent({ + sequence: 2, + type: "thread.message-sent", + aggregateKind: "thread", + aggregateId: "thread-fork", + occurredAt: "2026-02-25T10:00:01.000Z", + commandId: "cmd-fork-local", + payload: { + threadId: "thread-fork", + messageId: "fork-local-assistant", + role: "assistant", + text: "Fork-local answer", + turnId: "fork-local-turn", + streaming: false, + createdAt: "2026-02-25T10:00:01.000Z", + updatedAt: "2026-02-25T10:00:01.000Z", + }, + }), + makeEvent({ + sequence: 3, + type: "thread.reverted", + aggregateKind: "thread", + aggregateId: "thread-fork", + occurredAt: "2026-02-25T10:00:02.000Z", + commandId: "cmd-revert", + payload: { + threadId: "thread-fork", + turnCount: 0, + }, + }), + ]; + + let afterRevert = model; + for (const event of events) { + afterRevert = yield* projectEvent(afterRevert, event); + } + + expect(afterRevert.threads[0]?.messages.map((message) => message.id)).toEqual([ + "thread-fork:fork:0", + "thread-fork:fork:1", + ]); + }), + ); + it("does not fallback-retain messages tied to removed turn IDs", async () => { const createdAt = "2026-02-26T12:00:00.000Z"; const model = createEmptyReadModel(createdAt); @@ -856,7 +953,7 @@ describe("orchestration projector", () => { ).toEqual([{ id: "assistant-keep", role: "assistant", turnId: "turn-1" }]); }); - it("caps message and checkpoint retention for long-lived threads", async () => { + it("retains complete message history while capping checkpoints for long-lived threads", async () => { const createdAt = "2026-03-01T10:00:00.000Z"; const model = createEmptyReadModel(createdAt); @@ -949,8 +1046,8 @@ describe("orchestration projector", () => { ); const thread = finalState.threads[0]; - expect(thread?.messages).toHaveLength(2_000); - expect(thread?.messages[0]?.id).toBe("msg-100"); + expect(thread?.messages).toHaveLength(2_100); + expect(thread?.messages[0]?.id).toBe("msg-0"); expect(thread?.messages.at(-1)?.id).toBe("msg-2099"); expect(thread?.checkpoints).toHaveLength(500); expect(thread?.checkpoints[0]?.turnId).toBe("turn-100"); diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a..e78ffc51af5 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -17,6 +17,7 @@ import { ThreadActivityAppendedPayload, ThreadArchivedPayload, ThreadCreatedPayload, + ThreadForkedPayload, ThreadDeletedPayload, ThreadInteractionModeSetPayload, ThreadMetaUpdatedPayload, @@ -33,7 +34,6 @@ import { } from "./Schemas.ts"; type ThreadPatch = Partial>; -const MAX_THREAD_MESSAGES = 2_000; const MAX_THREAD_CHECKPOINTS = 500; function checkpointStatusToLatestTurnState(status: "ready" | "missing" | "error") { @@ -88,10 +88,11 @@ function retainThreadMessagesAfterRevert( messages: ReadonlyArray, retainedTurnIds: ReadonlySet, turnCount: number, + threadId: ThreadId, ): ReadonlyArray { const retainedMessageIds = new Set(); for (const message of messages) { - if (message.role === "system") { + if (message.role === "system" || message.id.startsWith(`${threadId}:fork:`)) { retainedMessageIds.add(message.id); continue; } @@ -312,6 +313,48 @@ export function projectEvent( }; }); + case "thread.forked": + return Effect.gen(function* () { + const payload = yield* decodeForEvent( + ThreadForkedPayload, + event.payload, + event.type, + "payload", + ); + const thread: OrchestrationThread = yield* decodeForEvent( + OrchestrationThread, + { + id: payload.threadId, + projectId: payload.projectId, + title: payload.title, + modelSelection: payload.modelSelection, + runtimeMode: payload.runtimeMode, + interactionMode: payload.interactionMode, + branch: payload.branch, + worktreePath: payload.worktreePath, + forkedFrom: payload.forkedFrom, + latestTurn: null, + createdAt: payload.createdAt, + updatedAt: payload.updatedAt, + archivedAt: null, + deletedAt: null, + messages: payload.inheritedMessages, + activities: [], + checkpoints: [], + session: null, + }, + event.type, + "thread", + ); + const existing = nextBase.threads.find((entry) => entry.id === thread.id); + return { + ...nextBase, + threads: existing + ? nextBase.threads.map((entry) => (entry.id === thread.id ? thread : entry)) + : [...nextBase.threads, thread], + }; + }); + case "thread.deleted": return decodeForEvent(ThreadDeletedPayload, event.payload, event.type, "payload").pipe( Effect.map((payload) => ({ @@ -486,12 +529,10 @@ export function projectEvent( : entry, ) : [...thread.messages, message]; - const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); - return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { - messages: cappedMessages, + messages, updatedAt: event.occurredAt, }), }; @@ -684,7 +725,8 @@ export function projectEvent( thread.messages, retainedTurnIds, payload.turnCount, - ).slice(-MAX_THREAD_MESSAGES); + payload.threadId, + ); const proposedPlans = retainThreadProposedPlansAfterRevert( thread.proposedPlans, retainedTurnIds, diff --git a/apps/server/src/persistence/Errors.ts b/apps/server/src/persistence/Errors.ts index 03edaec77d6..2850073cdfc 100644 --- a/apps/server/src/persistence/Errors.ts +++ b/apps/server/src/persistence/Errors.ts @@ -135,4 +135,20 @@ export type ProviderSessionRuntimeRepositoryError = PersistenceSqlError | Persis export type AuthPairingLinkRepositoryError = PersistenceSqlError | PersistenceDecodeError; export type AuthSessionRepositoryError = PersistenceSqlError | PersistenceDecodeError; -export type ProjectionRepositoryError = PersistenceSqlError | PersistenceDecodeError; +export class ProjectionAttachmentMaterializationError extends Schema.TaggedErrorClass()( + "ProjectionAttachmentMaterializationError", + { + operation: Schema.String, + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return `Projection attachment materialization failed in ${this.operation}: ${this.detail}`; + } +} + +export type ProjectionRepositoryError = + | PersistenceSqlError + | PersistenceDecodeError + | ProjectionAttachmentMaterializationError; diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 7e86d49eac3..b954310597d 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -39,6 +39,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + forked_from_thread_id, + forked_from_turn_id, latest_turn_id, created_at, updated_at, @@ -62,6 +64,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.forkedFromThreadId ?? null}, + ${row.forkedFromTurnId ?? null}, ${row.latestTurnId}, ${row.createdAt}, ${row.updatedAt}, @@ -85,6 +89,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + forked_from_thread_id = excluded.forked_from_thread_id, + forked_from_turn_id = excluded.forked_from_turn_id, latest_turn_id = excluded.latest_turn_id, created_at = excluded.created_at, updated_at = excluded.updated_at, @@ -115,6 +121,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + forked_from_thread_id AS "forkedFromThreadId", + forked_from_turn_id AS "forkedFromTurnId", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", @@ -147,6 +155,8 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + forked_from_thread_id AS "forkedFromThreadId", + forked_from_turn_id AS "forkedFromTurnId", latest_turn_id AS "latestTurnId", created_at AS "createdAt", updated_at AS "updatedAt", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index d25895671a9..8091b121a01 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -47,6 +47,7 @@ import Migration0031 from "./Migrations/031_AuthAuthorizationScopes.ts"; import Migration0032 from "./Migrations/032_AuthPairingProofKeyThumbprint.ts"; import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; +import Migration0035 from "./Migrations/035_ProjectionThreadForkLineage.ts"; /** * Migration loader with all migrations defined inline. @@ -93,6 +94,7 @@ export const migrationEntries = [ [32, "AuthPairingProofKeyThumbprint", Migration0032], [33, "ProjectionThreadsSettled", Migration0033], [34, "ProjectionThreadsSnoozed", Migration0034], + [35, "ProjectionThreadForkLineage", Migration0035], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.test.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.test.ts new file mode 100644 index 00000000000..ee685d1b07d --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.test.ts @@ -0,0 +1,29 @@ +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const layer = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +layer("035_ProjectionThreadForkLineage", (it) => { + it.effect("adds nullable fork lineage columns and the parent lookup index", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 34 }); + const before = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + assert.isFalse(before.some((column) => column.name === "forked_from_thread_id")); + + yield* runMigrations({ toMigrationInclusive: 35 }); + const columns = yield* sql<{ readonly name: string }>`PRAGMA table_info(projection_threads)`; + const indexes = yield* sql<{ readonly name: string }>`PRAGMA index_list(projection_threads)`; + + assert.isTrue(columns.some((column) => column.name === "forked_from_thread_id")); + assert.isTrue(columns.some((column) => column.name === "forked_from_turn_id")); + assert.isTrue(indexes.some((index) => index.name === "idx_projection_threads_forked_from")); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.ts b/apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.ts new file mode 100644 index 00000000000..e75a95a6bd4 --- /dev/null +++ b/apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.ts @@ -0,0 +1,13 @@ +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`ALTER TABLE projection_threads ADD COLUMN forked_from_thread_id TEXT`; + yield* sql`ALTER TABLE projection_threads ADD COLUMN forked_from_turn_id TEXT`; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_projection_threads_forked_from + ON projection_threads(forked_from_thread_id, created_at) + `; +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 056425ae886..d1358aef1d4 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -32,6 +32,8 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + forkedFromThreadId: Schema.optional(Schema.NullOr(ThreadId)), + forkedFromTurnId: Schema.optional(Schema.NullOr(TurnId)), latestTurnId: Schema.NullOr(TurnId), createdAt: IsoDateTime, updatedAt: IsoDateTime, diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 760f0e7fbab..fc65b669e5a 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -9,6 +9,7 @@ import type { PermissionMode, PermissionResult, SDKMessage, + SessionMessage, SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { @@ -37,7 +38,11 @@ import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { ProviderAdapterProcessError, ProviderAdapterValidationError } from "../Errors.ts"; import type { ClaudeAdapterShape } from "../Services/ClaudeAdapter.ts"; -import { makeClaudeAdapter, type ClaudeAdapterLiveOptions } from "./ClaudeAdapter.ts"; +import { + makeClaudeAdapter, + resolveClaudeAssistantForkPoint, + type ClaudeAdapterLiveOptions, +} from "./ClaudeAdapter.ts"; const decodeClaudeSettings = Schema.decodeSync(ClaudeSettings); // Test-local service tag so the rest of the file can keep using `yield* ClaudeAdapter`. @@ -156,6 +161,7 @@ function makeHarness(config?: { readonly baseDir?: string; readonly claudeConfig?: Partial; readonly instanceId?: ProviderInstanceId; + readonly getSessionMessages?: (sessionId: string) => Promise; }) { const query = new FakeClaudeQuery(); let createInput: @@ -171,6 +177,7 @@ function makeHarness(config?: { createInput = input; return query; }, + ...(config?.getSessionMessages ? { getSessionMessages: config.getSessionMessages } : {}), ...(config?.nativeEventLogger ? { nativeEventLogger: config.nativeEventLogger, @@ -221,6 +228,30 @@ function makeDeterministicRandomService(seed = 0x1234_5678): { }; } +describe("resolveClaudeAssistantForkPoint", () => { + it("selects the terminal assistant message for a human turn", () => { + const message = (type: SessionMessage["type"], uuid: string, content: unknown) => ({ + type, + uuid, + session_id: "session-1", + message: { content }, + parent_tool_use_id: null, + }); + const messages = [ + message("user", "user-1", [{ type: "text", text: "first" }]), + message("assistant", "assistant-1a", [{ type: "tool_use" }]), + message("user", "tool-result-1", [{ type: "tool_result" }]), + message("assistant", "assistant-1b", [{ type: "text" }]), + message("user", "user-2", [{ type: "text", text: "second" }]), + message("assistant", "assistant-2", [{ type: "text" }]), + ] satisfies SessionMessage[]; + + assert.equal(resolveClaudeAssistantForkPoint(messages, 0), "assistant-1b"); + assert.equal(resolveClaudeAssistantForkPoint(messages, 1), "assistant-2"); + assert.equal(resolveClaudeAssistantForkPoint(messages, undefined), "assistant-2"); + }); +}); + async function readFirstPromptText( input: | { @@ -2990,6 +3021,79 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("forks a Claude session at the selected assistant response", () => { + const sourceSessionId = "550e8400-e29b-41d4-a716-446655440000"; + const readCalls: string[] = []; + const harness = makeHarness({ + getSessionMessages: async (sessionId) => { + readCalls.push(sessionId); + return [ + { + type: "user", + uuid: "user-1", + session_id: sessionId, + message: { content: [{ type: "text", text: "first" }] }, + parent_tool_use_id: null, + }, + { + type: "assistant", + uuid: "assistant-1", + session_id: sessionId, + message: {}, + parent_tool_use_id: null, + }, + { + type: "user", + uuid: "user-2", + session_id: sessionId, + message: { content: [{ type: "text", text: "second" }] }, + parent_tool_use_id: null, + }, + { + type: "assistant", + uuid: "assistant-2", + session_id: sessionId, + message: { content: [{ type: "text", text: "response" }] }, + parent_tool_use_id: null, + }, + ]; + }, + }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: ThreadId.make("thread-claude-fork"), + provider: ProviderDriverKind.make("claudeAgent"), + forkFrom: { + threadId: RESUME_THREAD_ID, + sourceTurnIndex: 1, + resumeCursor: { + threadId: RESUME_THREAD_ID, + resume: sourceSessionId, + turnCount: 2, + }, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.deepEqual(readCalls, [sourceSessionId]); + assert.equal(createInput?.options.resume, sourceSessionId); + assert.equal(createInput?.options.forkSession, true); + assert.equal(createInput?.options.resumeSessionAt, "assistant-2"); + assert.equal(typeof createInput?.options.sessionId, "string"); + assert.notEqual(createInput?.options.sessionId, sourceSessionId); + assert.equal( + (session.resumeCursor as { resume?: string }).resume, + createInput?.options.sessionId, + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("preserves durable resume ids across Claude resume hooks", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 285d9dac608..7e694ce3841 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -14,6 +14,8 @@ import { type PermissionResult, type PermissionUpdate, type SDKMessage, + getSessionMessages, + type SessionMessage, type SDKControlGetContextUsageResponse, type SDKResultMessage, type SettingSource, @@ -220,6 +222,10 @@ export interface ClaudeAdapterLiveOptions { readonly prompt: AsyncIterable; readonly options: ClaudeQueryOptions; }) => ClaudeQueryRuntime; + readonly getSessionMessages?: ( + sessionId: string, + options?: { readonly dir?: string }, + ) => Promise; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; } @@ -593,6 +599,52 @@ function readClaudeResumeState(resumeCursor: unknown): ClaudeResumeState | undef }; } +function sessionUserMessageStartsHumanTurn(message: SessionMessage): boolean { + if (message.type !== "user") { + return false; + } + const content = (message.message as { readonly content?: unknown } | null)?.content; + if (!Array.isArray(content)) { + return true; + } + return content.some( + (block) => + typeof block !== "object" || + block === null || + (block as { readonly type?: unknown }).type !== "tool_result", + ); +} + +export function resolveClaudeAssistantForkPoint( + messages: ReadonlyArray, + sourceTurnIndex: number | undefined, +): string | undefined { + const terminalAssistantUuids: string[] = []; + let currentTurnAssistantUuid: string | undefined; + let hasHumanTurn = false; + + for (const message of messages) { + if (sessionUserMessageStartsHumanTurn(message)) { + if (hasHumanTurn && currentTurnAssistantUuid !== undefined) { + terminalAssistantUuids.push(currentTurnAssistantUuid); + } + hasHumanTurn = true; + currentTurnAssistantUuid = undefined; + continue; + } + if (message.type === "assistant" && hasHumanTurn) { + currentTurnAssistantUuid = message.uuid; + } + } + if (hasHumanTurn && currentTurnAssistantUuid !== undefined) { + terminalAssistantUuids.push(currentTurnAssistantUuid); + } + + return sourceTurnIndex === undefined + ? terminalAssistantUuids.at(-1) + : terminalAssistantUuids[sourceTurnIndex]; +} + function classifyToolItemType(toolName: string): CanonicalItemType { const normalized = toolName.toLowerCase(); if (normalized.includes("agent")) { @@ -1365,6 +1417,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( prompt: input.prompt, options: input.options, }) as ClaudeQueryRuntime); + const readSessionMessages = options?.getSessionMessages ?? getSessionMessages; const sessions = new Map(); const runtimeEventQueue = yield* Queue.unbounded(); @@ -3166,11 +3219,50 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( } const startedAt = yield* nowIso; - const resumeState = readClaudeResumeState(input.resumeCursor); + const isFork = input.forkFrom !== undefined; + const resumeState = readClaudeResumeState( + isFork ? input.forkFrom?.resumeCursor : input.resumeCursor, + ); + if (isFork && resumeState?.resume === undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "Claude forks require a valid persisted source session id.", + }); + } + const forkAtAssistantUuid = isFork + ? yield* Effect.tryPromise({ + try: async () => { + const messages = await readSessionMessages(resumeState!.resume!, { + ...(input.cwd ? { dir: input.cwd } : {}), + }); + const sourceTurnIndex = input.forkFrom?.sourceTurnIndex; + const assistantUuid = resolveClaudeAssistantForkPoint(messages, sourceTurnIndex); + if (!assistantUuid) { + throw new Error( + sourceTurnIndex === undefined + ? `Claude source session '${resumeState!.resume}' has no assistant response to fork.` + : `Claude source session '${resumeState!.resume}' has no assistant response at position ${sourceTurnIndex}.`, + ); + } + return assistantUuid; + }, + catch: (cause) => + new ProviderAdapterRequestError({ + provider: PROVIDER, + method: "session/fork", + detail: + cause instanceof Error ? cause.message : "Failed to resolve Claude fork point.", + cause, + }), + }) + : undefined; const threadId = input.threadId; const existingResumeSessionId = resumeState?.resume; - const newSessionId = existingResumeSessionId === undefined ? yield* randomUUIDv4 : undefined; + const newSessionId = + isFork || existingResumeSessionId === undefined ? yield* randomUUIDv4 : undefined; const sessionId = existingResumeSessionId ?? newSessionId; + const effectiveSessionId = isFork ? newSessionId : sessionId; const runtimeContext = yield* Effect.context(); const runFork = Effect.runForkWith(runtimeContext); @@ -3539,6 +3631,8 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(Object.keys(settings).length > 0 ? { settings } : {}), ...(existingResumeSessionId ? { resume: existingResumeSessionId } : {}), ...(newSessionId ? { sessionId: newSessionId } : {}), + ...(isFork ? { forkSession: true } : {}), + ...(forkAtAssistantUuid ? { resumeSessionAt: forkAtAssistantUuid } : {}), includePartialMessages: true, canUseTool, env: claudeEnvironment, @@ -3610,8 +3704,10 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( ...(threadId ? { threadId } : {}), resumeCursor: { ...(threadId ? { threadId } : {}), - ...(sessionId ? { resume: sessionId } : {}), - ...(resumeState?.resumeSessionAt ? { resumeSessionAt: resumeState.resumeSessionAt } : {}), + ...(effectiveSessionId ? { resume: effectiveSessionId } : {}), + ...(!isFork && resumeState?.resumeSessionAt + ? { resumeSessionAt: resumeState.resumeSessionAt } + : {}), turnCount: resumeState?.turnCount ?? 0, }, createdAt: startedAt, @@ -3626,7 +3722,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( startedAt, basePermissionMode: permissionMode, currentApiModelId: apiModelId, - resumeSessionId: sessionId, + resumeSessionId: effectiveSessionId, pendingApprovals, pendingUserInputs, turns: [], @@ -3636,7 +3732,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( lastKnownContextWindow: initialContextWindow, lastKnownTokenUsage: undefined, lastKnownTotalProcessedTokens: undefined, - lastAssistantUuid: resumeState?.resumeSessionAt, + lastAssistantUuid: isFork ? undefined : resumeState?.resumeSessionAt, lastThreadStartedId: undefined, stopped: false, }; @@ -3929,6 +4025,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + sessionFork: "native", }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CodexAdapter.ts b/apps/server/src/provider/Layers/CodexAdapter.ts index 38a5887cdc3..13b9da7ae8c 100644 --- a/apps/server/src/provider/Layers/CodexAdapter.ts +++ b/apps/server/src/provider/Layers/CodexAdapter.ts @@ -1406,6 +1406,16 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( ...(isCodexResumeCursorSchema(input.resumeCursor) ? { resumeCursor: input.resumeCursor } : {}), + ...(input.forkFrom !== undefined && isCodexResumeCursorSchema(input.forkFrom.resumeCursor) + ? { + forkFrom: { + providerThreadId: input.forkFrom.resumeCursor.threadId, + ...(input.forkFrom.sourceTurnId !== undefined + ? { sourceTurnId: input.forkFrom.sourceTurnId } + : {}), + }, + } + : {}), runtimeMode: input.runtimeMode, ...(input.modelSelection?.instanceId === boundInstanceId ? { model: input.modelSelection.model } @@ -1703,6 +1713,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + sessionFork: "native", }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts index d7346a0e0db..f9cdeb6653f 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.test.ts @@ -393,12 +393,64 @@ describe("isRecoverableThreadResumeError", () => { }); describe("openCodexThread", () => { + it.effect("forks the native Codex thread at the selected turn", () => + Effect.gen(function* () { + const calls: Array<{ + method: "thread/start" | "thread/resume" | "thread/fork"; + payload: unknown; + }> = []; + const forked = makeThreadOpenResponse("forked-provider-thread"); + const client = { + request: ( + method: M, + payload: CodexRpc.ClientRequestParamsByMethod[M], + ) => { + calls.push({ method, payload }); + return Effect.succeed(forked as CodexRpc.ClientRequestResponsesByMethod[M]); + }, + }; + + const opened = yield* openCodexThread({ + client, + threadId: ThreadId.make("t3-fork-thread"), + runtimeMode: "full-access", + cwd: "/tmp/project", + requestedModel: "gpt-5.4", + serviceTier: undefined, + resumeThreadId: "ignored-target-resume-thread", + forkFrom: { + providerThreadId: "source-provider-thread", + sourceTurnId: "turn-2" as import("@t3tools/contracts").TurnId, + }, + }); + + NodeAssert.equal(opened.thread.id, "forked-provider-thread"); + NodeAssert.deepStrictEqual(calls, [ + { + method: "thread/fork", + payload: { + threadId: "source-provider-thread", + cwd: "/tmp/project", + approvalPolicy: "never", + sandbox: "danger-full-access", + model: "gpt-5.4", + lastTurnId: "turn-2", + threadSource: "t3-code", + }, + }, + ]); + }), + ); + it.effect("falls back to thread/start when resume fails recoverably", () => Effect.gen(function* () { - const calls: Array<{ method: "thread/start" | "thread/resume"; payload: unknown }> = []; + const calls: Array<{ + method: "thread/start" | "thread/resume" | "thread/fork"; + payload: unknown; + }> = []; const started = makeThreadOpenResponse("fresh-thread"); const client = { - request: ( + request: ( method: M, payload: CodexRpc.ClientRequestParamsByMethod[M], ) => { @@ -436,7 +488,7 @@ describe("openCodexThread", () => { it.effect("propagates non-recoverable resume failures", () => Effect.gen(function* () { const client = { - request: ( + request: ( method: M, _payload: CodexRpc.ClientRequestParamsByMethod[M], ) => { diff --git a/apps/server/src/provider/Layers/CodexSessionRuntime.ts b/apps/server/src/provider/Layers/CodexSessionRuntime.ts index 67108dd4dbb..429f79280b5 100644 --- a/apps/server/src/provider/Layers/CodexSessionRuntime.ts +++ b/apps/server/src/provider/Layers/CodexSessionRuntime.ts @@ -105,6 +105,10 @@ export interface CodexSessionRuntimeOptions { readonly model?: string; readonly serviceTier?: CodexServiceTier | undefined; readonly resumeCursor?: CodexResumeCursor; + readonly forkFrom?: { + readonly providerThreadId: string; + readonly sourceTurnId?: TurnId; + }; readonly appServerArgs?: ReadonlyArray; } @@ -443,9 +447,10 @@ export function isRecoverableThreadResumeError(error: unknown): boolean { type CodexThreadOpenResponse = | CodexRpc.ClientRequestResponsesByMethod["thread/start"] - | CodexRpc.ClientRequestResponsesByMethod["thread/resume"]; + | CodexRpc.ClientRequestResponsesByMethod["thread/resume"] + | CodexRpc.ClientRequestResponsesByMethod["thread/fork"]; -type CodexThreadOpenMethod = "thread/start" | "thread/resume"; +type CodexThreadOpenMethod = "thread/start" | "thread/resume" | "thread/fork"; interface CodexThreadOpenClient { readonly request: ( @@ -462,6 +467,12 @@ export const openCodexThread = (input: { readonly requestedModel: string | undefined; readonly serviceTier: CodexServiceTier | undefined; readonly resumeThreadId: string | undefined; + readonly forkFrom?: + | { + readonly providerThreadId: string; + readonly sourceTurnId?: TurnId; + } + | undefined; }): Effect.Effect => { const resumeThreadId = input.resumeThreadId; const startParams = buildThreadStartParams({ @@ -471,6 +482,22 @@ export const openCodexThread = (input: { serviceTier: input.serviceTier, }); + if (input.forkFrom !== undefined) { + const config = runtimeModeToThreadConfig(input.runtimeMode); + return input.client.request("thread/fork", { + threadId: input.forkFrom.providerThreadId, + cwd: input.cwd, + approvalPolicy: config.approvalPolicy, + sandbox: config.sandbox, + ...(startParams.model ? { model: startParams.model } : {}), + ...(startParams.serviceTier ? { serviceTier: startParams.serviceTier } : {}), + ...(input.forkFrom.sourceTurnId !== undefined + ? { lastTurnId: input.forkFrom.sourceTurnId } + : {}), + threadSource: "t3-code", + }); + } + if (resumeThreadId === undefined) { return input.client.request("thread/start", startParams); } @@ -1227,6 +1254,7 @@ export const makeCodexSessionRuntime = ( requestedModel, serviceTier: options.serviceTier, resumeThreadId: readResumeCursorThreadId(options.resumeCursor), + forkFrom: options.forkFrom, }); const providerThreadId = opened.thread.id; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 1385ccbaabe..1e770509cda 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -13,7 +13,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import * as TestClock from "effect/testing/TestClock"; -import { beforeEach } from "vite-plus/test"; +import { beforeEach, describe } from "vite-plus/test"; import { OpenCodeSettings, @@ -37,6 +37,7 @@ import { isSameOpenCodeDirectory, makeOpenCodeAdapter, mergeOpenCodeAssistantText, + resolveOpenCodeAssistantForkPoint, } from "./OpenCodeAdapter.ts"; // Test-local service tag so the rest of the file can keep using `yield* OpenCodeAdapter`. @@ -73,7 +74,8 @@ const runtimeMock = { transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, - forkCalls: [] as Array<{ sessionID: string; directory?: string }>, + sessionUpdateError: null as Error | null, + forkCalls: [] as Array<{ sessionID: string; directory?: string; messageID?: string }>, }, reset() { this.state.startCalls.length = 0; @@ -93,6 +95,7 @@ const runtimeMock = { this.state.transientErrorSessionIds.clear(); this.state.sessionDirectoryById.clear(); this.state.sessionUpdateCalls.length = 0; + this.state.sessionUpdateError = null; this.state.forkCalls.length = 0; }, }; @@ -163,12 +166,27 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { }, update: async ({ sessionID, permission }: { sessionID: string; permission: unknown }) => { runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); + if (runtimeMock.state.sessionUpdateError) { + throw runtimeMock.state.sessionUpdateError; + } return { data: { id: sessionID } }; }, - fork: async ({ sessionID, directory }: { sessionID: string; directory?: string }) => { + fork: async ({ + sessionID, + directory, + messageID, + }: { + sessionID: string; + directory?: string; + messageID?: string; + }) => { // Fork clones history into a new session bound to the directory. const forkedId = `${sessionID}_fork`; - runtimeMock.state.forkCalls.push({ sessionID, ...(directory ? { directory } : {}) }); + runtimeMock.state.forkCalls.push({ + sessionID, + ...(directory ? { directory } : {}), + ...(messageID ? { messageID } : {}), + }); if (directory) { runtimeMock.state.sessionDirectoryById.set(forkedId, directory); } @@ -277,6 +295,22 @@ beforeEach(() => { runtimeMock.reset(); }); +describe("resolveOpenCodeAssistantForkPoint", () => { + it("selects the terminal assistant response for each human turn", () => { + const messages: MessageEntry[] = [ + { info: { id: "user-1", role: "user" }, parts: [] }, + { info: { id: "assistant-1a", role: "assistant" }, parts: [] }, + { info: { id: "assistant-1b", role: "assistant" }, parts: [] }, + { info: { id: "user-2", role: "user" }, parts: [] }, + { info: { id: "assistant-2", role: "assistant" }, parts: [] }, + ]; + + NodeAssert.equal(resolveOpenCodeAssistantForkPoint(messages, 0), "assistant-1b"); + NodeAssert.equal(resolveOpenCodeAssistantForkPoint(messages, 1), "assistant-2"); + NodeAssert.equal(resolveOpenCodeAssistantForkPoint(messages, undefined), "assistant-2"); + }); +}); + const advanceTestClock = (ms: number) => TestClock.adjust(`${ms} millis`).pipe(Effect.andThen(Effect.yieldNow)); @@ -353,6 +387,78 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + it.effect("forks the persisted OpenCode session at the selected assistant response", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-fork"); + runtimeMock.state.messages = [ + { info: { id: "user-1", role: "user" }, parts: [] }, + { info: { id: "assistant-1", role: "assistant" }, parts: [] }, + { info: { id: "user-2", role: "user" }, parts: [] }, + { info: { id: "assistant-2", role: "assistant" }, parts: [] }, + ]; + + const session = yield* adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + forkFrom: { + threadId: asThreadId("thread-opencode-source"), + sourceTurnIndex: 1, + resumeCursor: { schemaVersion: 1, sessionId: "ses_source" }, + }, + }); + + NodeAssert.deepEqual(runtimeMock.state.forkCalls, [ + { + sessionID: "ses_source", + directory: process.cwd(), + messageID: "assistant-2", + }, + ]); + NodeAssert.deepEqual(runtimeMock.state.sessionCreateUrls, []); + NodeAssert.deepEqual(session.resumeCursor, { + schemaVersion: 1, + sessionId: "ses_source_fork", + }); + NodeAssert.equal(runtimeMock.state.sessionUpdateCalls[0]?.sessionID, "ses_source_fork"); + + yield* adapter.stopSession(threadId); + }), + ); + + it.effect("aborts a newly forked OpenCode session when permission update fails", () => + Effect.gen(function* () { + const adapter = yield* OpenCodeAdapter; + const threadId = asThreadId("thread-opencode-fork-update-failure"); + runtimeMock.state.messages = [ + { info: { id: "user-1", role: "user" }, parts: [] }, + { info: { id: "assistant-1", role: "assistant" }, parts: [] }, + ]; + runtimeMock.state.sessionUpdateError = new Error("permission update failed"); + + const result = yield* Effect.result( + adapter.startSession({ + provider: ProviderDriverKind.make("opencode"), + threadId, + runtimeMode: "full-access", + forkFrom: { + threadId: asThreadId("thread-opencode-source"), + sourceTurnIndex: 0, + resumeCursor: { schemaVersion: 1, sessionId: "ses_source" }, + }, + }), + ); + + NodeAssert.equal(result._tag, "Failure"); + NodeAssert.deepEqual(runtimeMock.state.abortCalls, ["ses_source_fork"]); + NodeAssert.equal( + (yield* adapter.listSessions()).some((session) => session.threadId === threadId), + false, + ); + }), + ); + it.effect("sends follow-up turns to the resumed session id", () => Effect.gen(function* () { const adapter = yield* OpenCodeAdapter; diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 73c23b77e68..b309be47c23 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -163,6 +163,41 @@ export function isSameOpenCodeDirectory( ); } +export function resolveOpenCodeAssistantForkPoint( + messages: ReadonlyArray<{ + readonly info: { + readonly id: string; + readonly role: string; + }; + }>, + sourceTurnIndex: number | undefined, +): string | undefined { + const terminalAssistantMessageIds: string[] = []; + let currentTurnAssistantMessageId: string | undefined; + let hasHumanTurn = false; + + for (const message of messages) { + if (message.info.role === "user") { + if (hasHumanTurn && currentTurnAssistantMessageId !== undefined) { + terminalAssistantMessageIds.push(currentTurnAssistantMessageId); + } + hasHumanTurn = true; + currentTurnAssistantMessageId = undefined; + continue; + } + if (message.info.role === "assistant" && hasHumanTurn) { + currentTurnAssistantMessageId = message.info.id; + } + } + if (hasHumanTurn && currentTurnAssistantMessageId !== undefined) { + terminalAssistantMessageIds.push(currentTurnAssistantMessageId); + } + + return sourceTurnIndex === undefined + ? terminalAssistantMessageIds.at(-1) + : terminalAssistantMessageIds[sourceTurnIndex]; +} + interface OpenCodeTurnSnapshot { readonly id: TurnId; readonly items: Array; @@ -1191,6 +1226,14 @@ export function makeOpenCodeAdapter( const serverPassword = openCodeSettings.serverPassword; const directory = input.cwd ?? serverConfig.cwd; const resumeSessionId = parseOpenCodeResume(input.resumeCursor)?.sessionId; + const forkSessionId = parseOpenCodeResume(input.forkFrom?.resumeCursor)?.sessionId; + if (input.forkFrom !== undefined && forkSessionId === undefined) { + return yield* new ProviderAdapterValidationError({ + provider: PROVIDER, + operation: "startSession", + issue: "OpenCode forks require a valid persisted source session id.", + }); + } const existing = sessions.get(input.threadId); if (existing) { yield* stopOpenCodeContext(existing); @@ -1214,6 +1257,30 @@ export function makeOpenCodeAdapter( directory, ...(server.external && serverPassword ? { serverPassword } : {}), }); + const updateForkedSessionPermissions = Effect.fn( + "updateForkedOpenCodeSessionPermissions", + )(function* (forkedSessionId: string) { + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: forkedSessionId, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ).pipe( + Effect.catchCause((updateCause) => + runOpenCodeSdk("session.abort", () => + client.session.abort({ sessionID: forkedSessionId }), + ).pipe( + Effect.catchCause((abortCause) => + Effect.logWarning("opencode.fork.cleanup-failed", { + sessionId: forkedSessionId, + abortCause, + }), + ), + Effect.andThen(Effect.failCause(updateCause)), + ), + ), + ); + }); const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); if (mcpSession && !server.external) { yield* runOpenCodeSdk("mcp.add", () => @@ -1235,6 +1302,43 @@ export function makeOpenCodeAdapter( // a confirmed not-found (start fresh); transport/auth/server // errors propagate instead of masking as a new empty session. const resolved = yield* Effect.gen(function* () { + if (forkSessionId !== undefined) { + const sourceMessages = yield* runOpenCodeSdk("session.messages", () => + client.session.messages({ sessionID: forkSessionId }), + ); + const sourceTurnIndex = input.forkFrom?.sourceTurnIndex; + const sourceMessageId = resolveOpenCodeAssistantForkPoint( + sourceMessages.data ?? [], + sourceTurnIndex, + ); + if (!sourceMessageId) { + return yield* new OpenCodeRuntimeError({ + operation: "session.fork", + detail: + sourceTurnIndex === undefined + ? `OpenCode source session '${forkSessionId}' has no assistant response to fork.` + : `OpenCode source session '${forkSessionId}' has no assistant response at position ${sourceTurnIndex}.`, + }); + } + + const forkedResponse = yield* runOpenCodeSdk("session.fork", () => + client.session.fork({ + sessionID: forkSessionId, + directory, + messageID: sourceMessageId, + }), + ); + const forked = forkedResponse.data; + if (!forked) { + return yield* new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "OpenCode session.fork returned no session payload.", + }); + } + yield* updateForkedSessionPermissions(forked.id); + return { openCodeSession: forked, created: true }; + } + const adopted = resumeSessionId ? yield* runOpenCodeSdk("session.get", () => client.session.get({ sessionID: resumeSessionId }), @@ -1286,12 +1390,7 @@ export function makeOpenCodeAdapter( detail: "OpenCode session.fork returned no session payload.", }); } - yield* runOpenCodeSdk("session.update", () => - client.session.update({ - sessionID: forked.id, - permission: buildOpenCodePermissionRules(input.runtimeMode), - }), - ); + yield* updateForkedSessionPermissions(forked.id); return { openCodeSession: forked, created: true }; } @@ -1701,6 +1800,7 @@ export function makeOpenCodeAdapter( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + sessionFork: "native", }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/ProviderService.test.ts b/apps/server/src/provider/Layers/ProviderService.test.ts index ccbbce1759f..e532140cb73 100644 --- a/apps/server/src/provider/Layers/ProviderService.test.ts +++ b/apps/server/src/provider/Layers/ProviderService.test.ts @@ -1783,6 +1783,32 @@ fanout.layer("ProviderServiceLive fanout", (it) => { const validation = makeProviderServiceLayer(); validation.layer("ProviderServiceLive validation", (it) => { + it.effect("rejects fork intent when the source has no persisted provider binding", () => + Effect.gen(function* () { + const provider = yield* ProviderService.ProviderService; + + validation.codex.startSession.mockClear(); + const failure = yield* Effect.flip( + provider.startSession(asThreadId("thread-fork-target"), { + provider: ProviderDriverKind.make("codex"), + providerInstanceId: codexInstanceId, + threadId: asThreadId("thread-fork-target"), + forkFrom: { + threadId: asThreadId("thread-fork-source-without-binding"), + }, + runtimeMode: "full-access", + }), + ); + + assert.instanceOf(failure, ProviderValidationError); + assert.include( + failure.issue, + "Cannot fork thread 'thread-fork-source-without-binding' because it has no persisted provider binding.", + ); + assert.equal(validation.codex.startSession.mock.calls.length, 0); + }), + ); + it.effect("rejects session starts without an explicit provider instance id", () => Effect.gen(function* () { const provider = yield* ProviderService.ProviderService; diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..bdb8f6f7012 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -559,12 +559,61 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( `Provider instance '${resolvedInstanceId}' is disabled in T3 Code settings.`, ); } + const adapter = yield* registry.getByInstance(resolvedInstanceId); const persistedBinding = Option.getOrUndefined(yield* directory.getBinding(threadId)); + const requestedForkFrom = input.forkFrom; + const effectiveForkFrom = + requestedForkFrom === undefined + ? undefined + : yield* Effect.gen(function* () { + const sourceBinding = Option.getOrUndefined( + yield* directory.getBinding(requestedForkFrom.threadId), + ); + if (!sourceBinding) { + return yield* toValidationError( + "ProviderService.startSession", + `Cannot fork thread '${requestedForkFrom.threadId}' because it has no persisted provider binding.`, + ); + } + if (adapter.capabilities.sessionFork !== "native") { + return yield* toValidationError( + "ProviderService.startSession", + `Provider '${resolvedProvider}' does not support native thread forks.`, + ); + } + if (sourceBinding.providerInstanceId !== resolvedInstanceId) { + return yield* toValidationError( + "ProviderService.startSession", + `Cannot fork thread '${requestedForkFrom.threadId}' into provider instance '${resolvedInstanceId}' because its provider resume state belongs to a different instance.`, + ); + } + if ( + sourceBinding.resumeCursor === null || + sourceBinding.resumeCursor === undefined + ) { + return yield* toValidationError( + "ProviderService.startSession", + `Cannot fork thread '${requestedForkFrom.threadId}' because no provider resume state is persisted.`, + ); + } + return { + threadId: requestedForkFrom.threadId, + ...(requestedForkFrom.sourceTurnId !== undefined + ? { sourceTurnId: requestedForkFrom.sourceTurnId } + : {}), + ...(requestedForkFrom.sourceTurnIndex !== undefined + ? { sourceTurnIndex: requestedForkFrom.sourceTurnIndex } + : {}), + resumeCursor: sourceBinding.resumeCursor, + }; + }); const effectiveResumeCursor = - input.resumeCursor ?? - (persistedBinding?.providerInstanceId === resolvedInstanceId - ? persistedBinding.resumeCursor - : undefined); + effectiveForkFrom === undefined + ? (input.resumeCursor ?? + (persistedBinding?.providerInstanceId === resolvedInstanceId + ? persistedBinding.resumeCursor + : undefined)) + : undefined; const effectiveCwd = input.cwd ?? (persistedBinding?.providerInstanceId === resolvedInstanceId @@ -580,6 +629,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( ? "persisted" : "none", "provider.resume_cursor.present": effectiveResumeCursor !== undefined, + "provider.fork.present": effectiveForkFrom !== undefined, "provider.cwd.source": input.cwd !== undefined ? "request" @@ -589,7 +639,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( : "none", "provider.cwd.effective": effectiveCwd ?? "", }); - const adapter = yield* registry.getByInstance(resolvedInstanceId); yield* prepareMcpSession(threadId, resolvedInstanceId); const session = yield* adapter .startSession({ @@ -597,6 +646,7 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( providerInstanceId: resolvedInstanceId, ...(effectiveCwd !== undefined ? { cwd: effectiveCwd } : {}), ...(effectiveResumeCursor !== undefined ? { resumeCursor: effectiveResumeCursor } : {}), + ...(effectiveForkFrom !== undefined ? { forkFrom: effectiveForkFrom } : {}), }) .pipe(Effect.onError(() => clearMcpSession(threadId))); diff --git a/apps/server/src/provider/Services/ProviderAdapter.ts b/apps/server/src/provider/Services/ProviderAdapter.ts index 01eeae7b7bd..941e40af3e7 100644 --- a/apps/server/src/provider/Services/ProviderAdapter.ts +++ b/apps/server/src/provider/Services/ProviderAdapter.ts @@ -24,12 +24,14 @@ import type * as Effect from "effect/Effect"; import type * as Stream from "effect/Stream"; export type ProviderSessionModelSwitchMode = "in-session" | "unsupported"; +export type ProviderSessionForkMode = "native" | "unsupported"; export interface ProviderAdapterCapabilities { /** * Declares whether changing the model on an existing session is supported. */ readonly sessionModelSwitch: ProviderSessionModelSwitchMode; + readonly sessionFork?: ProviderSessionForkMode; } export interface ProviderThreadTurnSnapshot { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..e1c2aa149b5 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,6 +26,7 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; +import { supportsSelectedResponseFork } from "@t3tools/client-runtime/thread-forking"; import { parseScopedThreadKey, scopedThreadKey, @@ -113,6 +114,7 @@ import { } from "../types"; import { useTheme } from "../hooks/useTheme"; import { useTurnDiffSummaries } from "../hooks/useTurnDiffSummaries"; +import { useForkThread } from "../hooks/useForkThread"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { buildTemporaryWorktreeBranchName } from "@t3tools/shared/git"; import { useMediaQuery } from "../hooks/useMediaQuery"; @@ -1252,6 +1254,8 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [isForkingResponse, setIsForkingResponse] = useState(false); + const forkFromResponse = useForkThread(); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -5176,6 +5180,28 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const onForkFromResponse = useCallback( + async (sourceTurnId: TurnId) => { + if (!serverThread) return; + setIsForkingResponse(true); + try { + const forkedThreadRef = await forkFromResponse(serverThread, sourceTurnId); + if (forkedThreadRef !== null) { + await navigate({ + to: "/$environmentId/$threadId", + params: { + environmentId: forkedThreadRef.environmentId, + threadId: forkedThreadRef.threadId, + }, + }); + } + } finally { + setIsForkingResponse(false); + } + }, + [forkFromResponse, navigate, serverThread], + ); + const onImplementPlanInNewThread = useCallback(async () => { if ( !activeThread || @@ -5693,6 +5719,9 @@ function ChatViewContent(props: ChatViewProps) { revertTurnCountByUserMessageId={revertTurnCountByUserMessageId} onRevertUserMessage={onRevertUserMessage} isRevertingCheckpoint={isRevertingCheckpoint} + {...(isServerThread && supportsSelectedResponseFork(activeProviderStatus?.driver) + ? { onForkFromResponse, isForkingResponse } + : {})} onImageExpand={onExpandTimelineImage} markdownCwd={gitCwd ?? undefined} resolvedTheme={resolvedTheme} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..99e26896303 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -62,6 +62,10 @@ import { settlePromise, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { + resolveLatestForkableTurnId, + supportsThreadFork, +} from "@t3tools/client-runtime/thread-forking"; import { useLocation, useNavigate, useParams, useRouter } from "@tanstack/react-router"; import { MAX_SIDEBAR_THREAD_PREVIEW_COUNT, @@ -106,6 +110,7 @@ import { isModelPickerOpen } from "../modelPickerVisibility"; import { useShortcutModifierState } from "../shortcutModifierState"; import { readLocalApi } from "../localApi"; import { useComposerDraftStore } from "../composerDraftStore"; +import { useForkThread } from "../hooks/useForkThread"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { useDesktopUpdateState } from "../state/desktopUpdate"; @@ -191,7 +196,7 @@ import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { useIsMobile } from "~/hooks/useMediaQuery"; import { CommandDialogTrigger } from "./ui/command"; import { useClientSettings, useUpdateClientSettings } from "~/hooks/useSettings"; -import { primaryServerKeybindingsAtom } from "../state/server"; +import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { derivePhysicalProjectKey, deriveProjectGroupingOverrideKey, @@ -1110,6 +1115,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); + const serverConfigs = useAtomValue(environmentServerConfigsAtom); + const forkSidebarThread = useForkThread(); const updateSettings = useUpdateClientSettings(); const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -2112,11 +2119,23 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ); const threadWorkspacePath = thread.worktreePath ?? threadProject?.workspaceRoot ?? project.workspaceRoot ?? null; + const providerInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerDriver = + serverConfigs + .get(thread.environmentId) + ?.providers.find((provider) => provider.instanceId === providerInstanceId)?.driver ?? + null; + const forkSupported = supportsThreadFork(providerDriver); + const sourceTurnId = resolveLatestForkableTurnId(thread.latestTurn); const clicked = await api.contextMenu.show( [ ...(thread.branch ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), + ...(forkSupported + ? [{ id: "fork-thread", label: "Fork thread", disabled: sourceTurnId === null }] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "copy-path", label: "Copy Path" }, @@ -2150,6 +2169,15 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec return; } + if (clicked === "fork-thread") { + if (sourceTurnId === null) return; + const forkedThreadRef = await forkSidebarThread(thread, sourceTurnId); + if (forkedThreadRef !== null) { + navigateToThread(forkedThreadRef); + } + return; + } + if (clicked === "rename") { startThreadRename(threadKey, thread.title); return; @@ -2206,10 +2234,13 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec copyPathToClipboard, copyThreadIdToClipboard, deleteThread, + forkSidebarThread, handleNewThread, markThreadUnread, memberProjectByScopedKey, + navigateToThread, project.workspaceRoot, + serverConfigs, startThreadRename, ], ); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..150e02aff8d 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -7,6 +7,10 @@ import { threadWokeAt, } from "@t3tools/client-runtime/state/thread-settled"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + resolveLatestForkableTurnId, + supportsThreadFork, +} from "@t3tools/client-runtime/thread-forking"; import { scopeProjectRef, scopeThreadRef, @@ -82,6 +86,7 @@ import { import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; +import { useForkThread } from "../hooks/useForkThread"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; @@ -1008,6 +1013,7 @@ export default function SidebarV2() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, unsettleThread, snoozeThread, unsnoozeThread, deleteThread } = useThreadActions(); + const forkSidebarThread = useForkThread(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); @@ -1978,6 +1984,15 @@ export default function SidebarV2() { true; const supportsSnooze = serverConfigs.get(thread.environmentId)?.environment.capabilities.threadSnooze === true; + const providerInstanceId = + thread.session?.providerInstanceId ?? thread.modelSelection.instanceId; + const providerDriver = + serverConfigs + .get(thread.environmentId) + ?.providers.find((provider) => provider.instanceId === providerInstanceId)?.driver ?? + null; + const forkSupported = supportsThreadFork(providerDriver); + const sourceTurnId = resolveLatestForkableTurnId(thread.latestTurn); const isSettled = settledThreadKeysRef.current.has(threadKey); const isSnoozed = snoozedThreadKeysRef.current.has(threadKey); // Presets resolve at menu-open time (same as the popover). @@ -2015,6 +2030,9 @@ export default function SidebarV2() { }, ] : []), + ...(forkSupported + ? [{ id: "fork-thread", label: "Fork thread", disabled: sourceTurnId === null }] + : []), { id: "rename", label: "Rename thread" }, { id: "mark-unread", label: "Mark unread" }, { id: "delete", label: "Delete", destructive: true, icon: "trash" }, @@ -2063,6 +2081,14 @@ export default function SidebarV2() { case "unsnooze": attemptUnsnooze(threadRef); return; + case "fork-thread": { + if (sourceTurnId === null) return; + const forkedThreadRef = await forkSidebarThread(thread, sourceTurnId); + if (forkedThreadRef !== null) { + navigateToThread(forkedThreadRef); + } + return; + } case "rename": startThreadRename(threadRef, thread.title); return; @@ -2107,8 +2133,10 @@ export default function SidebarV2() { attemptUnsnooze, confirmThreadDelete, deleteThread, + forkSidebarThread, handleMultiSelectContextMenu, markThreadUnread, + navigateToThread, serverConfigs, startThreadRename, ], diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts index 6d74204bc1c..430e6cd2680 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,3 +1,4 @@ +import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { computeStableMessagesTimelineRows, @@ -5,6 +6,7 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + resolveAssistantMessageForkState, } from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { @@ -260,6 +262,35 @@ describe("resolveAssistantMessageCopyState", () => { }); }); +describe("resolveAssistantMessageForkState", () => { + it("keeps the selected completed assistant turn", () => { + const turnId = TurnId.make("turn-selected"); + + expect( + resolveAssistantMessageForkState({ + turnId, + showForkButton: true, + streaming: false, + }), + ).toEqual({ turnId, visible: true }); + }); + + it("hides the action for streaming, intermediate, and unscoped messages", () => { + const turnId = TurnId.make("turn-selected"); + + expect( + resolveAssistantMessageForkState({ turnId, showForkButton: true, streaming: true }).visible, + ).toBe(false); + expect( + resolveAssistantMessageForkState({ turnId, showForkButton: false, streaming: false }).visible, + ).toBe(false); + expect( + resolveAssistantMessageForkState({ turnId: null, showForkButton: true, streaming: false }) + .visible, + ).toBe(false); + }); +}); + describe("deriveMessagesTimelineRows", () => { it("only enables assistant copy for the terminal assistant message in a turn", () => { const rows = deriveMessagesTimelineRows({ diff --git a/apps/web/src/components/chat/MessagesTimeline.logic.ts b/apps/web/src/components/chat/MessagesTimeline.logic.ts index 3227bac2413..7585d23bdcf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -221,6 +221,21 @@ export function resolveAssistantMessageCopyState({ }; } +export function resolveAssistantMessageForkState({ + turnId, + showForkButton, + streaming, +}: { + turnId: TurnId | null; + showForkButton: boolean; + streaming: boolean; +}) { + return { + turnId, + visible: showForkButton && turnId !== null && !streaming, + }; +} + function deriveTerminalAssistantMessageIds(timelineEntries: ReadonlyArray) { const lastAssistantMessageIdByResponseKey = new Map(); let nullTurnResponseIndex = 0; diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e952..81f85d0b4d8 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -239,6 +239,61 @@ describe("MessagesTimeline", () => { expect(fadedMarkup).toContain("chat-timeline-scroll-fade"); }); + it("renders a fork action for the selected completed assistant response", () => { + const turnId = TurnId.make("turn-fork-point"); + const markup = renderToStaticMarkup( + {}} + timelineEntries={[ + { + id: "entry-assistant-fork-point", + kind: "message", + createdAt: MESSAGE_CREATED_AT, + message: { + id: MessageId.make("message-assistant-fork-point"), + role: "assistant", + text: "Fork from this response.", + turnId, + createdAt: MESSAGE_CREATED_AT, + updatedAt: MESSAGE_CREATED_AT, + streaming: false, + }, + }, + ]} + />, + ); + + expect(markup).toContain('aria-label="Fork from this message"'); + expect(markup).toContain("lucide-git-fork"); + }); + + it("does not render the fork action without a thread fork owner", () => { + const markup = renderToStaticMarkup( + , + ); + + expect(markup).not.toContain('aria-label="Fork from this message"'); + }); + it("keeps assistant changed-files headers sticky below the thread header", () => { const assistantMessageId = MessageId.make("message-assistant-with-files"); const turnId = TurnId.make("turn-with-files"); diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index a429b54deaf..ad9e6ce7cfb 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -45,6 +45,7 @@ import { ChevronRightIcon, CircleAlertIcon, EyeIcon, + GitForkIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -69,6 +70,7 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, + resolveAssistantMessageForkState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, @@ -131,6 +133,8 @@ interface TimelineRowSharedState { skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; + onForkFromResponse: ((turnId: TurnId) => void) | null; + isForkingResponse: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; @@ -169,6 +173,8 @@ interface MessagesTimelineProps { revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; + onForkFromResponse?: ((turnId: TurnId) => void) | undefined; + isForkingResponse?: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; @@ -204,6 +210,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, + onForkFromResponse, + isForkingResponse = false, onImageExpand, activeThreadEnvironmentId, markdownCwd, @@ -426,6 +434,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, + onForkFromResponse: onForkFromResponse ?? null, + isForkingResponse, onImageExpand, onOpenTurnDiff, onToggleTurnFold, @@ -440,6 +450,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, + onForkFromResponse, + isForkingResponse, onImageExpand, onOpenTurnDiff, onToggleTurnFold, @@ -1038,6 +1050,7 @@ function AssistantTimelineRow({ row }: { row: Extract + {!row.message.streaming && ( ; } +function AssistantForkButton({ row }: { row: Extract }) { + const ctx = use(TimelineRowCtx); + const forkState = resolveAssistantMessageForkState({ + turnId: row.message.turnId, + showForkButton: row.showAssistantMeta, + streaming: row.assistantCopyStreaming, + }); + + if (ctx.onForkFromResponse === null || !forkState.visible || forkState.turnId === null) { + return null; + } + + const turnId = forkState.turnId; + + return ( + + ctx.onForkFromResponse?.(turnId)} + /> + } + > + + + Fork thread from this response + + ); +} + function ProposedPlanTimelineRow({ row, }: { diff --git a/apps/web/src/hooks/useForkThread.ts b/apps/web/src/hooks/useForkThread.ts new file mode 100644 index 00000000000..047c2ca4b5f --- /dev/null +++ b/apps/web/src/hooks/useForkThread.ts @@ -0,0 +1,71 @@ +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; +import { truncate } from "@t3tools/shared/String"; +import { useCallback } from "react"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { newThreadId } from "../lib/utils"; +import { threadEnvironment } from "../state/threads"; +import { useAtomCommand } from "../state/use-atom-command"; + +type ForkableThread = Pick; + +// All fork entry points share this lock. A hook-local ref would allow the +// sidebar and message timeline to fork the same source concurrently. +const inFlightForkThreadKeys = new Set(); + +export function useForkThread(): ( + sourceThread: ForkableThread, + sourceTurnId: TurnId, +) => Promise { + const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); + + return useCallback( + async (sourceThread: ForkableThread, sourceTurnId: TurnId): Promise => { + const sourceThreadKey = `${sourceThread.environmentId}:${sourceThread.id}`; + if (inFlightForkThreadKeys.has(sourceThreadKey)) { + return null; + } + + inFlightForkThreadKeys.add(sourceThreadKey); + const nextThreadId = newThreadId(); + try { + const result = await forkThread({ + environmentId: sourceThread.environmentId, + input: { + threadId: nextThreadId, + sourceThreadId: sourceThread.id, + sourceTurnId, + title: truncate(`${sourceThread.title} (fork)`), + createdAt: new Date().toISOString(), + }, + }); + + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not fork thread", + description: + error instanceof Error ? error.message : "The thread could not be forked.", + }), + ); + } + return null; + } + + return scopeThreadRef(sourceThread.environmentId, nextThreadId); + } finally { + inFlightForkThreadKeys.delete(sourceThreadKey); + } + }, + [forkThread], + ); +} diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 4fa05f850e5..f595da57423 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -35,6 +35,10 @@ "types": "./src/platform/index.ts", "default": "./src/platform/index.ts" }, + "./thread-forking": { + "types": "./src/threadForking.ts", + "default": "./src/threadForking.ts" + }, "./relay": { "types": "./src/relay/index.ts", "default": "./src/relay/index.ts" diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index ad25d6544dc..aab53c3efd7 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -32,6 +32,7 @@ export type CreateProjectInput = CommandInput<"project.create">; export type UpdateProjectInput = CommandInput<"project.meta.update">; export type DeleteProjectInput = CommandInput<"project.delete">; export type CreateThreadInput = CommandInput<"thread.create">; +export type ForkThreadInput = CommandInput<"thread.fork">; export type DeleteThreadInput = CommandInput<"thread.delete">; export type ArchiveThreadInput = CommandInput<"thread.archive">; export type UnarchiveThreadInput = CommandInput<"thread.unarchive">; @@ -127,6 +128,18 @@ export const createThread: (input: CreateThreadInput) => CommandEffect = Effect. }); }); +export const forkThread: (input: ForkThreadInput) => CommandEffect = Effect.fn( + "EnvironmentCommands.forkThread", +)(function* (input) { + const metadata = yield* timestampedCommandMetadata(input); + return yield* dispatch({ + ...input, + type: "thread.fork", + commandId: metadata.commandId, + createdAt: metadata.createdAt, + }); +}); + export const deleteThread: (input: DeleteThreadInput) => CommandEffect = Effect.fn( "EnvironmentCommands.deleteThread", )(function* (input) { diff --git a/packages/client-runtime/src/state/threadCommands.ts b/packages/client-runtime/src/state/threadCommands.ts index 6c128eb01ab..4ce36e19bd4 100644 --- a/packages/client-runtime/src/state/threadCommands.ts +++ b/packages/client-runtime/src/state/threadCommands.ts @@ -5,6 +5,7 @@ import { createAtomCommandScheduler, createEnvironmentCommand } from "./runtime. import { type ArchiveThreadInput, type CreateThreadInput, + type ForkThreadInput, type DeleteThreadInput, type InterruptThreadTurnInput, type RespondToThreadApprovalInput, @@ -22,6 +23,7 @@ import { type UpdateThreadMetadataInput, archiveThread, createThread, + forkThread, deleteThread, interruptThreadTurn, respondToThreadApproval, @@ -43,6 +45,7 @@ import type { EnvironmentRegistry } from "../connection/registry.ts"; export type { ArchiveThreadInput, CreateThreadInput, + ForkThreadInput, DeleteThreadInput, InterruptThreadTurnInput, RespondToThreadApprovalInput, @@ -76,6 +79,12 @@ export function createThreadEnvironmentAtoms( scheduler, concurrency, }), + fork: createEnvironmentCommand(runtime, { + label: "environment-data:commands:thread:fork", + execute: (input: ForkThreadInput) => forkThread(input), + scheduler, + concurrency, + }), delete: createEnvironmentCommand(runtime, { label: "environment-data:commands:thread:delete", execute: (input: DeleteThreadInput) => deleteThread(input), diff --git a/packages/client-runtime/src/threadForking.test.ts b/packages/client-runtime/src/threadForking.test.ts new file mode 100644 index 00000000000..c89bf015826 --- /dev/null +++ b/packages/client-runtime/src/threadForking.test.ts @@ -0,0 +1,36 @@ +import { MessageId, ProviderDriverKind, TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { resolveLatestForkableTurnId, supportsThreadFork } from "./threadForking.js"; + +describe("supportsThreadFork", () => { + it("only enables providers with an exact native fork primitive", () => { + expect(supportsThreadFork(ProviderDriverKind.make("codex"))).toBe(true); + expect(supportsThreadFork(ProviderDriverKind.make("claudeAgent"))).toBe(true); + expect(supportsThreadFork(ProviderDriverKind.make("opencode"))).toBe(true); + expect(supportsThreadFork(ProviderDriverKind.make("cursor"))).toBe(false); + expect(supportsThreadFork(ProviderDriverKind.make("grok"))).toBe(false); + expect(supportsThreadFork(null)).toBe(false); + }); +}); + +describe("resolveLatestForkableTurnId", () => { + const completedTurn = { + turnId: TurnId.make("turn-1"), + state: "completed" as const, + requestedAt: "2026-07-23T00:00:00.000Z", + startedAt: "2026-07-23T00:00:01.000Z", + completedAt: "2026-07-23T00:00:02.000Z", + assistantMessageId: MessageId.make("assistant-1"), + }; + + it("uses the latest completed assistant turn as the full-thread clone boundary", () => { + expect(resolveLatestForkableTurnId(completedTurn)).toBe(completedTurn.turnId); + }); + + it("rejects running turns and turns without a completed assistant response", () => { + expect(resolveLatestForkableTurnId({ ...completedTurn, state: "running" })).toBeNull(); + expect(resolveLatestForkableTurnId({ ...completedTurn, assistantMessageId: null })).toBeNull(); + expect(resolveLatestForkableTurnId(null)).toBeNull(); + }); +}); diff --git a/packages/client-runtime/src/threadForking.ts b/packages/client-runtime/src/threadForking.ts new file mode 100644 index 00000000000..7b2333a8d88 --- /dev/null +++ b/packages/client-runtime/src/threadForking.ts @@ -0,0 +1,28 @@ +import { ProviderDriverKind, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; + +const THREAD_FORK_DRIVERS = new Set([ + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), + ProviderDriverKind.make("opencode"), +]); + +export function supportsThreadFork(driverKind: ProviderDriverKind | null | undefined): boolean { + return driverKind !== null && driverKind !== undefined + ? THREAD_FORK_DRIVERS.has(driverKind) + : false; +} + +export const supportsSelectedResponseFork = supportsThreadFork; + +export function resolveLatestForkableTurnId( + latestTurn: OrchestrationLatestTurn | null, +): TurnId | null { + if ( + latestTurn === null || + latestTurn.state === "running" || + latestTurn.assistantMessageId === null + ) { + return null; + } + return latestTurn.turnId; +} diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 84b7a8fa07f..da3830f85cf 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -342,6 +342,12 @@ export const OrchestrationLatestTurn = Schema.Struct({ }); export type OrchestrationLatestTurn = typeof OrchestrationLatestTurn.Type; +export const ThreadForkReference = Schema.Struct({ + threadId: ThreadId, + turnId: Schema.NullOr(TurnId), +}); +export type ThreadForkReference = typeof ThreadForkReference.Type; + export const OrchestrationThread = Schema.Struct({ id: ThreadId, projectId: ProjectId, @@ -353,6 +359,7 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + forkedFrom: Schema.optional(Schema.NullOr(ThreadForkReference)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -409,6 +416,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + forkedFrom: Schema.optional(Schema.NullOr(ThreadForkReference)), latestTurn: Schema.NullOr(OrchestrationLatestTurn), createdAt: IsoDateTime, updatedAt: IsoDateTime, @@ -557,6 +565,16 @@ const ThreadCreateCommand = Schema.Struct({ createdAt: IsoDateTime, }); +const ThreadForkCommand = Schema.Struct({ + type: Schema.Literal("thread.fork"), + commandId: CommandId, + threadId: ThreadId, + sourceThreadId: ThreadId, + sourceTurnId: TurnId, + title: TrimmedNonEmptyString, + createdAt: IsoDateTime, +}); + const ThreadDeleteCommand = Schema.Struct({ type: Schema.Literal("thread.delete"), commandId: CommandId, @@ -751,6 +769,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectMetaUpdateCommand, ProjectDeleteCommand, ThreadCreateCommand, + ThreadForkCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -776,6 +795,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectMetaUpdateCommand, ProjectDeleteCommand, ThreadCreateCommand, + ThreadForkCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -882,6 +902,7 @@ export const OrchestrationEventType = Schema.Literals([ "project.meta-updated", "project.deleted", "thread.created", + "thread.forked", "thread.deleted", "thread.archived", "thread.unarchived", @@ -952,6 +973,21 @@ export const ThreadCreatedPayload = Schema.Struct({ updatedAt: IsoDateTime, }); +export const ThreadForkedPayload = Schema.Struct({ + threadId: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode, + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + forkedFrom: ThreadForkReference, + inheritedMessages: Schema.Array(OrchestrationMessage), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); + export const ThreadDeletedPayload = Schema.Struct({ threadId: ThreadId, deletedAt: IsoDateTime, @@ -1149,6 +1185,11 @@ export const OrchestrationEvent = Schema.Union([ type: Schema.Literal("thread.created"), payload: ThreadCreatedPayload, }), + Schema.Struct({ + ...EventBaseFields, + type: Schema.Literal("thread.forked"), + payload: ThreadForkedPayload, + }), Schema.Struct({ ...EventBaseFields, type: Schema.Literal("thread.deleted"), diff --git a/packages/contracts/src/provider.ts b/packages/contracts/src/provider.ts index 94fb007a7bc..c53e3ca4d9c 100644 --- a/packages/contracts/src/provider.ts +++ b/packages/contracts/src/provider.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ApprovalRequestId, EventId, @@ -50,6 +50,15 @@ export const ProviderSession = Schema.Struct({ }); export type ProviderSession = typeof ProviderSession.Type; +export const ProviderSessionForkSource = Schema.Struct({ + threadId: ThreadId, + sourceTurnId: Schema.optional(TurnId), + /** Zero-based provider response position for adapters whose native fork API uses a message id. */ + sourceTurnIndex: Schema.optional(NonNegativeInt), + resumeCursor: Schema.optional(Schema.Unknown), +}); +export type ProviderSessionForkSource = typeof ProviderSessionForkSource.Type; + export const ProviderSessionStartInput = Schema.Struct({ threadId: ThreadId, provider: Schema.optional(ProviderDriverKind), @@ -58,6 +67,7 @@ export const ProviderSessionStartInput = Schema.Struct({ cwd: Schema.optional(TrimmedNonEmptyString), modelSelection: Schema.optional(ModelSelection), resumeCursor: Schema.optional(Schema.Unknown), + forkFrom: Schema.optional(ProviderSessionForkSource), approvalPolicy: Schema.optional(ProviderApprovalPolicy), sandboxMode: Schema.optional(ProviderSandboxMode), runtimeMode: RuntimeMode,