From b80d8d9c6702a5ca2ef7ef8d1fc4888a458fb586 Mon Sep 17 00:00:00 2001 From: Nassim Najjar Date: Wed, 22 Jul 2026 04:13:23 +0100 Subject: [PATCH 01/10] feat: fork from a selected assistant response --- .../Layers/ProjectionPipeline.ts | 52 +++++ .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 23 ++ .../Layers/ProviderCommandReactor.test.ts | 19 ++ .../Layers/ProviderCommandReactor.ts | 55 ++++- apps/server/src/orchestration/Schemas.ts | 2 + .../src/orchestration/decider.fork.test.ts | 199 ++++++++++++++++++ apps/server/src/orchestration/decider.ts | 67 ++++++ apps/server/src/orchestration/projector.ts | 43 ++++ .../persistence/Layers/ProjectionThreads.ts | 10 + apps/server/src/persistence/Migrations.ts | 2 + .../035_ProjectionThreadForkLineage.test.ts | 29 +++ .../035_ProjectionThreadForkLineage.ts | 13 ++ .../persistence/Services/ProjectionThreads.ts | 2 + .../src/provider/Layers/ClaudeAdapter.test.ts | 106 +++++++++- .../src/provider/Layers/ClaudeAdapter.ts | 109 +++++++++- .../src/provider/Layers/CodexAdapter.ts | 11 + .../Layers/CodexSessionRuntime.test.ts | 58 ++++- .../provider/Layers/CodexSessionRuntime.ts | 32 ++- .../provider/Layers/OpenCodeAdapter.test.ts | 58 ++++- .../src/provider/Layers/OpenCodeAdapter.ts | 54 +++++ .../src/provider/Layers/ProviderService.ts | 63 +++++- .../src/provider/Services/ProviderAdapter.ts | 2 + .../web/src/components/ChatView.logic.test.ts | 13 ++ apps/web/src/components/ChatView.logic.ts | 14 ++ apps/web/src/components/ChatView.tsx | 182 +++++++++++----- apps/web/src/components/RightPanelTabs.tsx | 15 +- .../chat/MessagesTimeline.logic.test.ts | 31 +++ .../components/chat/MessagesTimeline.logic.ts | 15 ++ .../components/chat/MessagesTimeline.test.tsx | 56 +++++ .../src/components/chat/MessagesTimeline.tsx | 49 +++++ apps/web/src/rightPanelStore.test.ts | 19 ++ apps/web/src/rightPanelStore.ts | 51 ++++- .../client-runtime/src/operations/commands.ts | 13 ++ .../src/state/threadCommands.ts | 9 + packages/contracts/src/orchestration.ts | 41 ++++ packages/contracts/src/provider.ts | 12 +- 37 files changed, 1440 insertions(+), 91 deletions(-) create mode 100644 apps/server/src/orchestration/decider.fork.test.ts create mode 100644 apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.test.ts create mode 100644 apps/server/src/persistence/Migrations/035_ProjectionThreadForkLineage.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 1f24a4a0200..2f07b563096 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -603,6 +603,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 +621,35 @@ 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, + 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 +910,27 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti "applyThreadMessagesProjection", )(function* (event, attachmentSideEffects) { switch (event.type) { + case "thread.forked": + yield* Effect.forEach( + event.payload.inheritedMessages, + (message) => + projectionThreadMessageRepository.upsert({ + messageId: message.id, + threadId: event.payload.threadId, + turnId: message.turnId, + role: message.role, + text: message.text, + ...(message.attachments !== undefined + ? { attachments: [...message.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, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d4a24a209ad..bc709419676 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", diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 3d05bef4bdf..d4868509be7 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, @@ -1400,6 +1418,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, @@ -1533,6 +1552,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 +1691,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 +1936,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 +2035,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..b7a81132ff4 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))); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index b6bff8c766a..49fab0ff5e9 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,40 @@ 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 sourceThread = yield* resolveThread(forkedFrom.threadId); + const sourceTurnId = forkedFrom.turnId; + const sourceTurnIndex = + sourceThread != null && sourceTurnId !== null + ? findCompletedTurnIndex(sourceThread.messages, sourceTurnId) + : undefined; + return { + threadId: forkedFrom.threadId, + ...(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 +650,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..94fe176af71 --- /dev/null +++ b/apps/server/src/orchestration/decider.fork.test.ts @@ -0,0 +1,199 @@ +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: [], + 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", + ]); + + 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("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"); + }), + ); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 100369ae6e3..d090aca186a 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, @@ -378,6 +379,72 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }; } + case "thread.fork": { + const sourceThread = yield* requireThread({ + readModel, + command, + threadId: command.sourceThreadId, + }); + 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.`, + }); + } + + const cutoffIndex = sourceThread.messages.findLastIndex( + (message) => message.turnId === sourceTurnId, + ); + const inheritedMessages = sourceThread.messages + .slice(0, cutoffIndex + 1) + .filter((message) => !message.streaming) + .map((message, index) => ({ + ...message, + id: MessageId.make(`${command.threadId}:fork:${index}`), + })); + + 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.ts b/apps/server/src/orchestration/projector.ts index 0504cb36f9a..a42406ac59f 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, @@ -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) => ({ 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..9d39254a947 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -73,7 +73,7 @@ const runtimeMock = { transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, - forkCalls: [] as Array<{ sessionID: string; directory?: string }>, + forkCalls: [] as Array<{ sessionID: string; directory?: string; messageID?: string }>, }, reset() { this.state.startCalls.length = 0; @@ -165,10 +165,22 @@ const OpenCodeRuntimeTestDouble: OpenCodeRuntimeShape = { runtimeMock.state.sessionUpdateCalls.push({ sessionID, permission }); 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); } @@ -353,6 +365,46 @@ 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("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..5c060e04173 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1191,6 +1191,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); @@ -1235,6 +1243,51 @@ 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 assistantMessages = (sourceMessages.data ?? []).filter( + (entry) => entry.info.role === "assistant", + ); + const sourceTurnIndex = input.forkFrom?.sourceTurnIndex; + const sourceMessage = + sourceTurnIndex === undefined + ? assistantMessages.at(-1) + : assistantMessages[sourceTurnIndex]; + if (!sourceMessage) { + 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: sourceMessage.info.id, + }), + ); + const forked = forkedResponse.data; + if (!forked) { + return yield* new OpenCodeRuntimeError({ + operation: "session.fork", + detail: "OpenCode session.fork returned no session payload.", + }); + } + yield* runOpenCodeSdk("session.update", () => + client.session.update({ + sessionID: forked.id, + permission: buildOpenCodePermissionRules(input.runtimeMode), + }), + ); + return { openCodeSession: forked, created: true }; + } + const adopted = resumeSessionId ? yield* runOpenCodeSdk("session.get", () => client.session.get({ sessionID: resumeSessionId }), @@ -1701,6 +1754,7 @@ export function makeOpenCodeAdapter( provider: PROVIDER, capabilities: { sessionModelSwitch: "in-session", + sessionFork: "native", }, startSession, sendTurn, diff --git a/apps/server/src/provider/Layers/ProviderService.ts b/apps/server/src/provider/Layers/ProviderService.ts index 2eaaeb8ce3c..c9e0c9d0a87 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -559,12 +559,64 @@ 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) { + if (requestedForkFrom.sourceTurnId === undefined) { + return undefined; + } + 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 +632,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 +642,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 +649,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.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 57c12959ffb..bff9b9165dd 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,6 +3,7 @@ import { MessageId, ProjectId, ProviderInstanceId, + ProviderDriverKind, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -27,8 +28,20 @@ import { resolveSendEnvMode, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, + supportsSelectedResponseFork, } from "./ChatView.logic"; +describe("supportsSelectedResponseFork", () => { + it("only enables providers with an exact historical fork primitive", () => { + expect(supportsSelectedResponseFork(ProviderDriverKind.make("codex"))).toBe(true); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("claudeAgent"))).toBe(true); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("opencode"))).toBe(true); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("cursor"))).toBe(false); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("grok"))).toBe(false); + expect(supportsSelectedResponseFork(null)).toBe(false); + }); +}); + const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 466c9b24c87..e2f534c27c5 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -54,6 +54,20 @@ export function resolveThreadMetadataUpdateForNextTurn(input: { }; } +const SELECTED_RESPONSE_FORK_DRIVERS = new Set([ + "codex" as ProviderDriverKind, + "claudeAgent" as ProviderDriverKind, + "opencode" as ProviderDriverKind, +]); + +export function supportsSelectedResponseFork( + driverKind: ProviderDriverKind | null | undefined, +): boolean { + return driverKind !== null && driverKind !== undefined + ? SELECTED_RESPONSE_FORK_DRIVERS.has(driverKind) + : false; +} + export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ab1256cddb3..26f3b3ea779 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -266,6 +266,7 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, + supportsSelectedResponseFork, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; @@ -458,6 +459,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + embedded?: boolean; routeKind: "server"; draftId?: never; } @@ -467,6 +469,7 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; + embedded?: boolean; routeKind: "draft"; draftId: DraftId; }; @@ -1123,6 +1126,7 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen, reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, + embedded = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( @@ -1138,6 +1142,7 @@ function ChatViewContent(props: ChatViewProps) { const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); + const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -1252,6 +1257,7 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); + const [isForkingToSide, setIsForkingToSide] = useState(false); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -1494,7 +1500,7 @@ function ChatViewContent(props: ChatViewProps) { [rightPanelState.surfaces], ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); - const rightPanelOpen = rightPanelState.isOpen; + const rightPanelOpen = !embedded && rightPanelState.isOpen; const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -5176,6 +5182,45 @@ function ChatViewContent(props: ChatViewProps) { ], ); + const onForkToSide = useCallback( + async (sourceTurnId: TurnId) => { + if (!activeThread || !activeThreadRef || !isServerThread || isForkingToSide) return; + + const nextThreadId = newThreadId(); + const title = truncate(`${activeThread.title} (fork)`); + setIsForkingToSide(true); + const result = await forkThread({ + environmentId: activeThread.environmentId, + input: { + threadId: nextThreadId, + sourceThreadId: activeThread.id, + sourceTurnId, + title, + createdAt: new Date().toISOString(), + }, + }); + setIsForkingToSide(false); + + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not fork side chat", + description: + error instanceof Error ? error.message : "The thread could not be forked.", + }), + ); + } + return; + } + + useRightPanelStore.getState().openThread(activeThreadRef, nextThreadId, title); + }, + [activeThread, activeThreadRef, forkThread, isForkingToSide, isServerThread], + ); + const onImplementPlanInNewThread = useCallback(async () => { if ( !activeThread || @@ -5531,7 +5576,15 @@ function ChatViewContent(props: ChatViewProps) { ); const rightPanelContent = activeThreadRef ? ( - activeRightPanelSurface?.kind === "preview" ? ( + activeRightPanelSurface?.kind === "thread" ? ( + + ) : activeRightPanelSurface?.kind === "preview" ? ( {/* Top bar */} -
- {!rightPanelOpen ? panelLayoutControls : null} - -
+ {!embedded ? ( +
+ {!rightPanelOpen ? panelLayoutControls : null} + +
+ ) : null} {/* end horizontal flex container */} - {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ))} + {!embedded + ? mountedTerminalThreadRefs.map( + ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( + + ), + ) + : null} {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 83524009c19..27eecfaa4e4 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,6 +1,15 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; +import { + ClipboardList, + FileDiff, + Files, + GitFork, + Globe2, + Plus, + TerminalSquare, + X, +} from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -205,6 +214,8 @@ function surfaceTitle( ); case "plan": return "Plan"; + case "thread": + return surface.title; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -266,6 +277,8 @@ function SurfaceIcon({ return ; case "plan": return ; + case "thread": + return ; } } 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..0b076ef2818 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -239,6 +239,62 @@ describe("MessagesTimeline", () => { expect(fadedMarkup).toContain("chat-timeline-scroll-fade"); }); + it("renders the fork action beneath a 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"); + expect(markup).toContain('aria-label="Copy link"'); + }); + + it("does not render the fork action without a side-chat 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..44ad9f07fec 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; + onForkToSide: ((turnId: TurnId) => void) | null; + isForkingToSide: 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; + onForkToSide?: ((turnId: TurnId) => void) | undefined; + isForkingToSide?: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; @@ -204,6 +210,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, + onForkToSide, + isForkingToSide = false, onImageExpand, activeThreadEnvironmentId, markdownCwd, @@ -426,6 +434,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, + onForkToSide: onForkToSide ?? null, + isForkingToSide, onImageExpand, onOpenTurnDiff, onToggleTurnFold, @@ -440,6 +450,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, + onForkToSide, + isForkingToSide, 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.onForkToSide === null || !forkState.visible || forkState.turnId === null) { + return null; + } + + const turnId = forkState.turnId; + + return ( + + ctx.onForkToSide?.(turnId)} + /> + } + > + + + Fork to side chat + + ); +} + function ProposedPlanTimelineRow({ row, }: { diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index c7457cfd304..e50ed5926fd 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -292,6 +292,25 @@ describe("rightPanelStore", () => { }); }); + it("opens a forked thread as a peer surface and refreshes its title", () => { + const forkId = ThreadId.make("thread-fork"); + useRightPanelStore.getState().openThread(refA, forkId, "Initial fork"); + useRightPanelStore.getState().openThread(refA, forkId, "Renamed fork"); + + expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ + isOpen: true, + activeSurfaceId: "thread:thread-fork", + surfaces: [ + { + id: "thread:thread-fork", + kind: "thread", + threadId: forkId, + title: "Renamed fork", + }, + ], + }); + }); + it("tracks one surface per terminal session", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().openTerminal(refA, "term-2"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 70d163306cc..2e6f7c0c13a 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -8,13 +8,21 @@ * workspace paths, and diff/plan/files remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; +export const RIGHT_PANEL_KINDS = [ + "plan", + "diff", + "files", + "file", + "preview", + "terminal", + "thread", +] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -37,10 +45,11 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" }; + | { id: "plan"; kind: "plan" } + | { id: `thread:${string}`; kind: "thread"; threadId: ThreadId; title: string }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 7; +const RIGHT_PANEL_STORAGE_VERSION = 8; export interface ThreadRightPanelState { isOpen: boolean; @@ -50,7 +59,11 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; - open: (ref: ScopedThreadRef, kind: Exclude) => void; + open: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; + openThread: (ref: ScopedThreadRef, threadId: ThreadId, title: string) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; @@ -72,7 +85,10 @@ interface RightPanelStoreState { show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; - toggle: (ref: ScopedThreadRef, kind: Exclude) => void; + toggle: ( + ref: ScopedThreadRef, + kind: Exclude, + ) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -83,7 +99,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -120,6 +136,13 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({ activeTerminalId: terminalId, }); +const threadSurface = (threadId: ThreadId, title: string): RightPanelSurface => ({ + id: `thread:${threadId}`, + kind: "thread", + threadId, + title, +}); + const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -259,6 +282,20 @@ export const useRightPanelStore = create()( return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), })), + openThread: (ref, threadId, title) => + set((state) => ({ + byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { + const surface = threadSurface(threadId, title); + const existing = current.surfaces.some((entry) => entry.id === surface.id); + return { + isOpen: true, + activeSurfaceId: surface.id, + surfaces: existing + ? current.surfaces.map((entry) => (entry.id === surface.id ? surface : entry)) + : [...current.surfaces, surface], + }; + }), + })), openFile: (ref, relativePath, line) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { 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/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, From 24f4453ba6f1b2c77594471d3f0fa1f20acc64c2 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 11:16:58 -0700 Subject: [PATCH 02/10] feat(mobile): fork from assistant responses --- .../features/threads/ThreadDetailScreen.tsx | 9 +++ .../src/features/threads/ThreadFeed.tsx | 38 ++++++++++++ .../features/threads/ThreadRouteScreen.tsx | 60 ++++++++++++++++++- .../web/src/components/ChatView.logic.test.ts | 13 ---- apps/web/src/components/ChatView.logic.ts | 14 ----- apps/web/src/components/ChatView.tsx | 2 +- packages/client-runtime/package.json | 4 ++ .../client-runtime/src/threadForking.test.ts | 15 +++++ packages/client-runtime/src/threadForking.ts | 15 +++++ 9 files changed, 140 insertions(+), 30 deletions(-) create mode 100644 packages/client-runtime/src/threadForking.test.ts create mode 100644 packages/client-runtime/src/threadForking.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index 5cb04290f66..b2e411f5178 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -13,6 +13,7 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, + TurnId, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -77,6 +78,8 @@ export interface ThreadDetailScreenProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; + readonly onForkFromTurn?: (turnId: TurnId) => void; + readonly isForkingFromTurn?: boolean; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -371,6 +374,12 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} skills={selectedProviderSkills} + {...(props.onForkFromTurn + ? { + onForkFromTurn: props.onForkFromTurn, + isForkingFromTurn: props.isForkingFromTurn ?? false, + } + : {})} /> ) : ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 37a8639fdbd..3fecaf91052 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -141,6 +141,8 @@ export interface ThreadFeedProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly skills?: ReadonlyArray; + readonly onForkFromTurn?: (turnId: TurnId) => void; + readonly isForkingFromTurn?: boolean; } function MessageAttachmentImage(props: { @@ -807,6 +809,8 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; + readonly onForkFromTurn: ((turnId: TurnId) => void) | null; + readonly isForkingFromTurn: boolean; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -978,6 +982,32 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> + {message.turnId !== null && props.onForkFromTurn !== null ? ( + { + if (message.turnId !== null) { + props.onForkFromTurn?.(message.turnId); + } + }} + > + + + ) : null} {timestampLabel} @@ -1389,6 +1419,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, userBubbleColor, viewportWidth, + canForkFromTurn: props.onForkFromTurn !== undefined, + isForkingFromTurn: props.isForkingFromTurn ?? false, }), [ copiedRowId, @@ -1398,6 +1430,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, userBubbleColor, viewportWidth, + props.onForkFromTurn, + props.isForkingFromTurn, ], ); const reportHeaderMaterialVisibility = useCallback( @@ -1650,6 +1684,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, + onForkFromTurn: props.onForkFromTurn ?? null, + isForkingFromTurn: props.isForkingFromTurn ?? false, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1671,6 +1707,8 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, + props.onForkFromTurn, + props.isForkingFromTurn, onPressImage, onToggleTurnFold, onToggleWorkGroup, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 7fb4740ddce..0da9a878a20 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -6,10 +6,14 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; +import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type ProjectScript, type TurnId } from "@t3tools/contracts"; +import { supportsSelectedResponseFork } from "@t3tools/client-runtime/thread-forking"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { Platform, ScrollView, View } from "react-native"; +import { truncate } from "@t3tools/shared/String"; +import * as Haptics from "expo-haptics"; +import { Alert, Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { useEnvironmentQuery } from "../../state/query"; @@ -59,6 +63,7 @@ import { useSelectedThreadRequests } from "../../state/use-selected-thread-reque import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; +import { uuidv4 } from "../../lib/uuid"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAdaptiveWorkspaceLayout, @@ -196,6 +201,8 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); + const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); + const [forkingTurnId, setForkingTurnId] = useState(null); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -478,6 +485,44 @@ function ThreadRouteContent( }, }); }, [interruptThreadTurn, selectedThread]); + const handleForkFromTurn = useCallback( + async (sourceTurnId: TurnId) => { + if (selectedThread === null || forkingTurnId !== null) { + return; + } + + const nextThreadId = ThreadId.make(uuidv4()); + setForkingTurnId(sourceTurnId); + void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); + try { + const result = await forkThread({ + environmentId: selectedThread.environmentId, + input: { + threadId: nextThreadId, + sourceThreadId: selectedThread.id, + sourceTurnId, + title: truncate(`${selectedThread.title} (fork)`), + createdAt: new Date().toISOString(), + }, + }); + if (result._tag === "Failure") { + const error = Cause.squash(result.cause); + Alert.alert( + "Could not fork thread", + error instanceof Error ? error.message : "The thread could not be forked.", + ); + return; + } + navigation.navigate("Thread", { + environmentId: String(selectedThread.environmentId), + threadId: String(nextThreadId), + }); + } finally { + setForkingTurnId(null); + } + }, + [forkThread, forkingTurnId, navigation, selectedThread], + ); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { @@ -741,6 +786,11 @@ function ThreadRouteContent( connectionState: routeConnectionState, }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; + const selectedProviderDriver = + serverConfig?.providers.find( + (provider) => provider.instanceId === selectedThread.modelSelection.instanceId, + )?.driver ?? null; + const canForkSelectedResponse = supportsSelectedResponseFork(selectedProviderDriver); const renderThreadRouteBody = (showActionControls: boolean) => ( <> @@ -781,6 +831,12 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} + {...(canForkSelectedResponse + ? { + onForkFromTurn: handleForkFromTurn, + isForkingFromTurn: forkingTurnId !== null, + } + : {})} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index bff9b9165dd..57c12959ffb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -3,7 +3,6 @@ import { MessageId, ProjectId, ProviderInstanceId, - ProviderDriverKind, ThreadId, TurnId, } from "@t3tools/contracts"; @@ -28,20 +27,8 @@ import { resolveSendEnvMode, shouldShowBranchMismatchBanner, shouldWriteThreadErrorToCurrentServerThread, - supportsSelectedResponseFork, } from "./ChatView.logic"; -describe("supportsSelectedResponseFork", () => { - it("only enables providers with an exact historical fork primitive", () => { - expect(supportsSelectedResponseFork(ProviderDriverKind.make("codex"))).toBe(true); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("claudeAgent"))).toBe(true); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("opencode"))).toBe(true); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("cursor"))).toBe(false); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("grok"))).toBe(false); - expect(supportsSelectedResponseFork(null)).toBe(false); - }); -}); - const environmentId = EnvironmentId.make("environment-local"); const projectId = ProjectId.make("project-1"); const threadId = ThreadId.make("thread-1"); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index e2f534c27c5..466c9b24c87 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -54,20 +54,6 @@ export function resolveThreadMetadataUpdateForNextTurn(input: { }; } -const SELECTED_RESPONSE_FORK_DRIVERS = new Set([ - "codex" as ProviderDriverKind, - "claudeAgent" as ProviderDriverKind, - "opencode" as ProviderDriverKind, -]); - -export function supportsSelectedResponseFork( - driverKind: ProviderDriverKind | null | undefined, -): boolean { - return driverKind !== null && driverKind !== undefined - ? SELECTED_RESPONSE_FORK_DRIVERS.has(driverKind) - : false; -} - export function buildLocalDraftThread( threadId: ThreadId, draftThread: DraftThreadState, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 26f3b3ea779..7ce7cd099a0 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, @@ -266,7 +267,6 @@ import { resolveSendEnvMode, revokeBlobPreviewUrl, revokeUserMessagePreviewUrls, - supportsSelectedResponseFork, waitForStartedServerThread, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; 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/threadForking.test.ts b/packages/client-runtime/src/threadForking.test.ts new file mode 100644 index 00000000000..1e50bb0b268 --- /dev/null +++ b/packages/client-runtime/src/threadForking.test.ts @@ -0,0 +1,15 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { supportsSelectedResponseFork } from "./threadForking.js"; + +describe("supportsSelectedResponseFork", () => { + it("only enables providers with an exact historical fork primitive", () => { + expect(supportsSelectedResponseFork(ProviderDriverKind.make("codex"))).toBe(true); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("claudeAgent"))).toBe(true); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("opencode"))).toBe(true); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("cursor"))).toBe(false); + expect(supportsSelectedResponseFork(ProviderDriverKind.make("grok"))).toBe(false); + expect(supportsSelectedResponseFork(null)).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/threadForking.ts b/packages/client-runtime/src/threadForking.ts new file mode 100644 index 00000000000..9058b441933 --- /dev/null +++ b/packages/client-runtime/src/threadForking.ts @@ -0,0 +1,15 @@ +import { ProviderDriverKind } from "@t3tools/contracts"; + +const SELECTED_RESPONSE_FORK_DRIVERS = new Set([ + ProviderDriverKind.make("codex"), + ProviderDriverKind.make("claudeAgent"), + ProviderDriverKind.make("opencode"), +]); + +export function supportsSelectedResponseFork( + driverKind: ProviderDriverKind | null | undefined, +): boolean { + return driverKind !== null && driverKind !== undefined + ? SELECTED_RESPONSE_FORK_DRIVERS.has(driverKind) + : false; +} From 43079884eb26321b67914165985edc331a708af6 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 13:32:23 -0700 Subject: [PATCH 03/10] fix: preserve fork history across providers and restarts --- apps/server/src/attachmentStore.test.ts | 8 + apps/server/src/attachmentStore.ts | 17 ++ .../Layers/ProjectionPipeline.test.ts | 174 ++++++++++++++++++ .../Layers/ProjectionPipeline.ts | 54 +++++- .../Layers/ProjectionSnapshotQuery.test.ts | 114 ++++++++++++ .../Layers/ProjectionSnapshotQuery.ts | 40 +++- .../Layers/ProviderCommandReactor.test.ts | 113 ++++++++++++ .../Layers/ProviderCommandReactor.ts | 16 +- .../src/orchestration/decider.fork.test.ts | 23 ++- apps/server/src/orchestration/decider.ts | 45 ++++- .../provider/Layers/OpenCodeAdapter.test.ts | 19 +- .../src/provider/Layers/OpenCodeAdapter.ts | 50 ++++- 12 files changed, 648 insertions(+), 25 deletions(-) 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..446a1f0b52f 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -660,6 +660,180 @@ 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 }); + yield* fileSystem.writeFileString(sourceAttachmentPath, "fork-owned-image"); + + yield* appendAndProject({ + 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, + }, + }); + + 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 2f07b563096..ed784e46f7c 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -50,6 +50,7 @@ import { } from "../Services/ProjectionPipeline.ts"; import { attachmentRelativePath, + createForkedAttachmentId, parseAttachmentIdFromRelativePath, parseThreadSegmentFromAttachmentId, toSafeThreadAttachmentSegment, @@ -102,6 +103,7 @@ interface ProjectorDefinition { } interface AttachmentSideEffects { + readonly copiedAttachmentRelativePaths: Map; readonly deletedThreadIds: Set; readonly prunedThreadRelativePaths: Map>; } @@ -229,7 +231,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; } @@ -363,6 +365,27 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* .readDirectory(attachmentsRootDir, { recursive: false }) .pipe(Effect.orElseSucceed(() => [] as Array)); + const copyForkedAttachment = Effect.fn("copyForkedAttachment")(function* ( + destinationRelativePath: string, + sourceRelativePath: string, + ) { + if (destinationRelativePath === sourceRelativePath) { + return; + } + yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true }); + yield* fileSystem.copyFile( + path.join(attachmentsRootDir, sourceRelativePath), + path.join(attachmentsRootDir, destinationRelativePath), + ); + }); + + yield* Effect.forEach( + sideEffects.copiedAttachmentRelativePaths.entries(), + ([destinationRelativePath, sourceRelativePath]) => + copyForkedAttachment(destinationRelativePath, sourceRelativePath), + { concurrency: 1 }, + ); + const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")( function* (threadSegment: string, entry: string) { const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); @@ -913,20 +936,36 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti case "thread.forked": yield* Effect.forEach( event.payload.inheritedMessages, - (message) => - projectionThreadMessageRepository.upsert({ + (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, - ...(message.attachments !== undefined - ? { attachments: [...message.attachments] } - : {}), + ...(attachments !== undefined ? { attachments } : {}), isStreaming: message.streaming, createdAt: message.createdAt, updatedAt: message.updatedAt, - }), + }); + }, { concurrency: 1, discard: true }, ); return; @@ -1632,6 +1671,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti event: OrchestrationEvent, ) { const attachmentSideEffects: AttachmentSideEffects = { + copiedAttachmentRelativePaths: new Map(), deletedThreadIds: new Set(), prunedThreadRelativePaths: new Map>(), }; diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index bc709419676..387adac7192 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -449,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 d4868509be7..aa0621776bd 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -1274,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( @@ -1310,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]; @@ -1347,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) { @@ -1428,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: [], diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b7a81132ff4..c9e638bc075 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -496,6 +496,119 @@ 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 Effect.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 Effect.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 Effect.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("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 Effect.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 Effect.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 Effect.runPromise( + harness.engine.dispatch({ + type: "thread.delete", + commandId: CommandId.make("cmd-delete-fork-source"), + threadId: ThreadId.make("thread-1"), + }), + ); + await Effect.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 49fab0ff5e9..cf44582321e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -530,10 +530,24 @@ const make = Effect.gen(function* () { ? yield* Effect.gen(function* () { const sourceThread = yield* resolveThread(forkedFrom.threadId); const sourceTurnId = forkedFrom.turnId; + if (sourceTurnId !== null && sourceThread === undefined) { + return yield* new ProviderAdapterRequestError({ + provider: desiredInfo.driverKind, + method: "thread.turn.start", + detail: `Cannot fork from turn '${sourceTurnId}' because source thread '${forkedFrom.threadId}' is unavailable.`, + }); + } const sourceTurnIndex = - sourceThread != null && sourceTurnId !== null + sourceThread !== undefined && sourceTurnId !== null ? findCompletedTurnIndex(sourceThread.messages, sourceTurnId) : undefined; + if (sourceTurnId !== null && sourceTurnIndex === 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 '${forkedFrom.threadId}'.`, + }); + } return { threadId: forkedFrom.threadId, ...(sourceTurnId !== null ? { sourceTurnId } : {}), diff --git a/apps/server/src/orchestration/decider.fork.test.ts b/apps/server/src/orchestration/decider.fork.test.ts index 94fe176af71..a6e89916334 100644 --- a/apps/server/src/orchestration/decider.fork.test.ts +++ b/apps/server/src/orchestration/decider.fork.test.ts @@ -58,7 +58,15 @@ const seedReadModel = (): OrchestrationReadModel => ({ id: MessageId.make("user-1"), role: "user", text: "First question", - attachments: [], + 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, @@ -159,6 +167,19 @@ it.layer(NodeServices.layer)("thread fork decider", (it) => { "First question", "First answer", ]); + expect(event.payload.inheritedMessages.map((message) => message.turnId)).toEqual([ + TurnId.make("thread-fork:fork-turn:0"), + TurnId.make("thread-fork:fork-turn:0"), + ]); + 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, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index d090aca186a..e4b5a0faae5 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,12 +4,14 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, + TurnId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; 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, @@ -409,13 +411,48 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const cutoffIndex = sourceThread.messages.findLastIndex( (message) => message.turnId === sourceTurnId, ); - const inheritedMessages = sourceThread.messages + const sourceMessages = sourceThread.messages .slice(0, cutoffIndex + 1) - .filter((message) => !message.streaming) - .map((message, index) => ({ + .filter((message) => !message.streaming); + const forkedTurnIds = new Map(); + const inheritedMessages = []; + + for (let index = 0; index < sourceMessages.length; index += 1) { + const message = sourceMessages[index]; + if (!message) { + continue; + } + let forkedTurnId: TurnId | null = null; + if (message.turnId !== null) { + forkedTurnId = + forkedTurnIds.get(message.turnId) ?? + TurnId.make(`${command.threadId}:fork-turn:${forkedTurnIds.size}`); + forkedTurnIds.set(message.turnId, forkedTurnId); + } + + const attachments = + message.attachments === undefined + ? undefined + : yield* Effect.forEach(message.attachments, (attachment) => { + const attachmentId = createForkedAttachmentId(command.threadId, attachment.id); + return attachmentId === null + ? 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}`), - })); + turnId: forkedTurnId, + ...(attachments !== undefined ? { attachments } : {}), + }); + } return { ...(yield* withEventBase({ diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 9d39254a947..03c5ca9b9eb 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`. @@ -289,6 +290,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)); diff --git a/apps/server/src/provider/Layers/OpenCodeAdapter.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.ts index 5c060e04173..d4af2b922e6 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; @@ -1247,15 +1282,12 @@ export function makeOpenCodeAdapter( const sourceMessages = yield* runOpenCodeSdk("session.messages", () => client.session.messages({ sessionID: forkSessionId }), ); - const assistantMessages = (sourceMessages.data ?? []).filter( - (entry) => entry.info.role === "assistant", - ); const sourceTurnIndex = input.forkFrom?.sourceTurnIndex; - const sourceMessage = - sourceTurnIndex === undefined - ? assistantMessages.at(-1) - : assistantMessages[sourceTurnIndex]; - if (!sourceMessage) { + const sourceMessageId = resolveOpenCodeAssistantForkPoint( + sourceMessages.data ?? [], + sourceTurnIndex, + ); + if (!sourceMessageId) { return yield* new OpenCodeRuntimeError({ operation: "session.fork", detail: @@ -1269,7 +1301,7 @@ export function makeOpenCodeAdapter( client.session.fork({ sessionID: forkSessionId, directory, - messageID: sourceMessage.info.id, + messageID: sourceMessageId, }), ); const forked = forkedResponse.data; From db2a4f3f23c0483bfc7b8adc2502ce89cd8e650c Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 13:53:55 -0700 Subject: [PATCH 04/10] fix: harden nested thread forks --- .../features/threads/ThreadRouteScreen.tsx | 7 +- .../features/threads/fork-action-lock.test.ts | 27 +++++++ .../src/features/threads/fork-action-lock.ts | 19 +++++ .../Layers/ProjectionPipeline.test.ts | 24 ++++++- .../Layers/ProjectionPipeline.ts | 64 +++++++++++------ .../Layers/ProviderCommandReactor.test.ts | 67 +++++++++++++++++ .../Layers/ProviderCommandReactor.ts | 67 +++++++++++++---- .../src/orchestration/decider.fork.test.ts | 71 ++++++++++++++++++- apps/server/src/orchestration/decider.ts | 21 +++--- apps/server/src/persistence/Errors.ts | 18 ++++- .../provider/Layers/OpenCodeAdapter.test.ts | 37 ++++++++++ .../src/provider/Layers/OpenCodeAdapter.ts | 38 ++++++---- .../provider/Layers/ProviderService.test.ts | 26 +++++++ .../src/provider/Layers/ProviderService.ts | 3 - 14 files changed, 422 insertions(+), 67 deletions(-) create mode 100644 apps/mobile/src/features/threads/fork-action-lock.test.ts create mode 100644 apps/mobile/src/features/threads/fork-action-lock.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 0da9a878a20..8e2fbcdba6e 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -64,6 +64,7 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { uuidv4 } from "../../lib/uuid"; +import { releaseForkActionLock, tryAcquireForkActionLock } from "./fork-action-lock"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAdaptiveWorkspaceLayout, @@ -203,6 +204,7 @@ function ThreadRouteContent( const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); const [forkingTurnId, setForkingTurnId] = useState(null); + const forkingTurnIdRef = useRef(null); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -487,7 +489,7 @@ function ThreadRouteContent( }, [interruptThreadTurn, selectedThread]); const handleForkFromTurn = useCallback( async (sourceTurnId: TurnId) => { - if (selectedThread === null || forkingTurnId !== null) { + if (selectedThread === null || !tryAcquireForkActionLock(forkingTurnIdRef, sourceTurnId)) { return; } @@ -518,10 +520,11 @@ function ThreadRouteContent( threadId: String(nextThreadId), }); } finally { + releaseForkActionLock(forkingTurnIdRef, sourceTurnId); setForkingTurnId(null); } }, - [forkThread, forkingTurnId, navigation, selectedThread], + [forkThread, navigation, selectedThread], ); const handleOpenTerminal = useCallback( diff --git a/apps/mobile/src/features/threads/fork-action-lock.test.ts b/apps/mobile/src/features/threads/fork-action-lock.test.ts new file mode 100644 index 00000000000..85ce6ca99fd --- /dev/null +++ b/apps/mobile/src/features/threads/fork-action-lock.test.ts @@ -0,0 +1,27 @@ +import { TurnId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + releaseForkActionLock, + tryAcquireForkActionLock, + type ForkActionLock, +} from "./fork-action-lock"; + +describe("fork action lock", () => { + it("rejects a second action until the first one releases", () => { + const firstTurnId = TurnId.make("turn-1"); + const secondTurnId = TurnId.make("turn-2"); + const lock: ForkActionLock = { current: null }; + + expect(tryAcquireForkActionLock(lock, firstTurnId)).toBe(true); + expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(false); + expect(lock.current).toBe(firstTurnId); + + releaseForkActionLock(lock, secondTurnId); + expect(lock.current).toBe(firstTurnId); + releaseForkActionLock(lock, firstTurnId); + + expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(true); + expect(lock.current).toBe(secondTurnId); + }); +}); diff --git a/apps/mobile/src/features/threads/fork-action-lock.ts b/apps/mobile/src/features/threads/fork-action-lock.ts new file mode 100644 index 00000000000..692750a1040 --- /dev/null +++ b/apps/mobile/src/features/threads/fork-action-lock.ts @@ -0,0 +1,19 @@ +import type { TurnId } from "@t3tools/contracts"; + +export interface ForkActionLock { + current: TurnId | null; +} + +export function tryAcquireForkActionLock(lock: ForkActionLock, turnId: TurnId): boolean { + if (lock.current !== null) { + return false; + } + lock.current = turnId; + return true; +} + +export function releaseForkActionLock(lock: ForkActionLock, turnId: TurnId): void { + if (lock.current === turnId) { + lock.current = null; + } +} diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 446a1f0b52f..1938375e534 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -731,9 +731,8 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-fork }); yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); - yield* fileSystem.writeFileString(sourceAttachmentPath, "fork-owned-image"); - yield* appendAndProject({ + const savedForkEvent = yield* eventStore.append({ type: "thread.forked", eventId: EventId.make("evt-fork-owner-3"), aggregateKind: "thread", @@ -784,6 +783,27 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-fork }, }); + 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({ diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index ed784e46f7c..6b73d289361 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"; @@ -353,7 +357,7 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } -const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* ( +const materializeForkedAttachments = Effect.fn("materializeForkedAttachments")(function* ( sideEffects: AttachmentSideEffects, ) { const serverConfig = yield* Effect.service(ServerConfig); @@ -361,30 +365,49 @@ const runAttachmentSideEffects = Effect.fn("runAttachmentSideEffects")(function* const path = yield* Effect.service(Path.Path); const attachmentsRootDir = serverConfig.attachmentsDir; - const readAttachmentRootEntries = fileSystem - .readDirectory(attachmentsRootDir, { recursive: false }) - .pipe(Effect.orElseSucceed(() => [] as Array)); - - const copyForkedAttachment = Effect.fn("copyForkedAttachment")(function* ( - destinationRelativePath: string, - sourceRelativePath: string, - ) { - if (destinationRelativePath === sourceRelativePath) { - return; - } - yield* fileSystem.makeDirectory(attachmentsRootDir, { recursive: true }); - yield* fileSystem.copyFile( - path.join(attachmentsRootDir, sourceRelativePath), - path.join(attachmentsRootDir, destinationRelativePath), - ); - }); yield* Effect.forEach( sideEffects.copiedAttachmentRelativePaths.entries(), ([destinationRelativePath, sourceRelativePath]) => - copyForkedAttachment(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, +) { + const serverConfig = yield* Effect.service(ServerConfig); + const fileSystem = yield* Effect.service(FileSystem.FileSystem); + const path = yield* Effect.service(Path.Path); + + const attachmentsRootDir = serverConfig.attachmentsDir; + const readAttachmentRootEntries = fileSystem + .readDirectory(attachmentsRootDir, { recursive: false }) + .pipe(Effect.orElseSucceed(() => [] as Array)); const removeDeletedThreadAttachmentEntry = Effect.fn("removeDeletedThreadAttachmentEntry")( function* (threadSegment: string, entry: string) { @@ -1678,6 +1701,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti 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/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c9e638bc075..b395a080a28 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -550,6 +550,73 @@ describe("ProviderCommandReactor", () => { }); }); + 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 Effect.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 Effect.runPromise( + harness.engine.dispatch({ + type: "thread.fork", + commandId: CommandId.make("cmd-parent-fork"), + threadId: parentForkThreadId, + sourceThreadId, + sourceTurnId, + title: "Parent fork", + createdAt: now, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.fork", + commandId: CommandId.make("cmd-nested-fork"), + threadId: nestedForkThreadId, + sourceThreadId: parentForkThreadId, + sourceTurnId, + title: "Nested fork", + createdAt: now, + }), + ); + await Effect.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"; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index cf44582321e..82281a83c57 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -528,28 +528,65 @@ const make = Effect.gen(function* () { const forkSource = forkedFrom != null ? yield* Effect.gen(function* () { - const sourceThread = yield* resolveThread(forkedFrom.threadId); const sourceTurnId = forkedFrom.turnId; - if (sourceTurnId !== null && sourceThread === undefined) { - return yield* new ProviderAdapterRequestError({ - provider: desiredInfo.driverKind, - method: "thread.turn.start", - detail: `Cannot fork from turn '${sourceTurnId}' because source thread '${forkedFrom.threadId}' is unavailable.`, - }); + 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; - if (sourceTurnId !== null && sourceTurnIndex === 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 '${forkedFrom.threadId}'.`, - }); - } return { - threadId: forkedFrom.threadId, + threadId: sourceThreadId, ...(sourceTurnId !== null ? { sourceTurnId } : {}), ...(sourceTurnIndex !== undefined ? { sourceTurnIndex } : {}), }; diff --git a/apps/server/src/orchestration/decider.fork.test.ts b/apps/server/src/orchestration/decider.fork.test.ts index a6e89916334..887de6b8fdb 100644 --- a/apps/server/src/orchestration/decider.fork.test.ts +++ b/apps/server/src/orchestration/decider.fork.test.ts @@ -168,8 +168,8 @@ it.layer(NodeServices.layer)("thread fork decider", (it) => { "First answer", ]); expect(event.payload.inheritedMessages.map((message) => message.turnId)).toEqual([ - TurnId.make("thread-fork:fork-turn:0"), - TurnId.make("thread-fork:fork-turn:0"), + turnOneId, + turnOneId, ]); expect(event.payload.inheritedMessages[0]?.attachments).toEqual([ { @@ -193,6 +193,44 @@ it.layer(NodeServices.layer)("thread fork decider", (it) => { }), ); + 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 fork at a running turn", () => Effect.gen(function* () { const readModel = seedReadModel(); @@ -217,4 +255,33 @@ it.layer(NodeServices.layer)("thread fork decider", (it) => { 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"); + }), + ); }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index e4b5a0faae5..ff8f2e376fd 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,7 +4,6 @@ import { type OrchestrationCommand, type OrchestrationEvent, type OrchestrationReadModel, - TurnId, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -407,6 +406,17 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" 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, @@ -414,7 +424,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const sourceMessages = sourceThread.messages .slice(0, cutoffIndex + 1) .filter((message) => !message.streaming); - const forkedTurnIds = new Map(); const inheritedMessages = []; for (let index = 0; index < sourceMessages.length; index += 1) { @@ -422,13 +431,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" if (!message) { continue; } - let forkedTurnId: TurnId | null = null; - if (message.turnId !== null) { - forkedTurnId = - forkedTurnIds.get(message.turnId) ?? - TurnId.make(`${command.threadId}:fork-turn:${forkedTurnIds.size}`); - forkedTurnIds.set(message.turnId, forkedTurnId); - } const attachments = message.attachments === undefined @@ -449,7 +451,6 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" inheritedMessages.push({ ...message, id: MessageId.make(`${command.threadId}:fork:${index}`), - turnId: forkedTurnId, ...(attachments !== undefined ? { attachments } : {}), }); } 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/provider/Layers/OpenCodeAdapter.test.ts b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts index 03c5ca9b9eb..1e770509cda 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.test.ts @@ -74,6 +74,7 @@ const runtimeMock = { transientErrorSessionIds: new Set(), sessionDirectoryById: new Map(), sessionUpdateCalls: [] as Array<{ sessionID: string; permission: unknown }>, + sessionUpdateError: null as Error | null, forkCalls: [] as Array<{ sessionID: string; directory?: string; messageID?: string }>, }, reset() { @@ -94,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; }, }; @@ -164,6 +166,9 @@ 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 ({ @@ -422,6 +427,38 @@ it.layer(OpenCodeAdapterTestLayer)("OpenCodeAdapterLive", (it) => { }), ); + 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 d4af2b922e6..b309be47c23 100644 --- a/apps/server/src/provider/Layers/OpenCodeAdapter.ts +++ b/apps/server/src/provider/Layers/OpenCodeAdapter.ts @@ -1257,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", () => @@ -1311,12 +1335,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 }; } @@ -1371,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 }; } 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 c9e0c9d0a87..bdb8f6f7012 100644 --- a/apps/server/src/provider/Layers/ProviderService.ts +++ b/apps/server/src/provider/Layers/ProviderService.ts @@ -570,9 +570,6 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( yield* directory.getBinding(requestedForkFrom.threadId), ); if (!sourceBinding) { - if (requestedForkFrom.sourceTurnId === undefined) { - return undefined; - } return yield* toValidationError( "ProviderService.startSession", `Cannot fork thread '${requestedForkFrom.threadId}' because it has no persisted provider binding.`, From 5fc1c36d272985dbf313bcda0586c3ccbc2c1f9f Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 14:13:51 -0700 Subject: [PATCH 05/10] fix remaining thread fork races and history gaps --- .../features/threads/ThreadRouteScreen.tsx | 7 +- .../features/threads/fork-action-lock.test.ts | 27 ----- .../src/features/threads/fork-action-lock.ts | 19 ---- .../src/orchestration/decider.fork.test.ts | 38 +++++++ apps/server/src/orchestration/decider.ts | 10 +- .../src/orchestration/projector.test.ts | 103 +++++++++++++++++- apps/server/src/orchestration/projector.ts | 11 +- apps/web/src/components/ChatView.tsx | 72 +++++++----- .../client-runtime/src/threadForking.test.ts | 28 ++++- packages/client-runtime/src/threadForking.ts | 20 +++- 10 files changed, 243 insertions(+), 92 deletions(-) delete mode 100644 apps/mobile/src/features/threads/fork-action-lock.test.ts delete mode 100644 apps/mobile/src/features/threads/fork-action-lock.ts diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index 8e2fbcdba6e..fca1eb0853a 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -9,7 +9,11 @@ import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } fro import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; import { EnvironmentId, ThreadId, type ProjectScript, type TurnId } from "@t3tools/contracts"; -import { supportsSelectedResponseFork } from "@t3tools/client-runtime/thread-forking"; +import { + releaseForkActionLock, + supportsSelectedResponseFork, + tryAcquireForkActionLock, +} from "@t3tools/client-runtime/thread-forking"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; import * as Haptics from "expo-haptics"; @@ -64,7 +68,6 @@ import { useSelectedThreadWorktree } from "../../state/use-selected-thread-workt import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; import { uuidv4 } from "../../lib/uuid"; -import { releaseForkActionLock, tryAcquireForkActionLock } from "./fork-action-lock"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAdaptiveWorkspaceLayout, diff --git a/apps/mobile/src/features/threads/fork-action-lock.test.ts b/apps/mobile/src/features/threads/fork-action-lock.test.ts deleted file mode 100644 index 85ce6ca99fd..00000000000 --- a/apps/mobile/src/features/threads/fork-action-lock.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { TurnId } from "@t3tools/contracts"; -import { describe, expect, it } from "vite-plus/test"; - -import { - releaseForkActionLock, - tryAcquireForkActionLock, - type ForkActionLock, -} from "./fork-action-lock"; - -describe("fork action lock", () => { - it("rejects a second action until the first one releases", () => { - const firstTurnId = TurnId.make("turn-1"); - const secondTurnId = TurnId.make("turn-2"); - const lock: ForkActionLock = { current: null }; - - expect(tryAcquireForkActionLock(lock, firstTurnId)).toBe(true); - expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(false); - expect(lock.current).toBe(firstTurnId); - - releaseForkActionLock(lock, secondTurnId); - expect(lock.current).toBe(firstTurnId); - releaseForkActionLock(lock, firstTurnId); - - expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(true); - expect(lock.current).toBe(secondTurnId); - }); -}); diff --git a/apps/mobile/src/features/threads/fork-action-lock.ts b/apps/mobile/src/features/threads/fork-action-lock.ts deleted file mode 100644 index 692750a1040..00000000000 --- a/apps/mobile/src/features/threads/fork-action-lock.ts +++ /dev/null @@ -1,19 +0,0 @@ -import type { TurnId } from "@t3tools/contracts"; - -export interface ForkActionLock { - current: TurnId | null; -} - -export function tryAcquireForkActionLock(lock: ForkActionLock, turnId: TurnId): boolean { - if (lock.current !== null) { - return false; - } - lock.current = turnId; - return true; -} - -export function releaseForkActionLock(lock: ForkActionLock, turnId: TurnId): void { - if (lock.current === turnId) { - lock.current = null; - } -} diff --git a/apps/server/src/orchestration/decider.fork.test.ts b/apps/server/src/orchestration/decider.fork.test.ts index 887de6b8fdb..7dbaef1b576 100644 --- a/apps/server/src/orchestration/decider.fork.test.ts +++ b/apps/server/src/orchestration/decider.fork.test.ts @@ -284,4 +284,42 @@ it.layer(NodeServices.layer)("thread fork decider", (it) => { 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 ff8f2e376fd..974faad4779 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -438,10 +438,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : yield* Effect.forEach(message.attachments, (attachment) => { const attachmentId = createForkedAttachmentId(command.threadId, attachment.id); return attachmentId === null - ? new OrchestrationCommandInvariantError({ - commandType: command.type, - detail: `Cannot fork attachment '${attachment.id}' into thread '${command.threadId}'.`, - }) + ? Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Cannot fork attachment '${attachment.id}' into thread '${command.threadId}'.`, + }), + ) : Effect.succeed({ ...attachment, id: attachmentId, 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 a42406ac59f..e78ffc51af5 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -34,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") { @@ -89,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; } @@ -529,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, }), }; @@ -727,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/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 7ce7cd099a0..ffcb4fae75c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,7 +26,11 @@ 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 { + releaseForkActionLock, + supportsSelectedResponseFork, + tryAcquireForkActionLock, +} from "@t3tools/client-runtime/thread-forking"; import { parseScopedThreadKey, scopedThreadKey, @@ -1258,6 +1262,7 @@ function ChatViewContent(props: ChatViewProps) { const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); const [isForkingToSide, setIsForkingToSide] = useState(false); + const forkingToSideTurnIdRef = useRef(null); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -5184,41 +5189,52 @@ function ChatViewContent(props: ChatViewProps) { const onForkToSide = useCallback( async (sourceTurnId: TurnId) => { - if (!activeThread || !activeThreadRef || !isServerThread || isForkingToSide) return; + if ( + !activeThread || + !activeThreadRef || + !isServerThread || + !tryAcquireForkActionLock(forkingToSideTurnIdRef, sourceTurnId) + ) { + return; + } const nextThreadId = newThreadId(); const title = truncate(`${activeThread.title} (fork)`); setIsForkingToSide(true); - const result = await forkThread({ - environmentId: activeThread.environmentId, - input: { - threadId: nextThreadId, - sourceThreadId: activeThread.id, - sourceTurnId, - title, - createdAt: new Date().toISOString(), - }, - }); - setIsForkingToSide(false); + try { + const result = await forkThread({ + environmentId: activeThread.environmentId, + input: { + threadId: nextThreadId, + sourceThreadId: activeThread.id, + sourceTurnId, + title, + createdAt: new Date().toISOString(), + }, + }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not fork side chat", - description: - error instanceof Error ? error.message : "The thread could not be forked.", - }), - ); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not fork side chat", + description: + error instanceof Error ? error.message : "The thread could not be forked.", + }), + ); + } + return; } - return; - } - useRightPanelStore.getState().openThread(activeThreadRef, nextThreadId, title); + useRightPanelStore.getState().openThread(activeThreadRef, nextThreadId, title); + } finally { + releaseForkActionLock(forkingToSideTurnIdRef, sourceTurnId); + setIsForkingToSide(false); + } }, - [activeThread, activeThreadRef, forkThread, isForkingToSide, isServerThread], + [activeThread, activeThreadRef, forkThread, isServerThread], ); const onImplementPlanInNewThread = useCallback(async () => { diff --git a/packages/client-runtime/src/threadForking.test.ts b/packages/client-runtime/src/threadForking.test.ts index 1e50bb0b268..cfd37bdb542 100644 --- a/packages/client-runtime/src/threadForking.test.ts +++ b/packages/client-runtime/src/threadForking.test.ts @@ -1,7 +1,12 @@ -import { ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { supportsSelectedResponseFork } from "./threadForking.js"; +import { + releaseForkActionLock, + supportsSelectedResponseFork, + tryAcquireForkActionLock, + type ForkActionLock, +} from "./threadForking.js"; describe("supportsSelectedResponseFork", () => { it("only enables providers with an exact historical fork primitive", () => { @@ -13,3 +18,22 @@ describe("supportsSelectedResponseFork", () => { expect(supportsSelectedResponseFork(null)).toBe(false); }); }); + +describe("fork action lock", () => { + it("rejects a second action until the first one releases", () => { + const firstTurnId = TurnId.make("turn-1"); + const secondTurnId = TurnId.make("turn-2"); + const lock: ForkActionLock = { current: null }; + + expect(tryAcquireForkActionLock(lock, firstTurnId)).toBe(true); + expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(false); + expect(lock.current).toBe(firstTurnId); + + releaseForkActionLock(lock, secondTurnId); + expect(lock.current).toBe(firstTurnId); + releaseForkActionLock(lock, firstTurnId); + + expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(true); + expect(lock.current).toBe(secondTurnId); + }); +}); diff --git a/packages/client-runtime/src/threadForking.ts b/packages/client-runtime/src/threadForking.ts index 9058b441933..ca6837572cc 100644 --- a/packages/client-runtime/src/threadForking.ts +++ b/packages/client-runtime/src/threadForking.ts @@ -1,4 +1,4 @@ -import { ProviderDriverKind } from "@t3tools/contracts"; +import { ProviderDriverKind, type TurnId } from "@t3tools/contracts"; const SELECTED_RESPONSE_FORK_DRIVERS = new Set([ ProviderDriverKind.make("codex"), @@ -13,3 +13,21 @@ export function supportsSelectedResponseFork( ? SELECTED_RESPONSE_FORK_DRIVERS.has(driverKind) : false; } + +export interface ForkActionLock { + current: TurnId | null; +} + +export function tryAcquireForkActionLock(lock: ForkActionLock, turnId: TurnId): boolean { + if (lock.current !== null) { + return false; + } + lock.current = turnId; + return true; +} + +export function releaseForkActionLock(lock: ForkActionLock, turnId: TurnId): void { + if (lock.current === turnId) { + lock.current = null; + } +} From 617237246890b227143573fbfaa569a1c5732eb9 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 16:27:10 -0700 Subject: [PATCH 06/10] fix: reject forks from deleted threads --- .../src/orchestration/decider.fork.test.ts | 23 +++++++++++++++++++ apps/server/src/orchestration/decider.ts | 6 +++++ 2 files changed, 29 insertions(+) diff --git a/apps/server/src/orchestration/decider.fork.test.ts b/apps/server/src/orchestration/decider.fork.test.ts index 7dbaef1b576..d109279ea27 100644 --- a/apps/server/src/orchestration/decider.fork.test.ts +++ b/apps/server/src/orchestration/decider.fork.test.ts @@ -231,6 +231,29 @@ it.layer(NodeServices.layer)("thread fork decider", (it) => { }), ); + 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(); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 974faad4779..bb7fa3b0a98 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -386,6 +386,12 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" 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, From 40d3a047cba7cc7487b48eac462b178867708b27 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 19:11:51 -0700 Subject: [PATCH 07/10] Simplify thread forking to sidebar cloning --- .../features/threads/ThreadDetailScreen.tsx | 9 - .../src/features/threads/ThreadFeed.tsx | 38 ---- .../features/threads/ThreadRouteScreen.tsx | 66 +----- apps/web/src/components/ChatView.tsx | 198 +++++------------- apps/web/src/components/RightPanelTabs.tsx | 15 +- apps/web/src/components/Sidebar.tsx | 33 ++- apps/web/src/components/SidebarV2.tsx | 28 +++ .../chat/MessagesTimeline.logic.test.ts | 31 --- .../components/chat/MessagesTimeline.logic.ts | 15 -- .../components/chat/MessagesTimeline.test.tsx | 56 ----- .../src/components/chat/MessagesTimeline.tsx | 49 ----- apps/web/src/hooks/useForkThread.ts | 69 ++++++ apps/web/src/rightPanelStore.test.ts | 19 -- apps/web/src/rightPanelStore.ts | 51 +---- .../client-runtime/src/threadForking.test.ts | 55 +++-- packages/client-runtime/src/threadForking.ts | 35 ++-- 16 files changed, 237 insertions(+), 530 deletions(-) create mode 100644 apps/web/src/hooks/useForkThread.ts diff --git a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx index b2e411f5178..5cb04290f66 100644 --- a/apps/mobile/src/features/threads/ThreadDetailScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadDetailScreen.tsx @@ -13,7 +13,6 @@ import type { RuntimeMode, ServerConfig as T3ServerConfig, ThreadId, - TurnId, } from "@t3tools/contracts"; import * as Haptics from "expo-haptics"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; @@ -78,8 +77,6 @@ export interface ThreadDetailScreenProps { readonly onRemoveDraftImage: (imageId: string) => void; readonly onStopThread: () => void; readonly onSendMessage: () => Promise; - readonly onForkFromTurn?: (turnId: TurnId) => void; - readonly isForkingFromTurn?: boolean; readonly onReconnectEnvironment: () => void; readonly onUpdateThreadModelSelection: (modelSelection: ModelSelection) => void; readonly onUpdateThreadRuntimeMode: (runtimeMode: RuntimeMode) => void; @@ -374,12 +371,6 @@ export const ThreadDetailScreen = memo(function ThreadDetailScreen(props: Thread usesAutomaticContentInsets={props.usesAutomaticContentInsets} onHeaderMaterialVisibilityChange={props.onHeaderMaterialVisibilityChange} skills={selectedProviderSkills} - {...(props.onForkFromTurn - ? { - onForkFromTurn: props.onForkFromTurn, - isForkingFromTurn: props.isForkingFromTurn ?? false, - } - : {})} /> ) : ( diff --git a/apps/mobile/src/features/threads/ThreadFeed.tsx b/apps/mobile/src/features/threads/ThreadFeed.tsx index 3fecaf91052..37a8639fdbd 100644 --- a/apps/mobile/src/features/threads/ThreadFeed.tsx +++ b/apps/mobile/src/features/threads/ThreadFeed.tsx @@ -141,8 +141,6 @@ export interface ThreadFeedProps { readonly usesAutomaticContentInsets?: boolean; readonly onHeaderMaterialVisibilityChange?: (visible: boolean) => void; readonly skills?: ReadonlyArray; - readonly onForkFromTurn?: (turnId: TurnId) => void; - readonly isForkingFromTurn?: boolean; } function MessageAttachmentImage(props: { @@ -809,8 +807,6 @@ function renderFeedEntry( readonly onToggleTurnFold: (turnId: TurnId) => void; readonly onPressImage: (uri: string, headers?: Record) => void; readonly onMarkdownLinkPress: (href: string) => void; - readonly onForkFromTurn: ((turnId: TurnId) => void) | null; - readonly isForkingFromTurn: boolean; readonly iconSubtleColor: string | import("react-native").ColorValue; readonly userBubbleColor: string | import("react-native").ColorValue; readonly markdownStyles: MarkdownStyleSets; @@ -982,32 +978,6 @@ function renderFeedEntry( buttonSize={28} iconSize={13} /> - {message.turnId !== null && props.onForkFromTurn !== null ? ( - { - if (message.turnId !== null) { - props.onForkFromTurn?.(message.turnId); - } - }} - > - - - ) : null} {timestampLabel} @@ -1419,8 +1389,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, userBubbleColor, viewportWidth, - canForkFromTurn: props.onForkFromTurn !== undefined, - isForkingFromTurn: props.isForkingFromTurn ?? false, }), [ copiedRowId, @@ -1430,8 +1398,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { reviewCommentColors, userBubbleColor, viewportWidth, - props.onForkFromTurn, - props.isForkingFromTurn, ], ); const reportHeaderMaterialVisibility = useCallback( @@ -1684,8 +1650,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { onToggleTurnFold, onPressImage, onMarkdownLinkPress, - onForkFromTurn: props.onForkFromTurn ?? null, - isForkingFromTurn: props.isForkingFromTurn ?? false, iconSubtleColor, userBubbleColor, markdownStyles, @@ -1707,8 +1671,6 @@ export const ThreadFeed = memo(function ThreadFeed(props: ThreadFeedProps) { userBubbleMaxWidth, onCopyWorkRow, onMarkdownLinkPress, - props.onForkFromTurn, - props.isForkingFromTurn, onPressImage, onToggleTurnFold, onToggleWorkGroup, diff --git a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx index fca1eb0853a..7fb4740ddce 100644 --- a/apps/mobile/src/features/threads/ThreadRouteScreen.tsx +++ b/apps/mobile/src/features/threads/ThreadRouteScreen.tsx @@ -6,18 +6,10 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react"; -import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; -import { EnvironmentId, ThreadId, type ProjectScript, type TurnId } from "@t3tools/contracts"; -import { - releaseForkActionLock, - supportsSelectedResponseFork, - tryAcquireForkActionLock, -} from "@t3tools/client-runtime/thread-forking"; +import { EnvironmentId, ThreadId, type ProjectScript } from "@t3tools/contracts"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { truncate } from "@t3tools/shared/String"; -import * as Haptics from "expo-haptics"; -import { Alert, Platform, ScrollView, View } from "react-native"; +import { Platform, ScrollView, View } from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useWorkspaceState } from "../../state/workspace"; import { useEnvironmentQuery } from "../../state/query"; @@ -67,7 +59,6 @@ import { useSelectedThreadRequests } from "../../state/use-selected-thread-reque import { useSelectedThreadWorktree } from "../../state/use-selected-thread-worktree"; import { useThreadComposerState } from "../../state/use-thread-composer-state"; import { threadEnvironment } from "../../state/threads"; -import { uuidv4 } from "../../lib/uuid"; import { projectThreadContentPresentation } from "./threadContentPresentation"; import { useAdaptiveWorkspaceLayout, @@ -205,9 +196,6 @@ function ThreadRouteContent( const gitActions = useSelectedThreadGitActions(); const requests = useSelectedThreadRequests(); const interruptThreadTurn = useAtomCommand(threadEnvironment.interruptTurn, "thread interrupt"); - const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); - const [forkingTurnId, setForkingTurnId] = useState(null); - const forkingTurnIdRef = useRef(null); const navigation = useNavigation(); const params = props.route.params; const environmentIdRaw = firstRouteParam(params.environmentId); @@ -490,45 +478,6 @@ function ThreadRouteContent( }, }); }, [interruptThreadTurn, selectedThread]); - const handleForkFromTurn = useCallback( - async (sourceTurnId: TurnId) => { - if (selectedThread === null || !tryAcquireForkActionLock(forkingTurnIdRef, sourceTurnId)) { - return; - } - - const nextThreadId = ThreadId.make(uuidv4()); - setForkingTurnId(sourceTurnId); - void Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light); - try { - const result = await forkThread({ - environmentId: selectedThread.environmentId, - input: { - threadId: nextThreadId, - sourceThreadId: selectedThread.id, - sourceTurnId, - title: truncate(`${selectedThread.title} (fork)`), - createdAt: new Date().toISOString(), - }, - }); - if (result._tag === "Failure") { - const error = Cause.squash(result.cause); - Alert.alert( - "Could not fork thread", - error instanceof Error ? error.message : "The thread could not be forked.", - ); - return; - } - navigation.navigate("Thread", { - environmentId: String(selectedThread.environmentId), - threadId: String(nextThreadId), - }); - } finally { - releaseForkActionLock(forkingTurnIdRef, sourceTurnId); - setForkingTurnId(null); - } - }, - [forkThread, navigation, selectedThread], - ); const handleOpenTerminal = useCallback( (nextTerminalId?: string | null) => { @@ -792,11 +741,6 @@ function ThreadRouteContent( connectionState: routeConnectionState, }); const serverConfig = routeEnvironmentRuntime?.serverConfig ?? null; - const selectedProviderDriver = - serverConfig?.providers.find( - (provider) => provider.instanceId === selectedThread.modelSelection.instanceId, - )?.driver ?? null; - const canForkSelectedResponse = supportsSelectedResponseFork(selectedProviderDriver); const renderThreadRouteBody = (showActionControls: boolean) => ( <> @@ -837,12 +781,6 @@ function ThreadRouteContent( serverConfig={serverConfig} onStopThread={handleStopThread} onSendMessage={composer.onSendMessage} - {...(canForkSelectedResponse - ? { - onForkFromTurn: handleForkFromTurn, - isForkingFromTurn: forkingTurnId !== null, - } - : {})} onReconnectEnvironment={handleReconnectEnvironment} onUpdateThreadModelSelection={composer.onUpdateModelSelection} onUpdateThreadRuntimeMode={composer.onUpdateRuntimeMode} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index ffcb4fae75c..ab1256cddb3 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -26,11 +26,6 @@ import { type EnvironmentConnectionPresentation, } from "@t3tools/client-runtime/connection"; import { effectiveSettled, effectiveSnoozed } from "@t3tools/client-runtime/state/thread-settled"; -import { - releaseForkActionLock, - supportsSelectedResponseFork, - tryAcquireForkActionLock, -} from "@t3tools/client-runtime/thread-forking"; import { parseScopedThreadKey, scopedThreadKey, @@ -463,7 +458,6 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; - embedded?: boolean; routeKind: "server"; draftId?: never; } @@ -473,7 +467,6 @@ type ChatViewProps = onDiffPanelOpen?: () => void; reserveTitleBarControlInset?: boolean; forceExpandedMobileComposer?: boolean; - embedded?: boolean; routeKind: "draft"; draftId: DraftId; }; @@ -1130,7 +1123,6 @@ function ChatViewContent(props: ChatViewProps) { onDiffPanelOpen, reserveTitleBarControlInset = true, forceExpandedMobileComposer = false, - embedded = false, } = props; const draftId = routeKind === "draft" ? props.draftId : null; const routeThreadRef = useMemo( @@ -1146,7 +1138,6 @@ function ChatViewContent(props: ChatViewProps) { const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const createThread = useAtomCommand(threadEnvironment.create, { reportFailure: false }); - const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -1261,8 +1252,6 @@ function ChatViewContent(props: ChatViewProps) { >({}); const [isConnecting, _setIsConnecting] = useState(false); const [isRevertingCheckpoint, setIsRevertingCheckpoint] = useState(false); - const [isForkingToSide, setIsForkingToSide] = useState(false); - const forkingToSideTurnIdRef = useRef(null); const [maximizedRightPanelThreadKey, setMaximizedRightPanelThreadKey] = useState( null, ); @@ -1505,7 +1494,7 @@ function ChatViewContent(props: ChatViewProps) { [rightPanelState.surfaces], ); const previewPanelOpen = activeRightPanelKind === "preview" && isPreviewSupportedInRuntime(); - const rightPanelOpen = !embedded && rightPanelState.isOpen; + const rightPanelOpen = rightPanelState.isOpen; const canMaximizeRightPanel = rightPanelOpen && !shouldUsePlanSidebarSheet; const rightPanelMaximized = canMaximizeRightPanel && maximizedRightPanelThreadKey === routeThreadKey; @@ -5187,56 +5176,6 @@ function ChatViewContent(props: ChatViewProps) { ], ); - const onForkToSide = useCallback( - async (sourceTurnId: TurnId) => { - if ( - !activeThread || - !activeThreadRef || - !isServerThread || - !tryAcquireForkActionLock(forkingToSideTurnIdRef, sourceTurnId) - ) { - return; - } - - const nextThreadId = newThreadId(); - const title = truncate(`${activeThread.title} (fork)`); - setIsForkingToSide(true); - try { - const result = await forkThread({ - environmentId: activeThread.environmentId, - input: { - threadId: nextThreadId, - sourceThreadId: activeThread.id, - sourceTurnId, - title, - createdAt: new Date().toISOString(), - }, - }); - - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: "Could not fork side chat", - description: - error instanceof Error ? error.message : "The thread could not be forked.", - }), - ); - } - return; - } - - useRightPanelStore.getState().openThread(activeThreadRef, nextThreadId, title); - } finally { - releaseForkActionLock(forkingToSideTurnIdRef, sourceTurnId); - setIsForkingToSide(false); - } - }, - [activeThread, activeThreadRef, forkThread, isServerThread], - ); - const onImplementPlanInNewThread = useCallback(async () => { if ( !activeThread || @@ -5592,15 +5531,7 @@ function ChatViewContent(props: ChatViewProps) { ); const rightPanelContent = activeThreadRef ? ( - activeRightPanelSurface?.kind === "thread" ? ( - - ) : activeRightPanelSurface?.kind === "preview" ? ( + activeRightPanelSurface?.kind === "preview" ? ( {/* Top bar */} - {!embedded ? ( -
- {!rightPanelOpen ? panelLayoutControls : null} - -
- ) : null} +
+ {!rightPanelOpen ? panelLayoutControls : null} + +
{/* end horizontal flex container */} - {!embedded - ? mountedTerminalThreadRefs.map( - ({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - ), - ) - : null} + {mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( + + ))} {!shouldUsePlanSidebarSheet && rightPanelOpen && activeThreadRef ? ( diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 27eecfaa4e4..83524009c19 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -1,15 +1,6 @@ import type { ContextMenuItem, PreviewSessionSnapshot } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; -import { - ClipboardList, - FileDiff, - Files, - GitFork, - Globe2, - Plus, - TerminalSquare, - X, -} from "lucide-react"; +import { ClipboardList, FileDiff, Files, Globe2, Plus, TerminalSquare, X } from "lucide-react"; import { type MouseEvent as ReactMouseEvent, type ReactElement, @@ -214,8 +205,6 @@ function surfaceTitle( ); case "plan": return "Plan"; - case "thread": - return surface.title; case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -277,8 +266,6 @@ function SurfaceIcon({ return ; case "plan": return ; - case "thread": - return ; } } 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 430e6cd2680..6d74204bc1c 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.test.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.test.ts @@ -1,4 +1,3 @@ -import { TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; import { computeStableMessagesTimelineRows, @@ -6,7 +5,6 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, - resolveAssistantMessageForkState, } from "./MessagesTimeline.logic"; describe("computeMessageDurationStart", () => { @@ -262,35 +260,6 @@ 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 7585d23bdcf..3227bac2413 100644 --- a/apps/web/src/components/chat/MessagesTimeline.logic.ts +++ b/apps/web/src/components/chat/MessagesTimeline.logic.ts @@ -221,21 +221,6 @@ 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 0b076ef2818..83ca7d3e952 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -239,62 +239,6 @@ describe("MessagesTimeline", () => { expect(fadedMarkup).toContain("chat-timeline-scroll-fade"); }); - it("renders the fork action beneath a 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"); - expect(markup).toContain('aria-label="Copy link"'); - }); - - it("does not render the fork action without a side-chat 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 44ad9f07fec..a429b54deaf 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -45,7 +45,6 @@ import { ChevronRightIcon, CircleAlertIcon, EyeIcon, - GitForkIcon, GlobeIcon, HammerIcon, MessageCircleIcon, @@ -70,7 +69,6 @@ import { deriveMessagesTimelineRows, normalizeCompactToolLabel, resolveAssistantMessageCopyState, - resolveAssistantMessageForkState, resolveTimelineIsAtEnd, resolveTimelineMinimapHasPersistentGutter, resolveTimelineMinimapHeightStyle, @@ -133,8 +131,6 @@ interface TimelineRowSharedState { skills: ReadonlyArray>; activeThreadEnvironmentId: EnvironmentId; onRevertUserMessage: (messageId: MessageId) => void; - onForkToSide: ((turnId: TurnId) => void) | null; - isForkingToSide: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; onOpenTurnDiff: (turnId: TurnId, filePath?: string) => void; onToggleTurnFold: (turnId: TurnId) => void; @@ -173,8 +169,6 @@ interface MessagesTimelineProps { revertTurnCountByUserMessageId: Map; onRevertUserMessage: (messageId: MessageId) => void; isRevertingCheckpoint: boolean; - onForkToSide?: ((turnId: TurnId) => void) | undefined; - isForkingToSide?: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; activeThreadEnvironmentId: EnvironmentId; markdownCwd: string | undefined; @@ -210,8 +204,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ revertTurnCountByUserMessageId, onRevertUserMessage, isRevertingCheckpoint, - onForkToSide, - isForkingToSide = false, onImageExpand, activeThreadEnvironmentId, markdownCwd, @@ -434,8 +426,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, - onForkToSide: onForkToSide ?? null, - isForkingToSide, onImageExpand, onOpenTurnDiff, onToggleTurnFold, @@ -450,8 +440,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ skills, activeThreadEnvironmentId, onRevertUserMessage, - onForkToSide, - isForkingToSide, onImageExpand, onOpenTurnDiff, onToggleTurnFold, @@ -1050,7 +1038,6 @@ 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.onForkToSide === null || !forkState.visible || forkState.turnId === null) { - return null; - } - - const turnId = forkState.turnId; - - return ( - - ctx.onForkToSide?.(turnId)} - /> - } - > - - - Fork to side chat - - ); -} - 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..b3b0f566656 --- /dev/null +++ b/apps/web/src/hooks/useForkThread.ts @@ -0,0 +1,69 @@ +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, useRef } 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"; + +export function useForkThread(): ( + sourceThread: EnvironmentThreadShell, + sourceTurnId: TurnId, +) => Promise { + const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); + const inFlightThreadKeysRef = useRef(new Set()); + + return useCallback( + async ( + sourceThread: EnvironmentThreadShell, + sourceTurnId: TurnId, + ): Promise => { + const sourceThreadKey = `${sourceThread.environmentId}:${sourceThread.id}`; + if (inFlightThreadKeysRef.current.has(sourceThreadKey)) { + return null; + } + + inFlightThreadKeysRef.current.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 { + inFlightThreadKeysRef.current.delete(sourceThreadKey); + } + }, + [forkThread], + ); +} diff --git a/apps/web/src/rightPanelStore.test.ts b/apps/web/src/rightPanelStore.test.ts index e50ed5926fd..c7457cfd304 100644 --- a/apps/web/src/rightPanelStore.test.ts +++ b/apps/web/src/rightPanelStore.test.ts @@ -292,25 +292,6 @@ describe("rightPanelStore", () => { }); }); - it("opens a forked thread as a peer surface and refreshes its title", () => { - const forkId = ThreadId.make("thread-fork"); - useRightPanelStore.getState().openThread(refA, forkId, "Initial fork"); - useRightPanelStore.getState().openThread(refA, forkId, "Renamed fork"); - - expect(selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, refA)).toEqual({ - isOpen: true, - activeSurfaceId: "thread:thread-fork", - surfaces: [ - { - id: "thread:thread-fork", - kind: "thread", - threadId: forkId, - title: "Renamed fork", - }, - ], - }); - }); - it("tracks one surface per terminal session", () => { useRightPanelStore.getState().openTerminal(refA, "term-1"); useRightPanelStore.getState().openTerminal(refA, "term-2"); diff --git a/apps/web/src/rightPanelStore.ts b/apps/web/src/rightPanelStore.ts index 2e6f7c0c13a..70d163306cc 100644 --- a/apps/web/src/rightPanelStore.ts +++ b/apps/web/src/rightPanelStore.ts @@ -8,21 +8,13 @@ * workspace paths, and diff/plan/files remain singleton surfaces. */ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef, ThreadId } from "@t3tools/contracts"; +import type { ScopedThreadRef } from "@t3tools/contracts"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { resolveStorage } from "./lib/storage"; -export const RIGHT_PANEL_KINDS = [ - "plan", - "diff", - "files", - "file", - "preview", - "terminal", - "thread", -] as const; +export const RIGHT_PANEL_KINDS = ["plan", "diff", "files", "file", "preview", "terminal"] as const; export type RightPanelKind = (typeof RIGHT_PANEL_KINDS)[number]; export type RightPanelSurface = @@ -45,11 +37,10 @@ export type RightPanelSurface = revealLine: number | null; revealRequestId: number; } - | { id: "plan"; kind: "plan" } - | { id: `thread:${string}`; kind: "thread"; threadId: ThreadId; title: string }; + | { id: "plan"; kind: "plan" }; const RIGHT_PANEL_STORAGE_KEY = "t3code:right-panel-state:v2"; -const RIGHT_PANEL_STORAGE_VERSION = 8; +const RIGHT_PANEL_STORAGE_VERSION = 7; export interface ThreadRightPanelState { isOpen: boolean; @@ -59,11 +50,7 @@ export interface ThreadRightPanelState { interface RightPanelStoreState { byThreadKey: Record; - open: ( - ref: ScopedThreadRef, - kind: Exclude, - ) => void; - openThread: (ref: ScopedThreadRef, threadId: ThreadId, title: string) => void; + open: (ref: ScopedThreadRef, kind: Exclude) => void; openBrowser: (ref: ScopedThreadRef, tabId: string | null) => void; openFile: (ref: ScopedThreadRef, relativePath: string, line?: number) => void; openTerminal: (ref: ScopedThreadRef, terminalId: string) => void; @@ -85,10 +72,7 @@ interface RightPanelStoreState { show: (ref: ScopedThreadRef) => void; close: (ref: ScopedThreadRef) => void; toggleVisibility: (ref: ScopedThreadRef) => void; - toggle: ( - ref: ScopedThreadRef, - kind: Exclude, - ) => void; + toggle: (ref: ScopedThreadRef, kind: Exclude) => void; removeThread: (ref: ScopedThreadRef) => void; } @@ -99,7 +83,7 @@ const EMPTY_THREAD_STATE: ThreadRightPanelState = { }; const singletonSurface = ( - kind: Exclude, + kind: Exclude, ): RightPanelSurface => { switch (kind) { case "diff": @@ -136,13 +120,6 @@ const terminalSurface = (terminalId: string): RightPanelSurface => ({ activeTerminalId: terminalId, }); -const threadSurface = (threadId: ThreadId, title: string): RightPanelSurface => ({ - id: `thread:${threadId}`, - kind: "thread", - threadId, - title, -}); - const upsertSurface = ( current: ThreadRightPanelState, surface: RightPanelSurface, @@ -282,20 +259,6 @@ export const useRightPanelStore = create()( return upsertSurface({ ...current, surfaces: withoutPlaceholder }, surface); }), })), - openThread: (ref, threadId, title) => - set((state) => ({ - byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { - const surface = threadSurface(threadId, title); - const existing = current.surfaces.some((entry) => entry.id === surface.id); - return { - isOpen: true, - activeSurfaceId: surface.id, - surfaces: existing - ? current.surfaces.map((entry) => (entry.id === surface.id ? surface : entry)) - : [...current.surfaces, surface], - }; - }), - })), openFile: (ref, relativePath, line) => set((state) => ({ byThreadKey: updateThread(state.byThreadKey, scopedThreadKey(ref), (current) => { diff --git a/packages/client-runtime/src/threadForking.test.ts b/packages/client-runtime/src/threadForking.test.ts index cfd37bdb542..c89bf015826 100644 --- a/packages/client-runtime/src/threadForking.test.ts +++ b/packages/client-runtime/src/threadForking.test.ts @@ -1,39 +1,36 @@ -import { ProviderDriverKind, TurnId } from "@t3tools/contracts"; +import { MessageId, ProviderDriverKind, TurnId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { - releaseForkActionLock, - supportsSelectedResponseFork, - tryAcquireForkActionLock, - type ForkActionLock, -} from "./threadForking.js"; +import { resolveLatestForkableTurnId, supportsThreadFork } from "./threadForking.js"; -describe("supportsSelectedResponseFork", () => { - it("only enables providers with an exact historical fork primitive", () => { - expect(supportsSelectedResponseFork(ProviderDriverKind.make("codex"))).toBe(true); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("claudeAgent"))).toBe(true); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("opencode"))).toBe(true); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("cursor"))).toBe(false); - expect(supportsSelectedResponseFork(ProviderDriverKind.make("grok"))).toBe(false); - expect(supportsSelectedResponseFork(null)).toBe(false); +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("fork action lock", () => { - it("rejects a second action until the first one releases", () => { - const firstTurnId = TurnId.make("turn-1"); - const secondTurnId = TurnId.make("turn-2"); - const lock: ForkActionLock = { current: null }; +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"), + }; - expect(tryAcquireForkActionLock(lock, firstTurnId)).toBe(true); - expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(false); - expect(lock.current).toBe(firstTurnId); - - releaseForkActionLock(lock, secondTurnId); - expect(lock.current).toBe(firstTurnId); - releaseForkActionLock(lock, firstTurnId); + it("uses the latest completed assistant turn as the full-thread clone boundary", () => { + expect(resolveLatestForkableTurnId(completedTurn)).toBe(completedTurn.turnId); + }); - expect(tryAcquireForkActionLock(lock, secondTurnId)).toBe(true); - expect(lock.current).toBe(secondTurnId); + 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 index ca6837572cc..9cbb8043c79 100644 --- a/packages/client-runtime/src/threadForking.ts +++ b/packages/client-runtime/src/threadForking.ts @@ -1,33 +1,26 @@ -import { ProviderDriverKind, type TurnId } from "@t3tools/contracts"; +import { ProviderDriverKind, type OrchestrationLatestTurn, type TurnId } from "@t3tools/contracts"; -const SELECTED_RESPONSE_FORK_DRIVERS = new Set([ +const THREAD_FORK_DRIVERS = new Set([ ProviderDriverKind.make("codex"), ProviderDriverKind.make("claudeAgent"), ProviderDriverKind.make("opencode"), ]); -export function supportsSelectedResponseFork( - driverKind: ProviderDriverKind | null | undefined, -): boolean { +export function supportsThreadFork(driverKind: ProviderDriverKind | null | undefined): boolean { return driverKind !== null && driverKind !== undefined - ? SELECTED_RESPONSE_FORK_DRIVERS.has(driverKind) + ? THREAD_FORK_DRIVERS.has(driverKind) : false; } -export interface ForkActionLock { - current: TurnId | null; -} - -export function tryAcquireForkActionLock(lock: ForkActionLock, turnId: TurnId): boolean { - if (lock.current !== null) { - return false; - } - lock.current = turnId; - return true; -} - -export function releaseForkActionLock(lock: ForkActionLock, turnId: TurnId): void { - if (lock.current === turnId) { - lock.current = null; +export function resolveLatestForkableTurnId( + latestTurn: OrchestrationLatestTurn | null, +): TurnId | null { + if ( + latestTurn === null || + latestTurn.state === "running" || + latestTurn.assistantMessageId === null + ) { + return null; } + return latestTurn.turnId; } From 4388b29078d346190dfaf3eb79461b48b497c561 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 19:15:39 -0700 Subject: [PATCH 08/10] Fix thread fork reactor test lint --- .../Layers/ProviderCommandReactor.test.ts | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index b395a080a28..20abd67912e 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -460,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"), @@ -502,7 +502,7 @@ describe("ProviderCommandReactor", () => { const sourceTurnId = asTurnId("source-turn-1"); const forkThreadId = ThreadId.make("thread-fork"); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.message.assistant.complete", commandId: CommandId.make("cmd-source-assistant-complete"), @@ -512,7 +512,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.fork", commandId: CommandId.make("cmd-thread-fork"), @@ -523,7 +523,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-fork-turn-start"), @@ -558,7 +558,7 @@ describe("ProviderCommandReactor", () => { const parentForkThreadId = ThreadId.make("thread-parent-fork"); const nestedForkThreadId = ThreadId.make("thread-nested-fork"); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.message.assistant.complete", commandId: CommandId.make("cmd-nested-source-complete"), @@ -568,7 +568,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.fork", commandId: CommandId.make("cmd-parent-fork"), @@ -579,7 +579,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.fork", commandId: CommandId.make("cmd-nested-fork"), @@ -590,7 +590,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-nested-fork-turn-start"), @@ -623,7 +623,7 @@ describe("ProviderCommandReactor", () => { const sourceTurnId = asTurnId("source-turn-1"); const forkThreadId = ThreadId.make("thread-fork-missing-source"); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.message.assistant.complete", commandId: CommandId.make("cmd-missing-source-assistant-complete"), @@ -633,7 +633,7 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.fork", commandId: CommandId.make("cmd-missing-source-fork"), @@ -644,14 +644,14 @@ describe("ProviderCommandReactor", () => { createdAt: now, }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.delete", commandId: CommandId.make("cmd-delete-fork-source"), threadId: ThreadId.make("thread-1"), }), ); - await Effect.runPromise( + await runtime!.runPromise( harness.engine.dispatch({ type: "thread.turn.start", commandId: CommandId.make("cmd-missing-source-turn-start"), From f8b68a4797cbfcdd09673d388ec1d75a33bea112 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 23:13:39 -0700 Subject: [PATCH 09/10] fix(web): preserve selected response fork points --- .../Layers/ProjectionPipeline.ts | 2 + apps/web/src/components/ChatView.tsx | 29 ++++++++++ .../chat/MessagesTimeline.logic.test.ts | 31 +++++++++++ .../components/chat/MessagesTimeline.logic.ts | 15 +++++ .../components/chat/MessagesTimeline.test.tsx | 55 +++++++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 49 +++++++++++++++++ apps/web/src/hooks/useForkThread.ts | 9 ++- packages/client-runtime/src/threadForking.ts | 2 + 8 files changed, 187 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6b73d289361..d35dd28b26d 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -685,6 +685,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti archivedAt: null, settledOverride: null, settledAt: null, + snoozedUntil: null, + snoozedAt: null, latestUserMessageAt: event.payload.inheritedMessages .toReversed() 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/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 index b3b0f566656..220145911e8 100644 --- a/apps/web/src/hooks/useForkThread.ts +++ b/apps/web/src/hooks/useForkThread.ts @@ -13,18 +13,17 @@ import { newThreadId } from "../lib/utils"; import { threadEnvironment } from "../state/threads"; import { useAtomCommand } from "../state/use-atom-command"; +type ForkableThread = Pick; + export function useForkThread(): ( - sourceThread: EnvironmentThreadShell, + sourceThread: ForkableThread, sourceTurnId: TurnId, ) => Promise { const forkThread = useAtomCommand(threadEnvironment.fork, { reportFailure: false }); const inFlightThreadKeysRef = useRef(new Set()); return useCallback( - async ( - sourceThread: EnvironmentThreadShell, - sourceTurnId: TurnId, - ): Promise => { + async (sourceThread: ForkableThread, sourceTurnId: TurnId): Promise => { const sourceThreadKey = `${sourceThread.environmentId}:${sourceThread.id}`; if (inFlightThreadKeysRef.current.has(sourceThreadKey)) { return null; diff --git a/packages/client-runtime/src/threadForking.ts b/packages/client-runtime/src/threadForking.ts index 9cbb8043c79..7b2333a8d88 100644 --- a/packages/client-runtime/src/threadForking.ts +++ b/packages/client-runtime/src/threadForking.ts @@ -12,6 +12,8 @@ export function supportsThreadFork(driverKind: ProviderDriverKind | null | undef : false; } +export const supportsSelectedResponseFork = supportsThreadFork; + export function resolveLatestForkableTurnId( latestTurn: OrchestrationLatestTurn | null, ): TurnId | null { From b779c54181e93998e572e82ae0ae25f148451105 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Thu, 23 Jul 2026 23:33:46 -0700 Subject: [PATCH 10/10] fix(web): share fork action lock globally --- apps/web/src/hooks/useForkThread.ts | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/src/hooks/useForkThread.ts b/apps/web/src/hooks/useForkThread.ts index 220145911e8..047c2ca4b5f 100644 --- a/apps/web/src/hooks/useForkThread.ts +++ b/apps/web/src/hooks/useForkThread.ts @@ -6,7 +6,7 @@ import { } from "@t3tools/client-runtime/state/runtime"; import type { ScopedThreadRef, TurnId } from "@t3tools/contracts"; import { truncate } from "@t3tools/shared/String"; -import { useCallback, useRef } from "react"; +import { useCallback } from "react"; import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { newThreadId } from "../lib/utils"; @@ -15,21 +15,24 @@ 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 }); - const inFlightThreadKeysRef = useRef(new Set()); return useCallback( async (sourceThread: ForkableThread, sourceTurnId: TurnId): Promise => { const sourceThreadKey = `${sourceThread.environmentId}:${sourceThread.id}`; - if (inFlightThreadKeysRef.current.has(sourceThreadKey)) { + if (inFlightForkThreadKeys.has(sourceThreadKey)) { return null; } - inFlightThreadKeysRef.current.add(sourceThreadKey); + inFlightForkThreadKeys.add(sourceThreadKey); const nextThreadId = newThreadId(); try { const result = await forkThread({ @@ -60,7 +63,7 @@ export function useForkThread(): ( return scopeThreadRef(sourceThread.environmentId, nextThreadId); } finally { - inFlightThreadKeysRef.current.delete(sourceThreadKey); + inFlightForkThreadKeys.delete(sourceThreadKey); } }, [forkThread],