From 161316952c7f65c30b52633c8fca4807525f01a6 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 20:07:31 +0300 Subject: [PATCH 01/11] Add message-level conversation forks --- .../Layers/OrchestrationEngine.test.ts | 169 ++++++++ .../Layers/OrchestrationEngine.ts | 35 ++ .../Layers/ProjectionPipeline.test.ts | 196 ++++++++- .../Layers/ProjectionPipeline.ts | 186 ++++----- .../Layers/ProjectionSnapshotQuery.test.ts | 20 +- .../Layers/ProjectionSnapshotQuery.ts | 21 + .../Layers/ProviderCommandReactor.test.ts | 311 +++++++++++++-- .../Layers/ProviderCommandReactor.ts | 12 +- .../assistantMessageLifecycle.ts | 2 +- .../orchestration/decider.forkTitle.test.ts | 213 ++++++++++ apps/server/src/orchestration/decider.ts | 69 +++- .../src/orchestration/forkTitle.test.ts | 147 +++++++ apps/server/src/orchestration/forkTitle.ts | 96 +++++ .../src/orchestration/messageFork.test.ts | 186 +++++++++ apps/server/src/orchestration/messageFork.ts | 164 ++++++++ .../src/orchestration/projector.test.ts | 3 + apps/server/src/orchestration/projector.ts | 3 + .../persistence/Layers/ProjectionThreads.ts | 15 + apps/server/src/persistence/Migrations.ts | 4 + ...ProjectionThreadsForkSourceMessage.test.ts | 84 ++++ .../056_ProjectionThreadsForkSourceMessage.ts | 19 + ...ProjectionThreadsForkTitleSequence.test.ts | 285 ++++++++++++++ .../057_ProjectionThreadsForkTitleSequence.ts | 179 +++++++++ .../persistence/Services/ProjectionThreads.ts | 5 + apps/web/src/components/ChatView.browser.tsx | 371 +++++++++++++++++- apps/web/src/components/ChatView.tsx | 2 + .../components/chat/ChatTranscriptPane.tsx | 3 + .../MessagesTimeline.messageFork.browser.tsx | 106 +++++ .../components/chat/MessagesTimeline.test.tsx | 60 +++ .../src/components/chat/MessagesTimeline.tsx | 24 ++ .../web/src/hooks/useComposerSlashCommands.ts | 55 ++- apps/web/src/lib/assistantSelections.ts | 9 +- apps/web/src/lib/icons.tsx | 1 + apps/web/src/lib/threadHandoff.test.ts | 161 +++++++- apps/web/src/lib/threadHandoff.ts | 105 +++-- apps/web/src/store.ts | 17 + apps/web/src/test/effectRpcWebSocketMock.ts | 3 + apps/web/src/types.ts | 7 + packages/contracts/src/orchestration.ts | 28 ++ packages/shared/package.json | 4 + packages/shared/src/assistantSelections.ts | 10 + 41 files changed, 3216 insertions(+), 174 deletions(-) create mode 100644 apps/server/src/orchestration/decider.forkTitle.test.ts create mode 100644 apps/server/src/orchestration/forkTitle.test.ts create mode 100644 apps/server/src/orchestration/forkTitle.ts create mode 100644 apps/server/src/orchestration/messageFork.test.ts create mode 100644 apps/server/src/orchestration/messageFork.ts create mode 100644 apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.test.ts create mode 100644 apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.ts create mode 100644 apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts create mode 100644 apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts create mode 100644 apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx create mode 100644 packages/shared/src/assistantSelections.ts diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index dfafa44ba..2989d973e 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -122,6 +122,175 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("serializes concurrent forks into distinct authoritative title ordinals", async () => { + const createdAt = now(); + const system = await createOrchestrationSystem(); + const { engine } = system; + const projectId = asProjectId("project-concurrent-forks"); + const sourceThreadId = ThreadId.makeUnsafe("thread-concurrent-forks-source"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-concurrent-forks-create"), + projectId, + title: "Concurrent Fork Project", + workspaceRoot: "/tmp/project-concurrent-forks", + defaultModelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-concurrent-forks-source-create"), + threadId: sourceThreadId, + projectId, + title: "Greeting", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + + const forkCommand = (suffix: string) => ({ + type: "thread.fork.create" as const, + commandId: CommandId.makeUnsafe(`cmd-thread-concurrent-fork-${suffix}`), + threadId: ThreadId.makeUnsafe(`thread-concurrent-fork-${suffix}`), + sourceThreadId, + projectId, + title: "stale client title", + modelSelection: { + provider: "codex" as const, + model: "gpt-5-codex", + }, + runtimeMode: "approval-required" as const, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local" as const, + branch: null, + worktreePath: null, + importedMessages: [], + createdAt, + }); + await Promise.all([ + system.run(engine.dispatch(forkCommand("a"))), + system.run(engine.dispatch(forkCommand("b"))), + ]); + + const forkedThreads = (await system.run(engine.getReadModel())).threads + .filter((thread) => thread.forkSourceThreadId === sourceThreadId) + .toSorted((left, right) => (left.forkTitleOrdinal ?? 0) - (right.forkTitleOrdinal ?? 0)); + expect( + forkedThreads.map((thread) => ({ + title: thread.title, + base: thread.forkTitleBase, + ordinal: thread.forkTitleOrdinal, + })), + ).toEqual([ + { title: "Greeting (2)", base: "Greeting", ordinal: 2 }, + { title: "Greeting (3)", base: "Greeting", ordinal: 3 }, + ]); + await system.dispose(); + }); + + it("rejects a fork import that reuses a source message id without moving the source row", async () => { + const createdAt = now(); + const system = await createOrchestrationSystem(); + const { engine } = system; + const projectId = asProjectId("project-fork-message-id-collision"); + const sourceThreadId = ThreadId.makeUnsafe("thread-fork-message-id-collision-source"); + const sourceMessageId = asMessageId("message-fork-message-id-collision-source"); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-fork-message-id-collision"), + projectId, + title: "Fork collision project", + workspaceRoot: "/tmp/project-fork-message-id-collision", + defaultModelSelection: { provider: "codex", model: "gpt-5-codex" }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-fork-message-id-collision-source"), + threadId: sourceThreadId, + projectId, + title: "Source", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-turn-fork-message-id-collision-source"), + threadId: sourceThreadId, + message: { + messageId: sourceMessageId, + role: "user", + text: "Source message", + attachments: [], + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + createdAt, + }), + ); + + await expect( + system.run( + engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-fork-message-id-collision"), + threadId: ThreadId.makeUnsafe("thread-fork-message-id-collision-destination"), + sourceThreadId, + sourceMessageId, + projectId, + title: "Source", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: sourceMessageId, + role: "user", + text: "Source message", + attachments: [], + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ), + ).rejects.toThrow("must be unique and must not already exist"); + + const sourceThread = (await system.run(engine.getReadModel())).threads.find( + (thread) => thread.id === sourceThreadId, + ); + expect(sourceThread?.messages.map((message) => message.id)).toEqual([sourceMessageId]); + await system.dispose(); + }); + it("replays append-only events from sequence", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index 665063611..acc0c7e17 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -123,6 +123,40 @@ const makeOrchestrationEngine = Effect.gen(function* () { detail, }); + const validateImportedMessageIdsAgainstProjection = (command: OrchestrationCommand) => { + if ( + (command.type !== "thread.handoff.create" && command.type !== "thread.fork.create") || + command.importedMessages.length === 0 + ) { + return Effect.void; + } + + const importedMessageIds = command.importedMessages.map((message) => message.messageId); + return sql<{ readonly messageId: string }>` + SELECT message_id AS "messageId" + FROM projection_thread_messages + WHERE message_id IN ${sql.in(importedMessageIds)} + LIMIT 1 + `.pipe( + Effect.mapError( + toPersistenceSqlError( + "OrchestrationEngine.validateImportedMessageIdsAgainstProjection:query", + ), + ), + Effect.flatMap((rows) => { + const conflictingMessageId = rows[0]?.messageId; + return conflictingMessageId === undefined + ? Effect.void + : Effect.fail( + new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Imported message id '${conflictingMessageId}' must be unique and must not already exist.`, + }), + ); + }), + ); + }; + const resolveStoredCommandOutcome = ( command: OrchestrationCommand, ): Effect.Effect<{ sequence: number }, OrchestrationDispatchError, never> => @@ -381,6 +415,7 @@ const makeOrchestrationEngine = Effect.gen(function* () { } const deciderReadModel = yield* buildDeciderReadModel(envelope.command); + yield* validateImportedMessageIdsAgainstProjection(envelope.command); const eventBase = yield* decideOrchestrationCommand({ command: envelope.command, readModel: deciderReadModel, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 81381d591..1df332861 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -1463,6 +1463,7 @@ it.layer( const { attachmentsDir } = yield* ServerConfig; const now = new Date().toISOString(); const threadId = ThreadId.makeUnsafe("Thread Revert.Files"); + const forkThreadId = ThreadId.makeUnsafe("Thread Revert.Files Fork"); const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001"; const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002"; const removeFileAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000004"; @@ -1576,6 +1577,64 @@ it.layer( }, }); + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe("evt-revert-files-fork-1"), + aggregateKind: "thread", + aggregateId: forkThreadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-fork-1"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-fork-1"), + metadata: {}, + payload: { + threadId: forkThreadId, + projectId: ProjectId.makeUnsafe("project-revert-files"), + title: "Thread Revert Files Fork", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + forkSourceThreadId: threadId, + createdAt: now, + updatedAt: now, + }, + }); + + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe("evt-revert-files-fork-2"), + aggregateKind: "thread", + aggregateId: forkThreadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-fork-2"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-fork-2"), + metadata: {}, + payload: { + threadId: forkThreadId, + messageId: MessageId.makeUnsafe("message-remove-fork-copy"), + role: "assistant", + text: "Fork keeps shared image", + attachments: [ + { + type: "image", + id: removeAttachmentId, + name: "remove.png", + mimeType: "image/png", + sizeBytes: 5, + }, + ], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ type: "thread.turn-diff-completed", eventId: EventId.makeUnsafe("evt-revert-files-5"), @@ -1669,9 +1728,23 @@ it.layer( }); assert.isTrue(yield* exists(keepPath)); - assert.isFalse(yield* exists(removePath)); + assert.isTrue(yield* exists(removePath)); assert.isFalse(yield* exists(removeFilePath)); assert.isTrue(yield* exists(otherThreadPath)); + + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.makeUnsafe("evt-revert-files-fork-3"), + aggregateKind: "thread", + aggregateId: forkThreadId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-revert-files-fork-3"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-revert-files-fork-3"), + metadata: {}, + payload: { threadId: forkThreadId, deletedAt: now }, + }); + assert.isFalse(yield* exists(removePath)); }), ); }); @@ -1803,6 +1876,127 @@ it.layer( assert.isTrue(yield* exists(otherThreadAttachmentPath)); }), ); + + it.effect("keeps a shared fork attachment until its final active reference is deleted", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const { attachmentsDir } = yield* ServerConfig; + const now = new Date().toISOString(); + const projectId = ProjectId.makeUnsafe("project-shared-fork-attachment"); + const sourceThreadId = ThreadId.makeUnsafe("thread-shared-attachment-source"); + const forkThreadId = ThreadId.makeUnsafe("thread-shared-attachment-fork"); + const attachmentId = "thread-shared-attachment-source-00000000-0000-4000-8000-000000000001"; + const attachment = { + type: "image" as const, + id: attachmentId, + name: "shared.png", + mimeType: "image/png", + sizeBytes: 6, + }; + + const appendAndProject = (event: Parameters[0]) => + eventStore + .append(event) + .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent))); + + yield* appendAndProject({ + type: "project.created", + eventId: EventId.makeUnsafe("evt-shared-fork-attachment-project"), + aggregateKind: "project", + aggregateId: projectId, + occurredAt: now, + commandId: CommandId.makeUnsafe("cmd-shared-fork-attachment-project"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("cmd-shared-fork-attachment-project"), + metadata: {}, + payload: { + projectId, + title: "Shared fork attachment", + workspaceRoot: "/tmp/project-shared-fork-attachment", + defaultModelSelection: null, + scripts: [], + createdAt: now, + updatedAt: now, + }, + }); + + for (const [threadId, suffix] of [ + [sourceThreadId, "source"], + [forkThreadId, "fork"], + ] as const) { + yield* appendAndProject({ + type: "thread.created", + eventId: EventId.makeUnsafe(`evt-shared-fork-attachment-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe(`cmd-shared-fork-attachment-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe(`cmd-shared-fork-attachment-${suffix}`), + metadata: {}, + payload: { + threadId, + projectId, + title: suffix, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + createdAt: now, + updatedAt: now, + }, + }); + yield* appendAndProject({ + type: "thread.message-sent", + eventId: EventId.makeUnsafe(`evt-shared-fork-message-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe(`cmd-shared-fork-message-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe(`cmd-shared-fork-message-${suffix}`), + metadata: {}, + payload: { + threadId, + messageId: MessageId.makeUnsafe(`message-shared-fork-${suffix}`), + role: "user", + text: "Shared attachment", + attachments: [attachment], + turnId: null, + streaming: false, + createdAt: now, + updatedAt: now, + }, + }); + } + + const attachmentPath = path.join(attachmentsDir, `${attachmentId}.png`); + yield* fileSystem.makeDirectory(attachmentsDir, { recursive: true }); + yield* fileSystem.writeFileString(attachmentPath, "shared"); + + for (const [threadId, suffix, shouldRemain] of [ + [sourceThreadId, "source", true], + [forkThreadId, "fork", false], + ] as const) { + yield* appendAndProject({ + type: "thread.deleted", + eventId: EventId.makeUnsafe(`evt-shared-fork-delete-${suffix}`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: now, + commandId: CommandId.makeUnsafe(`cmd-shared-fork-delete-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe(`cmd-shared-fork-delete-${suffix}`), + metadata: {}, + payload: { threadId, deletedAt: now }, + }); + assert.equal(yield* exists(attachmentPath), shouldRemain); + } + }), + ); }); it.layer( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 6dfe46bfc..e3de4aaca 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -115,6 +115,7 @@ interface ProjectorDefinition { interface AttachmentSideEffects { readonly deletedThreadIds: Set; readonly prunedThreadRelativePaths: Map>; + readonly orphanCandidateRelativePaths: Set; } const REQUIRED_SNAPSHOT_PROJECTORS = PROJECT_METADATA_SNAPSHOT_PROJECTORS; @@ -477,6 +478,20 @@ function retainProjectionProposedPlansAfterConversationRollback( ); } +function collectAttachmentRelativePaths( + messages: ReadonlyArray, +): Set { + const relativePaths = new Set(); + for (const message of messages) { + for (const attachment of message.attachments ?? []) { + if (attachment.type === "image" || attachment.type === "file") { + relativePaths.add(attachmentRelativePath(attachment)); + } + } + } + return relativePaths; +} + function collectThreadAttachmentRelativePaths( threadId: string, messages: ReadonlyArray, @@ -501,7 +516,18 @@ function collectThreadAttachmentRelativePaths( return relativePaths; } -const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSideEffects) { +const runAttachmentSideEffects = Effect.fn(function* ( + sideEffects: AttachmentSideEffects, + sql: SqlClient.SqlClient, +) { + if ( + sideEffects.deletedThreadIds.size === 0 && + sideEffects.prunedThreadRelativePaths.size === 0 && + sideEffects.orphanCandidateRelativePaths.size === 0 + ) { + return; + } + const serverConfig = yield* Effect.service(ServerConfig); const fileSystem = yield* Effect.service(FileSystem.FileSystem); const path = yield* Effect.service(Path.Path); @@ -511,102 +537,67 @@ const runAttachmentSideEffects = Effect.fn(function* (sideEffects: AttachmentSid .readDirectory(attachmentsRootDir, { recursive: false }) .pipe(Effect.catch(() => Effect.succeed([] as Array))); - // Deleted-thread cleanup removes every attachment owned by the thread. - const removeDeletedThreadAttachmentEntry = Effect.fn(function* ( - threadSegment: string, - entry: string, - ) { - const normalizedEntry = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); - if (normalizedEntry.length === 0 || normalizedEntry.includes("/")) { - return; - } - const attachmentId = parseAttachmentIdFromRelativePath(normalizedEntry); - if (!attachmentId) { - return; - } - const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); - if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { - return; - } - yield* fileSystem.remove(path.join(attachmentsRootDir, normalizedEntry), { - force: true, + const activeAttachmentRows = yield* sql<{ readonly attachmentsJson: string }>` + SELECT messages.attachments_json AS "attachmentsJson" + FROM projection_thread_messages AS messages + INNER JOIN projection_threads AS threads + ON threads.thread_id = messages.thread_id + WHERE threads.deleted_at IS NULL + AND messages.attachments_json IS NOT NULL + `; + const activeRelativePaths = new Set(); + for (const row of activeAttachmentRows) { + const attachments = yield* Effect.try({ + try: () => JSON.parse(row.attachmentsJson) as ReadonlyArray, + catch: (cause) => new Error("Failed to decode projected attachment references.", { cause }), }); - }); + for (const attachment of attachments) { + if (attachment.type === "image" || attachment.type === "file") { + activeRelativePaths.add(attachmentRelativePath(attachment)); + } + } + } - const deleteThreadAttachments = Effect.fn(function* (threadId: string) { + const cleanupThreadSegments = new Set(); + for (const threadId of [ + ...sideEffects.deletedThreadIds, + ...sideEffects.prunedThreadRelativePaths.keys(), + ]) { const threadSegment = toSafeThreadAttachmentSegment(threadId); if (!threadSegment) { - yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", { - threadId, - }); - return; - } - - yield* Effect.forEach( - attachmentRootEntries, - (entry) => removeDeletedThreadAttachmentEntry(threadSegment, entry), - { - concurrency: 1, - }, - ); - }); - - const pruneThreadAttachmentEntry = Effect.fn(function* ( - threadSegment: string, - keptThreadRelativePaths: Set, - entry: string, - ) { - const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); - if (relativePath.length === 0 || relativePath.includes("/")) { - return; - } - const attachmentId = parseAttachmentIdFromRelativePath(relativePath); - if (!attachmentId) { - return; - } - const attachmentThreadSegment = parseThreadSegmentFromAttachmentId(attachmentId); - if (!attachmentThreadSegment || attachmentThreadSegment !== threadSegment) { - return; - } - - const absolutePath = path.join(attachmentsRootDir, relativePath); - const fileInfo = yield* fileSystem - .stat(absolutePath) - .pipe(Effect.catch(() => Effect.succeed(null))); - if (!fileInfo || fileInfo.type !== "File") { - return; - } - - if (!keptThreadRelativePaths.has(relativePath)) { - yield* fileSystem.remove(absolutePath, { force: true }); + yield* Effect.logWarning("skipping attachment cleanup for unsafe thread id", { threadId }); + continue; } - }); - - yield* Effect.forEach( - sideEffects.deletedThreadIds, - (threadId) => deleteThreadAttachments(threadId), - { concurrency: 1 }, - ); + cleanupThreadSegments.add(threadSegment); + } yield* Effect.forEach( - sideEffects.prunedThreadRelativePaths.entries(), - ([threadId, keptThreadRelativePaths]) => { - if (sideEffects.deletedThreadIds.has(threadId)) { - return Effect.void; - } - return Effect.gen(function* () { - const threadSegment = toSafeThreadAttachmentSegment(threadId); - if (!threadSegment) { - yield* Effect.logWarning("skipping attachment prune for unsafe thread id", { threadId }); + attachmentRootEntries, + (entry) => + Effect.gen(function* () { + const relativePath = entry.replace(/^[/\\]+/, "").replace(/\\/g, "/"); + if (relativePath.length === 0 || relativePath.includes("/")) { return; } - yield* Effect.forEach( - attachmentRootEntries, - (entry) => pruneThreadAttachmentEntry(threadSegment, keptThreadRelativePaths, entry), - { concurrency: 1 }, - ); - }); - }, + const attachmentId = parseAttachmentIdFromRelativePath(relativePath); + const attachmentThreadSegment = attachmentId + ? parseThreadSegmentFromAttachmentId(attachmentId) + : null; + const isCleanupCandidate = + sideEffects.orphanCandidateRelativePaths.has(relativePath) || + (attachmentThreadSegment !== null && cleanupThreadSegments.has(attachmentThreadSegment)); + if (!isCleanupCandidate || activeRelativePaths.has(relativePath)) { + return; + } + + const absolutePath = path.join(attachmentsRootDir, relativePath); + const fileInfo = yield* fileSystem + .stat(absolutePath) + .pipe(Effect.catch(() => Effect.succeed(null))); + if (fileInfo?.type === "File") { + yield* fileSystem.remove(absolutePath, { force: true }); + } + }), { concurrency: 1 }, ); }); @@ -662,6 +653,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { subagentNickname: event.payload.subagentNickname ?? null, subagentRole: event.payload.subagentRole ?? null, forkSourceThreadId: event.payload.forkSourceThreadId, + forkSourceMessageId: event.payload.forkSourceMessageId, + forkTitleBase: event.payload.forkTitleBase, + forkTitleOrdinal: event.payload.forkTitleOrdinal, sidechatSourceThreadId: event.payload.sidechatSourceThreadId, lastKnownPr: event.payload.lastKnownPr ?? null, latestTurnId: null, @@ -957,6 +951,12 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { case "thread.deleted": { attachmentSideEffects.deletedThreadIds.add(event.payload.threadId); + const existingMessages = yield* projectionThreadMessageRepository.listByThreadId({ + threadId: event.payload.threadId, + }); + for (const relativePath of collectAttachmentRelativePaths(existingMessages)) { + attachmentSideEffects.orphanCandidateRelativePaths.add(relativePath); + } const existingRow = yield* projectionThreadRepository.getById({ threadId: event.payload.threadId, }); @@ -1175,6 +1175,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { return; } + for (const relativePath of collectAttachmentRelativePaths(existingRows)) { + attachmentSideEffects.orphanCandidateRelativePaths.add(relativePath); + } + yield* projectionThreadMessageRepository.deleteByThreadId({ threadId: event.payload.threadId, }); @@ -1210,6 +1214,9 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { concurrency: 1, }).pipe(Effect.asVoid); if (event.payload.skipAttachmentPrune !== true) { + for (const relativePath of collectAttachmentRelativePaths(existingRows)) { + attachmentSideEffects.orphanCandidateRelativePaths.add(relativePath); + } attachmentSideEffects.prunedThreadRelativePaths.set( event.payload.threadId, collectThreadAttachmentRelativePaths(event.payload.threadId, rollback.keptRows), @@ -1932,6 +1939,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const attachmentSideEffects: AttachmentSideEffects = { deletedThreadIds: new Set(), prunedThreadRelativePaths: new Map>(), + orphanCandidateRelativePaths: new Set(), }; yield* Effect.forEach( @@ -1969,7 +1977,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ) => attachmentSideEffects === null ? Effect.void - : runAttachmentSideEffects(attachmentSideEffects).pipe( + : runAttachmentSideEffects(attachmentSideEffects, sql).pipe( Effect.catch((cause) => Effect.logWarning("failed to apply projected attachment side-effects", { projectors: selectedProjectors.map((projector) => projector.name), diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index d5bd8784c..3bf4c5f99 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -61,6 +61,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { model_selection_json, branch, worktree_path, + fork_source_thread_id, + fork_source_message_id, + fork_title_base, + fork_title_ordinal, latest_turn_id, created_at, updated_at, @@ -69,10 +73,14 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { VALUES ( 'thread-1', 'project-1', - 'Thread 1', + 'Thread 1 (2)', '{"provider":"codex","model":"gpt-5-codex"}', NULL, NULL, + 'thread-source', + 'message-source-boundary', + 'Thread 1', + 2, 'turn-1', '2026-02-24T00:00:02.000Z', '2026-02-24T00:00:03.000Z', @@ -312,7 +320,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { { id: ThreadId.makeUnsafe("thread-1"), projectId: asProjectId("project-1"), - title: "Thread 1", + title: "Thread 1 (2)", modelSelection: { provider: "codex", model: "gpt-5-codex", @@ -331,7 +339,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentAgentId: null, subagentNickname: null, subagentRole: null, - forkSourceThreadId: null, + forkSourceThreadId: ThreadId.makeUnsafe("thread-source"), + forkSourceMessageId: MessageId.makeUnsafe("message-source-boundary"), + forkTitleBase: "Thread 1", + forkTitleOrdinal: 2, sidechatSourceThreadId: null, lastKnownPr: null, latestUserMessageAt: "2026-02-24T00:00:03.500Z", @@ -1375,6 +1386,9 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + forkSourceMessageId: null, + forkTitleBase: null, + forkTitleOrdinal: null, sidechatSourceThreadId: null, lastKnownPr: null, latestTurn: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index c45d6c395..df4fdbb06 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -592,6 +592,9 @@ function toProjectedThreadShell(input: { subagentNickname: threadRow.subagentNickname ?? null, subagentRole: threadRow.subagentRole ?? null, forkSourceThreadId: threadRow.forkSourceThreadId ?? null, + forkSourceMessageId: threadRow.forkSourceMessageId ?? null, + forkTitleBase: threadRow.forkTitleBase ?? null, + forkTitleOrdinal: threadRow.forkTitleOrdinal ?? null, sidechatSourceThreadId: threadRow.sidechatSourceThreadId ?? null, lastKnownPr: threadRow.lastKnownPr, latestTurn: input.latestTurn, @@ -633,6 +636,9 @@ function toProjectedThreadShellFromStoredSummary(input: { subagentNickname: threadRow.subagentNickname ?? null, subagentRole: threadRow.subagentRole ?? null, forkSourceThreadId: threadRow.forkSourceThreadId ?? null, + forkSourceMessageId: threadRow.forkSourceMessageId ?? null, + forkTitleBase: threadRow.forkTitleBase ?? null, + forkTitleOrdinal: threadRow.forkTitleOrdinal ?? null, sidechatSourceThreadId: threadRow.sidechatSourceThreadId ?? null, lastKnownPr: threadRow.lastKnownPr, latestTurn: input.latestTurn, @@ -679,6 +685,9 @@ function toProjectedThread(input: { subagentNickname: threadRow.subagentNickname ?? null, subagentRole: threadRow.subagentRole ?? null, forkSourceThreadId: threadRow.forkSourceThreadId, + forkSourceMessageId: threadRow.forkSourceMessageId ?? null, + forkTitleBase: threadRow.forkTitleBase ?? null, + forkTitleOrdinal: threadRow.forkTitleOrdinal ?? null, sidechatSourceThreadId: threadRow.sidechatSourceThreadId ?? null, lastKnownPr: threadRow.lastKnownPr, latestTurn: input.latestTurn, @@ -785,6 +794,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_nickname AS "subagentNickname", subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", + fork_source_message_id AS "forkSourceMessageId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", last_known_pr_json AS "lastKnownPr", latest_turn_id AS "latestTurnId", @@ -827,6 +839,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_nickname AS "subagentNickname", subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", + fork_source_message_id AS "forkSourceMessageId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", last_known_pr_json AS "lastKnownPr", latest_turn_id AS "latestTurnId", @@ -1175,6 +1190,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_nickname AS "subagentNickname", subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", + fork_source_message_id AS "forkSourceMessageId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", last_known_pr_json AS "lastKnownPr", latest_turn_id AS "latestTurnId", @@ -1222,6 +1240,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_nickname AS "subagentNickname", subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", + fork_source_message_id AS "forkSourceMessageId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", last_known_pr_json AS "lastKnownPr", latest_turn_id AS "latestTurnId", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 3975700a0..d79ed04c0 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -33,7 +33,10 @@ import { TextGenerationError } from "../../git/Errors.ts"; import { ProviderAdapterRequestError } from "../../provider/Errors.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; -import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../../persistence/Layers/Sqlite.ts"; import { ProviderService, type ProviderServiceShape, @@ -131,11 +134,13 @@ describe("ProviderCommandReactor", () => { readonly studioOutputReactor?: Partial; readonly forkThreadResult?: ProviderForkThreadResult | null; readonly skillAuthoringEnabled?: boolean; + readonly filePersistence?: boolean; + readonly seedInitialState?: boolean; }) { const now = new Date().toISOString(); const baseDir = input?.baseDir ?? fs.mkdtempSync(path.join(os.tmpdir(), "synara-reactor-")); createdBaseDirs.add(baseDir); - const { stateDir } = deriveServerPathsSync(baseDir, undefined); + const { dbPath, stateDir } = deriveServerPathsSync(baseDir, undefined); createdStateDirs.add(stateDir); const runtimeEventPubSub = Effect.runSync(PubSub.unbounded()); let nextSessionIndex = 1; @@ -397,6 +402,9 @@ describe("ProviderCommandReactor", () => { Layer.provide(OrchestrationEventStoreLive), Layer.provide(OrchestrationCommandReceiptRepositoryLive), ); + const persistenceLayer = input?.filePersistence + ? makeSqlitePersistenceLive(dbPath).pipe(Layer.provide(NodeServices.layer)) + : SqlitePersistenceMemory; const layer = ProviderCommandReactorLive.pipe( Layer.provideMerge(orchestrationLayer), Layer.provideMerge(OrchestrationProjectionSnapshotQueryLive), @@ -430,44 +438,59 @@ describe("ProviderCommandReactor", () => { ), Layer.provideMerge(ServerConfig.layerTest(process.cwd(), baseDir)), Layer.provideMerge(NodeServices.layer), - Layer.provideMerge(SqlitePersistenceMemory), + Layer.provideMerge(persistenceLayer), ); - const runtime = ManagedRuntime.make(layer); + const harnessRuntime = ManagedRuntime.make(layer); + runtime = harnessRuntime; const emitRuntimeEvent = (event: ProviderRuntimeEvent) => Effect.runPromise(PubSub.publish(runtimeEventPubSub, event).pipe(Effect.asVoid)); - const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); - const reactor = await runtime.runPromise(Effect.service(ProviderCommandReactor)); - scope = await Effect.runPromise(Scope.make("sequential")); - await Effect.runPromise(reactor.start.pipe(Scope.provide(scope))); + const engine = await harnessRuntime.runPromise(Effect.service(OrchestrationEngineService)); + const reactor = await harnessRuntime.runPromise(Effect.service(ProviderCommandReactor)); + const harnessScope = await Effect.runPromise(Scope.make("sequential")); + scope = harnessScope; + await Effect.runPromise(reactor.start.pipe(Scope.provide(harnessScope))); const drain = () => Effect.runPromise(reactor.drain); - await Effect.runPromise( - engine.dispatch({ - type: "project.create", - commandId: CommandId.makeUnsafe("cmd-project-create"), - projectId: asProjectId("project-1"), - title: "Provider Project", - workspaceRoot: "/tmp/provider-project", - defaultModelSelection: modelSelection, - createdAt: now, - }), - ); - await Effect.runPromise( - engine.dispatch({ - type: "thread.create", - commandId: CommandId.makeUnsafe("cmd-thread-create"), - threadId: ThreadId.makeUnsafe("thread-1"), - projectId: asProjectId("project-1"), - title: "Thread", - modelSelection: modelSelection, - interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, - runtimeMode: "approval-required", - branch: null, - worktreePath: null, - createdAt: now, - }), - ); + if (input?.seedInitialState !== false) { + await Effect.runPromise( + engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-create"), + projectId: asProjectId("project-1"), + title: "Provider Project", + workspaceRoot: "/tmp/provider-project", + defaultModelSelection: modelSelection, + createdAt: now, + }), + ); + await Effect.runPromise( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-create"), + threadId: ThreadId.makeUnsafe("thread-1"), + projectId: asProjectId("project-1"), + title: "Thread", + modelSelection: modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: now, + }), + ); + } + + const dispose = async () => { + await Effect.runPromise(Scope.close(harnessScope, Exit.void)); + if (scope === harnessScope) { + scope = null; + } + await harnessRuntime.dispose(); + if (runtime === harnessRuntime) { + runtime = null; + } + }; return { engine, @@ -496,6 +519,7 @@ describe("ProviderCommandReactor", () => { drain, emitRuntimeEvent, setRuntimeSessionTurnState, + dispose, }; } @@ -689,6 +713,225 @@ describe("ProviderCommandReactor", () => { expect(secondInput?.input).toContain("Second side question"); }); + it("starts fresh and bootstraps only the selected prefix for a message-level fork", async () => { + const forkThreadId = ThreadId.makeUnsafe("thread-message-boundary-fork"); + const sourceBoundaryId = asMessageId("source-boundary-assistant"); + const harness = await createHarness({ + forkThreadResult: { + threadId: forkThreadId, + resumeCursor: { sessionId: "native-tip-that-must-not-be-used" }, + }, + }); + const now = new Date().toISOString(); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-message-boundary-source-import"), + threadId: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + messageId: asMessageId("source-prefix-user"), + role: "user", + text: "Question before the branch", + createdAt: now, + updatedAt: now, + }, + { + messageId: sourceBoundaryId, + role: "assistant", + text: "Answer at the branch boundary", + createdAt: now, + updatedAt: now, + }, + { + messageId: asMessageId("source-after-boundary-user"), + role: "user", + text: "This later source message must not be inherited", + createdAt: now, + updatedAt: now, + }, + ], + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-message-boundary-fork-create"), + threadId: forkThreadId, + sourceThreadId: ThreadId.makeUnsafe("thread-1"), + sourceMessageId: sourceBoundaryId, + projectId: asProjectId("project-1"), + title: "Message boundary fork", + modelSelection: { + provider: "codex", + model: "gpt-5-codex", + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("fork-prefix-user"), + role: "user", + text: "Question before the branch", + createdAt: now, + updatedAt: now, + }, + { + messageId: asMessageId("fork-boundary-assistant"), + role: "assistant", + text: "Answer at the branch boundary", + createdAt: now, + updatedAt: now, + }, + ], + createdAt: now, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-message-boundary-fork-turn"), + threadId: forkThreadId, + message: { + messageId: asMessageId("message-boundary-fork-new-user"), + role: "user", + text: "Take this in a different direction", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: now, + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + expect(harness.forkThread).not.toHaveBeenCalled(); + expect(harness.startSession).toHaveBeenCalled(); + const input = harness.sendTurn.mock.calls[0]?.[0] as { input?: string } | undefined; + expect(input?.input).toContain(""); + expect(input?.input).toContain("Question before the branch"); + expect(input?.input).toContain("Answer at the branch boundary"); + expect(input?.input).not.toContain("This later source message must not be inherited"); + expect(input?.input).toContain("Take this in a different direction"); + + const readModel = await Effect.runPromise(harness.engine.getReadModel()); + const forkedThread = readModel.threads.find((thread) => thread.id === forkThreadId); + expect(forkedThread?.title).toBe("Thread (2)"); + expect(forkedThread?.forkSourceThreadId).toBe("thread-1"); + expect(forkedThread?.forkSourceMessageId).toBe(sourceBoundaryId); + expect(forkedThread?.forkTitleBase).toBe("Thread"); + expect(forkedThread?.forkTitleOrdinal).toBe(2); + }); + + it("reconstructs message-fork bootstrap context after a reactor restart", async () => { + const baseDir = fs.mkdtempSync(path.join(os.tmpdir(), "synara-reactor-fork-restart-")); + const forkThreadId = ThreadId.makeUnsafe("thread-message-fork-restart"); + const sourceBoundaryId = asMessageId("source-message-fork-restart-boundary"); + const createdAt = new Date().toISOString(); + const firstHarness = await createHarness({ baseDir, filePersistence: true }); + + await Effect.runPromise( + firstHarness.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-message-fork-restart-source-import"), + threadId: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + messageId: asMessageId("source-message-fork-restart-user"), + role: "user", + text: "Persisted question before restart", + createdAt, + updatedAt: createdAt, + }, + { + messageId: sourceBoundaryId, + role: "assistant", + text: "Persisted answer before restart", + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ); + await Effect.runPromise( + firstHarness.engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-message-fork-restart-create"), + threadId: forkThreadId, + sourceThreadId: ThreadId.makeUnsafe("thread-1"), + sourceMessageId: sourceBoundaryId, + projectId: asProjectId("project-1"), + title: "Message fork restart", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("fork-message-restart-user"), + role: "user", + text: "Persisted question before restart", + createdAt, + updatedAt: createdAt, + }, + { + messageId: asMessageId("fork-message-restart-assistant"), + role: "assistant", + text: "Persisted answer before restart", + createdAt, + updatedAt: createdAt, + }, + ], + createdAt, + }), + ); + await firstHarness.dispose(); + + const restartedHarness = await createHarness({ + baseDir, + filePersistence: true, + seedInitialState: false, + }); + // The reactor streams are scoped fibers; let their subscriptions attach before publishing the + // post-restart turn so this test measures reconstruction rather than subscription scheduling. + await new Promise((resolve) => setTimeout(resolve, 50)); + await Effect.runPromise( + restartedHarness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-message-fork-restart-turn"), + threadId: forkThreadId, + message: { + messageId: asMessageId("message-fork-restart-new-user"), + role: "user", + text: "Continue after restart", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt, + }), + ); + + await waitFor(() => restartedHarness.sendTurn.mock.calls.length === 1); + const providerInput = restartedHarness.sendTurn.mock.calls[0]?.[0] as + | { input?: string } + | undefined; + expect(providerInput?.input).toContain(""); + expect(providerInput?.input).toContain("Persisted question before restart"); + expect(providerInput?.input).toContain("Persisted answer before restart"); + expect(providerInput?.input).toContain("Continue after restart"); + }); + it("bootstraps Droid sidechat context after a native provider fork", async () => { const threadId = ThreadId.makeUnsafe("thread-native-droid-sidechat"); const harness = await createHarness({ diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index a8383ce7c..3b293df71 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -961,7 +961,10 @@ const make = Effect.gen(function* () { return restartedSession.threadId; } - if (providerService.forkThread && thread.forkSourceThreadId) { + const isMessageBoundaryFork = + thread.forkSourceMessageId !== null && thread.forkSourceMessageId !== undefined; + + if (providerService.forkThread && thread.forkSourceThreadId && !isMessageBoundaryFork) { const forked = yield* providerService.forkThread({ sourceThreadId: thread.forkSourceThreadId, threadId, @@ -1005,6 +1008,13 @@ const make = Effect.gen(function* () { } } + if (shouldRegisterContextBootstrap && isMessageBoundaryFork) { + // Provider-native forks branch from the source session's current tip. A message-level + // fork must instead start fresh and bootstrap only the persisted imported prefix, or + // messages after the selected boundary could silently leak into the new conversation. + freshSessionContextBootstrapThreadIds.add(threadId); + } + if ( shouldRegisterContextBootstrap && thread.sidechatSourceThreadId && diff --git a/apps/server/src/orchestration/assistantMessageLifecycle.ts b/apps/server/src/orchestration/assistantMessageLifecycle.ts index 4599b970c..d0742fc14 100644 --- a/apps/server/src/orchestration/assistantMessageLifecycle.ts +++ b/apps/server/src/orchestration/assistantMessageLifecycle.ts @@ -56,7 +56,7 @@ export function collectAssistantMessagesToSettle(input: { /** A late provider delta may enrich settled text, but must never reopen its turn. */ export function isAssistantTurnTerminal( - thread: OrchestrationThread, + thread: Pick, turnId: TurnId | null | undefined, ): boolean { return ( diff --git a/apps/server/src/orchestration/decider.forkTitle.test.ts b/apps/server/src/orchestration/decider.forkTitle.test.ts new file mode 100644 index 000000000..e718d8b4a --- /dev/null +++ b/apps/server/src/orchestration/decider.forkTitle.test.ts @@ -0,0 +1,213 @@ +import { + CommandId, + DEFAULT_PROVIDER_INTERACTION_MODE, + EventId, + ProjectId, + ThreadId, + type OrchestrationEvent, + type OrchestrationReadModel, +} from "@synara/contracts"; +import { Effect } from "effect"; +import { describe, expect, it } from "vitest"; + +import { decideOrchestrationCommand } from "./decider.ts"; +import { createEmptyReadModel, projectEvent } from "./projector.ts"; + +const PROJECT_ID = ProjectId.makeUnsafe("project-1"); +const ROOT_THREAD_ID = ThreadId.makeUnsafe("thread-root"); +const NOW = "2026-07-22T10:00:00.000Z"; + +const eventId = (value: string) => EventId.makeUnsafe(value); +const commandId = (value: string) => CommandId.makeUnsafe(value); + +async function createRootReadModel(title = "Greeting") { + const withProject = await Effect.runPromise( + projectEvent(createEmptyReadModel(NOW), { + sequence: 1, + eventId: eventId("event-project"), + aggregateKind: "project", + aggregateId: PROJECT_ID, + type: "project.created", + occurredAt: NOW, + commandId: commandId("command-project"), + causationEventId: null, + correlationId: commandId("command-project"), + metadata: {}, + payload: { + projectId: PROJECT_ID, + title: "Project", + workspaceRoot: "/tmp/project", + defaultModelSelection: null, + scripts: [], + createdAt: NOW, + updatedAt: NOW, + }, + }), + ); + + return Effect.runPromise( + projectEvent(withProject, { + sequence: 2, + eventId: eventId("event-root"), + aggregateKind: "thread", + aggregateId: ROOT_THREAD_ID, + type: "thread.created", + occurredAt: NOW, + commandId: commandId("command-root"), + causationEventId: null, + correlationId: commandId("command-root"), + metadata: {}, + payload: { + threadId: ROOT_THREAD_ID, + projectId: PROJECT_ID, + title, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + forkSourceThreadId: null, + forkSourceMessageId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + sidechatSourceThreadId: null, + handoff: null, + createdAt: NOW, + updatedAt: NOW, + }, + }), + ); +} + +const forkCommand = (input: { + readonly threadId: string; + readonly sourceThreadId: string; + readonly title?: string; + readonly sidechatSourceThreadId?: string; +}) => ({ + type: "thread.fork.create" as const, + commandId: commandId(`command-${input.threadId}`), + threadId: ThreadId.makeUnsafe(input.threadId), + sourceThreadId: ThreadId.makeUnsafe(input.sourceThreadId), + ...(input.sidechatSourceThreadId + ? { sidechatSourceThreadId: ThreadId.makeUnsafe(input.sidechatSourceThreadId) } + : {}), + projectId: PROJECT_ID, + title: input.title ?? "stale client title", + modelSelection: { provider: "codex" as const, model: "gpt-5-codex" }, + runtimeMode: "approval-required" as const, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local" as const, + branch: null, + worktreePath: null, + importedMessages: [], + createdAt: NOW, +}); + +async function decideCreatedEvent( + readModel: OrchestrationReadModel, + command: ReturnType, +) { + const result = await Effect.runPromise(decideOrchestrationCommand({ command, readModel })); + const event = (Array.isArray(result) ? result : [result])[0]; + expect(event?.type).toBe("thread.created"); + if (!event || event.type !== "thread.created") { + throw new Error("Expected thread.created event"); + } + return event; +} + +async function applyEvent( + readModel: OrchestrationReadModel, + event: Omit, + sequence: number, +) { + return Effect.runPromise(projectEvent(readModel, { ...event, sequence } as OrchestrationEvent)); +} + +describe("thread.fork.create title allocation", () => { + it("allocates repeated fork titles from authoritative serialized state", async () => { + const rootModel = await createRootReadModel(); + const fork2Event = await decideCreatedEvent( + rootModel, + forkCommand({ threadId: "fork-2", sourceThreadId: ROOT_THREAD_ID }), + ); + expect(fork2Event.payload).toMatchObject({ + title: "Greeting (2)", + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + + const withFork2 = await applyEvent(rootModel, fork2Event, 3); + const fork3Event = await decideCreatedEvent( + withFork2, + forkCommand({ threadId: "fork-3", sourceThreadId: ROOT_THREAD_ID }), + ); + expect(fork3Event.payload).toMatchObject({ + title: "Greeting (3)", + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }); + }); + + it("starts a new series after the source fork is manually renamed", async () => { + const rootModel = await createRootReadModel(); + const fork2Event = await decideCreatedEvent( + rootModel, + forkCommand({ threadId: "fork-2", sourceThreadId: ROOT_THREAD_ID }), + ); + const withFork2 = await applyEvent(rootModel, fork2Event, 3); + const renameResult = await Effect.runPromise( + decideOrchestrationCommand({ + readModel: withFork2, + command: { + type: "thread.meta.update", + commandId: commandId("command-rename-fork-2"), + threadId: ThreadId.makeUnsafe("fork-2"), + title: "Experiment (2026)", + }, + }), + ); + const renameEvent = (Array.isArray(renameResult) ? renameResult : [renameResult])[0]; + if (!renameEvent || renameEvent.type !== "thread.meta-updated") { + throw new Error("Expected thread.meta-updated event"); + } + const renamedModel = await applyEvent(withFork2, renameEvent, 4); + + const renamedForkEvent = await decideCreatedEvent( + renamedModel, + forkCommand({ threadId: "experiment-2", sourceThreadId: "fork-2" }), + ); + expect(renamedForkEvent.payload).toMatchObject({ + title: "Experiment (2026) (2)", + forkTitleBase: "Experiment (2026)", + forkTitleOrdinal: 2, + }); + }); + + it("preserves sidechat-owned titles and excludes them from fork numbering", async () => { + const rootModel = await createRootReadModel(); + const sidechatEvent = await decideCreatedEvent( + rootModel, + forkCommand({ + threadId: "sidechat", + sourceThreadId: ROOT_THREAD_ID, + sidechatSourceThreadId: ROOT_THREAD_ID, + title: "Sidechat: Greeting", + }), + ); + expect(sidechatEvent.payload).toMatchObject({ + title: "Sidechat: Greeting", + forkTitleBase: null, + forkTitleOrdinal: null, + }); + + const withSidechat = await applyEvent(rootModel, sidechatEvent, 3); + const fork2Event = await decideCreatedEvent( + withSidechat, + forkCommand({ threadId: "fork-2", sourceThreadId: ROOT_THREAD_ID }), + ); + expect(fork2Event.payload.title).toBe("Greeting (2)"); + }); +}); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index c99c53f08..3ffa863a8 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -28,6 +28,8 @@ import { isAssistantTurnTerminal, } from "./assistantMessageLifecycle.ts"; import { hasNativeHandoffMessages } from "./handoff.ts"; +import { resolveNextForkTitle } from "./forkTitle.ts"; +import { validateImportedMessageIds, validateMessageForkImport } from "./messageFork.ts"; import { resolveStableMessageTurnId } from "./messageTurnId.ts"; import { listActiveProjectsByWorkspaceRoot, @@ -49,6 +51,12 @@ const STUDIO_PROJECT_KIND_SET = new Set(["studio"]); // use placeholder roots (e.g. the home dir) that legitimately coexist with real projects. const WORKSPACE_OWNING_PROJECT_KIND_SET = new Set(["project", "studio"]); +function collectExistingMessageIds(readModel: OrchestrationReadModel) { + return new Set( + readModel.threads.flatMap((thread) => thread.messages.map((message) => message.id)), + ); +} + const defaultMetadata: Omit = { eventId: crypto.randomUUID() as OrchestrationEvent["eventId"], aggregateKind: "thread", @@ -467,6 +475,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" subagentNickname: command.subagentNickname, subagentRole: command.subagentRole, forkSourceThreadId: null, + forkSourceMessageId: null, lastKnownPr: command.lastKnownPr, handoff: null, createdAt: command.createdAt, @@ -503,6 +512,18 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" detail: `Source thread '${command.sourceThreadId}' belongs to a different project.`, }); } + + const importedMessageIdValidation = validateImportedMessageIds({ + importedMessages: command.importedMessages, + existingMessageIds: collectExistingMessageIds(readModel), + }); + if (!importedMessageIdValidation.ok) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Imported message id '${importedMessageIdValidation.conflictingMessageId}' must be unique and must not already exist.`, + }); + } + if (sourceThread.handoff !== null && !hasNativeHandoffMessages(sourceThread)) { return yield* new OrchestrationCommandInvariantError({ commandType: command.type, @@ -548,6 +569,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + forkSourceMessageId: null, handoff: { sourceThreadId: command.sourceThreadId, sourceProvider: sourceThread.modelSelection.provider, @@ -614,6 +636,48 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" }); } + const importedMessageIdValidation = validateImportedMessageIds({ + importedMessages: command.importedMessages, + existingMessageIds: collectExistingMessageIds(readModel), + }); + if (!importedMessageIdValidation.ok) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Imported message id '${importedMessageIdValidation.conflictingMessageId}' must be unique and must not already exist.`, + }); + } + + if (command.sourceMessageId !== undefined) { + const validation = validateMessageForkImport({ + sourceThread, + sourceMessageId: command.sourceMessageId, + importedMessages: command.importedMessages, + }); + if (!validation.ok && validation.reason === "invalid-source") { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Fork source message '${command.sourceMessageId}' is not a completed conversation message in source thread '${command.sourceThreadId}'.`, + }); + } + if (!validation.ok) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Message-boundary fork through '${command.sourceMessageId}' must import the exact ${validation.expectedImportedMessageCount} completed conversation messages from the authoritative source prefix.`, + }); + } + } + + const resolvedForkTitle = command.sidechatSourceThreadId + ? { + title: command.title, + forkTitleBase: null, + forkTitleOrdinal: null, + } + : resolveNextForkTitle({ + sourceThread, + threads: readModel.threads, + }); + const createdEvent: Omit = { ...withEventBase({ aggregateKind: "thread", @@ -625,7 +689,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" payload: { threadId: command.threadId, projectId: command.projectId, - title: command.title, + title: resolvedForkTitle.title, modelSelection: command.modelSelection, runtimeMode: command.runtimeMode, interactionMode: command.interactionMode, @@ -652,6 +716,9 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" subagentNickname: null, subagentRole: null, forkSourceThreadId: command.sourceThreadId, + forkSourceMessageId: command.sourceMessageId ?? null, + forkTitleBase: resolvedForkTitle.forkTitleBase, + forkTitleOrdinal: resolvedForkTitle.forkTitleOrdinal, sidechatSourceThreadId: command.sidechatSourceThreadId, handoff: null, createdAt: command.createdAt, diff --git a/apps/server/src/orchestration/forkTitle.test.ts b/apps/server/src/orchestration/forkTitle.test.ts new file mode 100644 index 000000000..28eba89d1 --- /dev/null +++ b/apps/server/src/orchestration/forkTitle.test.ts @@ -0,0 +1,147 @@ +import { describe, expect, it } from "vitest"; + +import { resolveNextForkTitle, type ForkTitleThread } from "./forkTitle.ts"; + +const projectId = "project-1"; + +const thread = ( + id: string, + title: string, + overrides: Partial = {}, +): ForkTitleThread => ({ + id, + projectId, + title, + forkSourceThreadId: null, + sidechatSourceThreadId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + archivedAt: null, + deletedAt: null, + ...overrides, +}); + +describe("resolveNextForkTitle", () => { + it("numbers the first and repeated forks from the root title", () => { + const root = thread("root", "Greeting"); + expect(resolveNextForkTitle({ sourceThread: root, threads: [root] })).toEqual({ + title: "Greeting (2)", + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + + const fork2 = thread("fork-2", "Greeting (2)", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + expect(resolveNextForkTitle({ sourceThread: root, threads: [root, fork2] })).toEqual({ + title: "Greeting (3)", + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }); + }); + + it("continues the same family when an automatically named child is forked", () => { + const root = thread("root", "Greeting"); + const fork2 = thread("fork-2", "Greeting (2)", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + const fork3 = thread("fork-3", "Greeting (3)", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }); + + expect(resolveNextForkTitle({ sourceThread: fork2, threads: [root, fork2, fork3] })).toEqual({ + title: "Greeting (4)", + forkTitleBase: "Greeting", + forkTitleOrdinal: 4, + }); + }); + + it("starts a new naming series after a manual rename", () => { + const root = thread("root", "Greeting"); + const renamedFork = thread("fork-2", "Experiment", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + const experiment2 = thread("experiment-2", "Experiment (2)", { + forkSourceThreadId: renamedFork.id, + forkTitleBase: "Experiment", + forkTitleOrdinal: 2, + }); + + expect( + resolveNextForkTitle({ sourceThread: renamedFork, threads: [root, renamedFork] }), + ).toEqual({ + title: "Experiment (2)", + forkTitleBase: "Experiment", + forkTitleOrdinal: 2, + }); + expect( + resolveNextForkTitle({ + sourceThread: renamedFork, + threads: [root, renamedFork, experiment2], + }), + ).toEqual({ + title: "Experiment (3)", + forkTitleBase: "Experiment", + forkTitleOrdinal: 3, + }); + }); + + it("preserves natural numeric parentheses as part of the base title", () => { + const root = thread("root", "Plan (2026)"); + const renamedFork = thread("fork-2", "Experiment (2026)", { + forkSourceThreadId: root.id, + forkTitleBase: "Plan (2026)", + forkTitleOrdinal: 2, + }); + + expect(resolveNextForkTitle({ sourceThread: root, threads: [root] }).title).toBe( + "Plan (2026) (2)", + ); + expect( + resolveNextForkTitle({ sourceThread: renamedFork, threads: [root, renamedFork] }).title, + ).toBe("Experiment (2026) (2)"); + }); + + it("counts archived and deleted family members without crossing families or sidechats", () => { + const root = thread("root", "Greeting"); + const archivedFork = thread("fork-2", "Greeting (2)", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + archivedAt: "2026-07-22T00:00:00.000Z", + }); + const deletedFork = thread("fork-3", "Greeting (3)", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + deletedAt: "2026-07-22T01:00:00.000Z", + }); + const otherRoot = thread("other-root", "Greeting"); + const otherFork = thread("other-fork", "Greeting (20)", { + forkSourceThreadId: otherRoot.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 20, + }); + const sidechat = thread("sidechat", "Greeting (99)", { + forkSourceThreadId: root.id, + sidechatSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 99, + }); + + expect( + resolveNextForkTitle({ + sourceThread: root, + threads: [root, archivedFork, deletedFork, otherRoot, otherFork, sidechat], + }).title, + ).toBe("Greeting (4)"); + }); +}); diff --git a/apps/server/src/orchestration/forkTitle.ts b/apps/server/src/orchestration/forkTitle.ts new file mode 100644 index 000000000..d6c1c9de3 --- /dev/null +++ b/apps/server/src/orchestration/forkTitle.ts @@ -0,0 +1,96 @@ +// FILE: forkTitle.ts +// Purpose: Resolve deterministic server-owned titles for conversation forks. +// Layer: Server orchestration domain logic + +export interface ForkTitleThread { + readonly id: string; + readonly projectId: string; + readonly title: string; + readonly forkSourceThreadId?: string | null | undefined; + readonly sidechatSourceThreadId?: string | null | undefined; + readonly forkTitleBase?: string | null | undefined; + readonly forkTitleOrdinal?: number | null | undefined; + readonly archivedAt?: string | null | undefined; + readonly deletedAt?: string | null | undefined; +} + +export interface ResolvedForkTitle { + readonly title: string; + readonly forkTitleBase: string; + readonly forkTitleOrdinal: number; +} + +export function formatForkTitle(base: string, ordinal: number): string { + return `${base} (${ordinal})`; +} + +function isStoredForkOrdinal(value: number | null | undefined): value is number { + return Number.isSafeInteger(value) && (value ?? 0) >= 2; +} + +function resolveForkFamilyRootId( + thread: ForkTitleThread, + threadsById: ReadonlyMap, +): string { + let current = thread; + const visited = new Set(); + + while (current.forkSourceThreadId) { + if (visited.has(current.id)) { + return [...visited, current.id].toSorted()[0] ?? current.id; + } + visited.add(current.id); + + const sourceId = current.forkSourceThreadId; + const source = threadsById.get(sourceId); + if (!source) { + return sourceId; + } + current = source; + } + + return current.id; +} + +export function resolveNextForkTitle(input: { + readonly sourceThread: ForkTitleThread; + readonly threads: ReadonlyArray; +}): ResolvedForkTitle { + const source = input.sourceThread; + const threadsById = new Map(input.threads.map((thread) => [thread.id, thread])); + if (!threadsById.has(source.id)) { + threadsById.set(source.id, source); + } + + const sourceUsesAutomaticTitle = + source.forkTitleBase !== null && + source.forkTitleBase !== undefined && + isStoredForkOrdinal(source.forkTitleOrdinal) && + source.title === formatForkTitle(source.forkTitleBase, source.forkTitleOrdinal); + const forkTitleBase = sourceUsesAutomaticTitle ? source.forkTitleBase : source.title; + const familyRootId = resolveForkFamilyRootId(source, threadsById); + + let highestOrdinal = 1; + for (const thread of input.threads) { + if ( + thread.projectId !== source.projectId || + thread.sidechatSourceThreadId || + thread.forkTitleBase !== forkTitleBase || + !isStoredForkOrdinal(thread.forkTitleOrdinal) || + resolveForkFamilyRootId(thread, threadsById) !== familyRootId + ) { + continue; + } + highestOrdinal = Math.max(highestOrdinal, thread.forkTitleOrdinal); + } + + if (highestOrdinal >= Number.MAX_SAFE_INTEGER) { + throw new RangeError(`Fork title ordinal is exhausted for '${forkTitleBase}'.`); + } + const forkTitleOrdinal = highestOrdinal + 1; + return { + title: formatForkTitle(forkTitleBase, forkTitleOrdinal), + forkTitleBase, + forkTitleOrdinal, + }; +} diff --git a/apps/server/src/orchestration/messageFork.test.ts b/apps/server/src/orchestration/messageFork.test.ts new file mode 100644 index 000000000..6c940f456 --- /dev/null +++ b/apps/server/src/orchestration/messageFork.test.ts @@ -0,0 +1,186 @@ +import { + MessageId, + TurnId, + type OrchestrationMessage, + type OrchestrationThread, + type ThreadHandoffImportedMessage, +} from "@synara/contracts"; +import { describe, expect, it } from "vitest"; + +import { validateImportedMessageIds, validateMessageForkImport } from "./messageFork.ts"; + +const now = "2026-07-22T10:00:00.000Z"; +const turnId = TurnId.makeUnsafe("turn-1"); +const userMessageId = MessageId.makeUnsafe("message-user"); +const assistantMessageId = MessageId.makeUnsafe("message-assistant"); + +const sourceMessages: OrchestrationMessage[] = [ + { + id: userMessageId, + role: "user", + text: [ + "Question", + "", + "", + "- assistant message earlier:", + " selected text", + "", + ].join("\n"), + attachments: [ + { + type: "assistant-selection", + id: "selection-1", + assistantMessageId: MessageId.makeUnsafe("earlier"), + text: "selected text", + }, + ], + turnId, + streaming: false, + source: "native", + createdAt: now, + updatedAt: now, + }, + { + id: assistantMessageId, + role: "assistant", + text: "Completed answer", + turnId, + // A delayed projection may retain this flag after the terminal turn event. + streaming: true, + source: "native", + createdAt: "2026-07-22T10:00:01.000Z", + updatedAt: "2026-07-22T10:00:02.000Z", + }, +]; + +const sourceThread: Pick = { + messages: sourceMessages, + latestTurn: { + turnId, + state: "completed", + requestedAt: now, + startedAt: now, + completedAt: "2026-07-22T10:00:02.000Z", + assistantMessageId, + }, +}; + +const importedMessages: ThreadHandoffImportedMessage[] = [ + { + messageId: MessageId.makeUnsafe("import-user"), + role: "user", + text: "Question", + attachments: sourceMessages[0]!.attachments, + createdAt: now, + updatedAt: now, + }, + { + messageId: MessageId.makeUnsafe("import-assistant"), + role: "assistant", + text: "Completed answer", + createdAt: "2026-07-22T10:00:01.000Z", + // Client state can lack the delayed completion timestamp; it is not part of + // transcript identity, so the authoritative comparison intentionally ignores it. + updatedAt: "2026-07-22T10:00:01.000Z", + }, +]; + +describe("validateMessageForkImport", () => { + it("accepts the exact prefix through a terminal assistant with a stale streaming flag", () => { + expect( + validateMessageForkImport({ + sourceThread, + sourceMessageId: assistantMessageId, + importedMessages, + }), + ).toEqual({ ok: true }); + }); + + it("rejects a streaming assistant while its turn is still running", () => { + expect( + validateMessageForkImport({ + sourceThread: { + ...sourceThread, + latestTurn: { + ...sourceThread.latestTurn!, + state: "running", + completedAt: null, + }, + }, + sourceMessageId: assistantMessageId, + importedMessages, + }), + ).toEqual({ + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }); + }); + + it("rejects same-length imports whose text or attachments do not match the source prefix", () => { + expect( + validateMessageForkImport({ + sourceThread, + sourceMessageId: assistantMessageId, + importedMessages: [ + { + ...importedMessages[0]!, + text: "Different question", + }, + importedMessages[1]!, + ], + }), + ).toEqual({ + ok: false, + reason: "import-mismatch", + expectedImportedMessageCount: 2, + }); + + expect( + validateMessageForkImport({ + sourceThread, + sourceMessageId: assistantMessageId, + importedMessages: [ + { + ...importedMessages[0]!, + attachments: [], + }, + importedMessages[1]!, + ], + }), + ).toEqual({ + ok: false, + reason: "import-mismatch", + expectedImportedMessageCount: 2, + }); + }); +}); + +describe("validateImportedMessageIds", () => { + it("rejects duplicate ids inside an import", () => { + expect( + validateImportedMessageIds({ + importedMessages: [ + importedMessages[0]!, + { ...importedMessages[1]!, messageId: importedMessages[0]!.messageId }, + ], + existingMessageIds: new Set(), + }), + ).toEqual({ + ok: false, + conflictingMessageId: importedMessages[0]!.messageId, + }); + }); + + it("rejects ids already owned by any projected thread", () => { + expect( + validateImportedMessageIds({ + importedMessages, + existingMessageIds: new Set([importedMessages[1]!.messageId]), + }), + ).toEqual({ + ok: false, + conflictingMessageId: importedMessages[1]!.messageId, + }); + }); +}); diff --git a/apps/server/src/orchestration/messageFork.ts b/apps/server/src/orchestration/messageFork.ts new file mode 100644 index 000000000..dfca5f8fe --- /dev/null +++ b/apps/server/src/orchestration/messageFork.ts @@ -0,0 +1,164 @@ +// FILE: messageFork.ts +// Purpose: Validate server-authoritative message boundaries and imported fork prefixes. +// Layer: Server orchestration domain logic + +import type { + ChatAttachment, + MessageId, + OrchestrationMessage, + OrchestrationThread, + ThreadHandoffImportedMessage, +} from "@synara/contracts"; +import { stripEmbeddedAssistantSelections } from "@synara/shared/assistantSelections"; + +import { isAssistantTurnTerminal } from "./assistantMessageLifecycle.ts"; + +export type MessageForkValidation = + | { readonly ok: true } + | { + readonly ok: false; + readonly reason: "invalid-source" | "import-mismatch"; + readonly expectedImportedMessageCount: number; + }; + +export interface ImportedMessageIdValidation { + readonly ok: boolean; + readonly conflictingMessageId: MessageId | null; +} + +type MessageForkSourceThread = Pick; + +function isCompletedConversationMessage( + thread: MessageForkSourceThread, + message: OrchestrationMessage, +): message is OrchestrationMessage & { readonly role: "user" | "assistant" } { + if (message.role !== "user" && message.role !== "assistant") { + return false; + } + if (!message.streaming) { + return true; + } + return message.role === "assistant" && isAssistantTurnTerminal(thread, message.turnId); +} + +function attachmentsEqual( + left: ReadonlyArray | undefined, + right: ReadonlyArray | undefined, +): boolean { + const leftAttachments = left ?? []; + const rightAttachments = right ?? []; + if (leftAttachments.length !== rightAttachments.length) { + return false; + } + + return leftAttachments.every((leftAttachment, index) => { + const rightAttachment = rightAttachments[index]; + if ( + !rightAttachment || + leftAttachment.type !== rightAttachment.type || + leftAttachment.id !== rightAttachment.id + ) { + return false; + } + if ( + leftAttachment.type === "assistant-selection" && + rightAttachment.type === "assistant-selection" + ) { + return ( + leftAttachment.assistantMessageId === rightAttachment.assistantMessageId && + leftAttachment.text === rightAttachment.text + ); + } + if ( + leftAttachment.type === "assistant-selection" || + rightAttachment.type === "assistant-selection" + ) { + return false; + } + return ( + leftAttachment.name === rightAttachment.name && + leftAttachment.mimeType === rightAttachment.mimeType && + leftAttachment.sizeBytes === rightAttachment.sizeBytes + ); + }); +} + +function importedMessageMatchesSource( + importedMessage: ThreadHandoffImportedMessage, + sourceMessage: OrchestrationMessage & { readonly role: "user" | "assistant" }, +): boolean { + const expectedText = + sourceMessage.role === "user" + ? stripEmbeddedAssistantSelections(sourceMessage.text) + : sourceMessage.text; + return ( + importedMessage.role === sourceMessage.role && + importedMessage.text === expectedText && + importedMessage.createdAt === sourceMessage.createdAt && + attachmentsEqual(importedMessage.attachments, sourceMessage.attachments) + ); +} + +export function validateMessageForkImport(input: { + readonly sourceThread: MessageForkSourceThread; + readonly sourceMessageId: MessageId; + readonly importedMessages: ReadonlyArray; +}): MessageForkValidation { + const sourceMessageIndex = input.sourceThread.messages.findIndex( + (message) => message.id === input.sourceMessageId, + ); + const sourceMessage = input.sourceThread.messages[sourceMessageIndex]; + if ( + sourceMessageIndex < 0 || + !sourceMessage || + !isCompletedConversationMessage(input.sourceThread, sourceMessage) + ) { + return { + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }; + } + + const expectedMessages = input.sourceThread.messages + .slice(0, sourceMessageIndex + 1) + .filter((message) => isCompletedConversationMessage(input.sourceThread, message)); + const importedMessagesMatch = + input.importedMessages.length === expectedMessages.length && + input.importedMessages.every((message, index) => { + const expectedMessage = expectedMessages[index]; + return expectedMessage ? importedMessageMatchesSource(message, expectedMessage) : false; + }); + return importedMessagesMatch + ? { ok: true } + : { + ok: false, + reason: "import-mismatch", + expectedImportedMessageCount: expectedMessages.length, + }; +} + +/** + * Imported transcript rows must never reuse a projected message id. Projection message ids are + * globally keyed, so accepting a duplicate would either collapse the imported prefix or reassign + * an existing source row to the destination thread. + */ +export function validateImportedMessageIds(input: { + readonly importedMessages: ReadonlyArray; + readonly existingMessageIds: ReadonlySet; +}): ImportedMessageIdValidation { + const importedMessageIds = new Set(); + for (const message of input.importedMessages) { + if ( + importedMessageIds.has(message.messageId) || + input.existingMessageIds.has(message.messageId) + ) { + return { + ok: false, + conflictingMessageId: message.messageId, + }; + } + importedMessageIds.add(message.messageId); + } + return { ok: true, conflictingMessageId: null }; +} diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 23dbc79c6..e11a0e089 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -174,6 +174,9 @@ describe("orchestration projector", () => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + forkSourceMessageId: null, + forkTitleBase: null, + forkTitleOrdinal: null, sidechatSourceThreadId: null, lastKnownPr: null, latestTurn: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index dd35edea1..55f60ca06 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -403,6 +403,9 @@ export function projectEvent( subagentNickname: payload.subagentNickname, subagentRole: payload.subagentRole, forkSourceThreadId: payload.forkSourceThreadId, + forkSourceMessageId: payload.forkSourceMessageId, + forkTitleBase: payload.forkTitleBase, + forkTitleOrdinal: payload.forkTitleOrdinal, sidechatSourceThreadId: payload.sidechatSourceThreadId, lastKnownPr: payload.lastKnownPr ?? null, latestTurn: null, diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 9055aa788..dcdc57176 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -67,6 +67,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_nickname, subagent_role, fork_source_thread_id, + fork_source_message_id, + fork_title_base, + fork_title_ordinal, sidechat_source_thread_id, last_known_pr_json, latest_turn_id, @@ -103,6 +106,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.subagentNickname ?? null}, ${row.subagentRole ?? null}, ${row.forkSourceThreadId ?? null}, + ${row.forkSourceMessageId ?? null}, + ${row.forkTitleBase ?? null}, + ${row.forkTitleOrdinal ?? null}, ${row.sidechatSourceThreadId ?? null}, ${row.lastKnownPr === null ? null : JSON.stringify(row.lastKnownPr)}, ${row.latestTurnId}, @@ -139,6 +145,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_nickname = excluded.subagent_nickname, subagent_role = excluded.subagent_role, fork_source_thread_id = excluded.fork_source_thread_id, + fork_source_message_id = excluded.fork_source_message_id, + fork_title_base = excluded.fork_title_base, + fork_title_ordinal = excluded.fork_title_ordinal, sidechat_source_thread_id = excluded.sidechat_source_thread_id, last_known_pr_json = excluded.last_known_pr_json, latest_turn_id = excluded.latest_turn_id, @@ -182,6 +191,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_nickname AS "subagentNickname", subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", + fork_source_message_id AS "forkSourceMessageId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", last_known_pr_json AS "lastKnownPr", latest_turn_id AS "latestTurnId", @@ -227,6 +239,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_nickname AS "subagentNickname", subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", + fork_source_message_id AS "forkSourceMessageId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", last_known_pr_json AS "lastKnownPr", latest_turn_id AS "latestTurnId", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 8ef19dd50..581d6ddb1 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -71,6 +71,8 @@ import Migration0052 from "./Migrations/052_ProjectionThreadUserMessageSummaryIn import Migration0053 from "./Migrations/053_BackfillThreadActivitySequence.ts"; import Migration0054 from "./Migrations/054_ProjectPullRequestPins.ts"; import Migration0055 from "./Migrations/055_ProjectionThreadSessionLastErrorEvent.ts"; +import Migration0056 from "./Migrations/056_ProjectionThreadsForkSourceMessage.ts"; +import Migration0057 from "./Migrations/057_ProjectionThreadsForkTitleSequence.ts"; /** * Migration loader with all migrations defined inline. @@ -138,6 +140,8 @@ export const migrationEntries = [ [53, "BackfillThreadActivitySequence", Migration0053], [54, "ProjectPullRequestPins", Migration0054], [55, "ProjectionThreadSessionLastErrorEvent", Migration0055], + [56, "ProjectionThreadsForkSourceMessage", Migration0056], + [57, "ProjectionThreadsForkTitleSequence", Migration0057], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.test.ts b/apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.test.ts new file mode 100644 index 000000000..fffd0b99b --- /dev/null +++ b/apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.test.ts @@ -0,0 +1,84 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { describe } from "vitest"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const projectionThreadColumnNames = (sql: SqlClient.SqlClient) => + sql<{ readonly name: string }>` + SELECT name FROM pragma_table_info('projection_threads') + `.pipe(Effect.map((rows) => rows.map((row) => row.name))); + +describe("056_ProjectionThreadsForkSourceMessage", () => { + it.effect("adds and round-trips the message-level fork boundary idempotently", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 55 }); + assert.notInclude(yield* projectionThreadColumnNames(sql), "fork_source_message_id"); + + yield* runMigrations(); + yield* runMigrations(); + + assert.include(yield* projectionThreadColumnNames(sql), "fork_source_message_id"); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + env_mode, + create_branch_flow_completed, + is_pinned, + fork_source_thread_id, + fork_source_message_id, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at + ) + VALUES ( + 'thread-fork', + 'project-1', + 'Fork', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'local', + 0, + 0, + 'thread-source', + 'message-boundary', + 0, + 0, + 0, + '2026-07-22T10:00:00.000Z', + '2026-07-22T10:00:00.000Z' + ) + `; + + const rows = yield* sql<{ + readonly forkSourceThreadId: string | null; + readonly forkSourceMessageId: string | null; + }>` + SELECT + fork_source_thread_id AS "forkSourceThreadId", + fork_source_message_id AS "forkSourceMessageId" + FROM projection_threads + WHERE thread_id = 'thread-fork' + `; + assert.deepEqual(rows, [ + { + forkSourceThreadId: "thread-source", + forkSourceMessageId: "message-boundary", + }, + ]); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); +}); diff --git a/apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.ts b/apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.ts new file mode 100644 index 000000000..0fdd46b75 --- /dev/null +++ b/apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.ts @@ -0,0 +1,19 @@ +// FILE: 056_ProjectionThreadsForkSourceMessage.ts +// Purpose: Persist the exact transcript boundary for message-level conversation forks. +// Layer: Server persistence migration + +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { columnExists } from "./schemaHelpers.ts"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + if (!(yield* columnExists(sql, "projection_threads", "fork_source_message_id"))) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN fork_source_message_id TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts new file mode 100644 index 000000000..6f4ffe61d --- /dev/null +++ b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts @@ -0,0 +1,285 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { describe } from "vitest"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +const projectionThreadColumnNames = (sql: SqlClient.SqlClient) => + sql<{ readonly name: string }>` + SELECT name FROM pragma_table_info('projection_threads') + `.pipe(Effect.map((rows) => rows.map((row) => row.name))); + +describe("057_ProjectionThreadsForkTitleSequence", () => { + it.effect("adds title metadata and backfills retained legacy fork families idempotently", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + yield* runMigrations({ toMigrationInclusive: 56 }); + assert.notInclude(yield* projectionThreadColumnNames(sql), "fork_title_base"); + assert.notInclude(yield* projectionThreadColumnNames(sql), "fork_title_ordinal"); + + const createdAt = "2026-07-22T10:00:00.000Z"; + const rows = [ + { + id: "root", + title: "Greeting", + sourceId: null, + sidechatSourceId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + }, + { + id: "fork-2", + title: "Greeting", + sourceId: "root", + sidechatSourceId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + }, + { + id: "fork-3", + title: "Greeting", + sourceId: "root", + sidechatSourceId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + }, + { + id: "experiment-2", + title: "Experiment", + sourceId: "fork-2", + sidechatSourceId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + }, + { + id: "sidechat", + title: "Sidechat: Greeting", + sourceId: "root", + sidechatSourceId: "root", + forkTitleBase: null, + forkTitleOrdinal: null, + }, + { + id: "pre-numbered", + title: "Greeting (7)", + sourceId: "root", + sidechatSourceId: null, + forkTitleBase: "Greeting", + forkTitleOrdinal: 7, + }, + ] as const; + + for (const [index, row] of rows.entries()) { + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + env_mode, + create_branch_flow_completed, + is_pinned, + fork_source_thread_id, + sidechat_source_thread_id, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at + ) + VALUES ( + ${row.id}, + 'project-1', + ${row.title}, + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'local', + 0, + 0, + ${row.sourceId}, + ${row.sidechatSourceId}, + 0, + 0, + 0, + ${createdAt}, + ${createdAt} + ) + `; + + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${`event-${row.id}`}, + 'thread', + ${row.id}, + 1, + 'thread.created', + ${createdAt}, + ${`command-${row.id}`}, + NULL, + ${`command-${row.id}`}, + 'client', + ${JSON.stringify({ + threadId: row.id, + projectId: "project-1", + title: row.title, + forkSourceThreadId: row.sourceId, + sidechatSourceThreadId: row.sidechatSourceId, + forkTitleBase: row.forkTitleBase, + forkTitleOrdinal: row.forkTitleOrdinal, + createdAt, + updatedAt: createdAt, + })}, + '{}' + ) + `; + + if (index === 1) { + yield* sql` + UPDATE projection_threads + SET title = 'Renamed first fork' + WHERE thread_id = 'fork-2' + `; + } + } + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + env_mode, + create_branch_flow_completed, + is_pinned, + fork_source_thread_id, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at + ) + VALUES ( + 'projection-only', + 'project-1', + 'Greeting', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'local', + 0, + 0, + 'root', + 0, + 0, + 0, + ${createdAt}, + ${createdAt} + ) + `; + + yield* runMigrations(); + yield* runMigrations(); + + assert.include(yield* projectionThreadColumnNames(sql), "fork_title_base"); + assert.include(yield* projectionThreadColumnNames(sql), "fork_title_ordinal"); + + const projectionRows = yield* sql<{ + readonly id: string; + readonly title: string; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; + }>` + SELECT + thread_id AS id, + title, + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + ORDER BY created_at ASC, thread_id ASC + `; + assert.deepEqual(projectionRows, [ + { + id: "experiment-2", + title: "Experiment", + forkTitleBase: "Experiment", + forkTitleOrdinal: 2, + }, + { + id: "fork-2", + title: "Renamed first fork", + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }, + { + id: "fork-3", + title: "Greeting", + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }, + { + id: "pre-numbered", + title: "Greeting (7)", + forkTitleBase: "Greeting", + forkTitleOrdinal: 7, + }, + { + id: "projection-only", + title: "Greeting", + forkTitleBase: "Greeting", + forkTitleOrdinal: 8, + }, + { id: "root", title: "Greeting", forkTitleBase: null, forkTitleOrdinal: null }, + { + id: "sidechat", + title: "Sidechat: Greeting", + forkTitleBase: null, + forkTitleOrdinal: null, + }, + ]); + + const eventRows = yield* sql<{ + readonly id: string; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; + }>` + SELECT + stream_id AS id, + json_extract(payload_json, '$.forkTitleBase') AS "forkTitleBase", + json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal" + FROM orchestration_events + WHERE event_type = 'thread.created' + ORDER BY stream_id ASC + `; + assert.deepEqual(eventRows, [ + { id: "experiment-2", forkTitleBase: "Experiment", forkTitleOrdinal: 2 }, + { id: "fork-2", forkTitleBase: "Greeting", forkTitleOrdinal: 2 }, + { id: "fork-3", forkTitleBase: "Greeting", forkTitleOrdinal: 3 }, + { id: "pre-numbered", forkTitleBase: "Greeting", forkTitleOrdinal: 7 }, + { id: "root", forkTitleBase: null, forkTitleOrdinal: null }, + { id: "sidechat", forkTitleBase: null, forkTitleOrdinal: null }, + ]); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); +}); diff --git a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts new file mode 100644 index 000000000..651aa9282 --- /dev/null +++ b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts @@ -0,0 +1,179 @@ +// FILE: 057_ProjectionThreadsForkTitleSequence.ts +// Purpose: Persist automatic fork-title series metadata and preserve legacy fork order. +// Layer: Server persistence migration + +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { columnExists } from "./schemaHelpers.ts"; + +interface ThreadCreatedRow { + readonly sequence: number; + readonly threadId: string; + readonly projectId: string | null; + readonly title: string | null; + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; +} + +const familyRootId = ( + row: ThreadCreatedRow, + rowsByThreadId: ReadonlyMap, +): string => { + let current = row; + const visited = new Set(); + + while (current.forkSourceThreadId) { + if (visited.has(current.threadId)) { + return [...visited, current.threadId].toSorted()[0] ?? current.threadId; + } + visited.add(current.threadId); + const source = rowsByThreadId.get(current.forkSourceThreadId); + if (!source) { + return current.forkSourceThreadId; + } + current = source; + } + + return current.threadId; +}; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + if (!(yield* columnExists(sql, "projection_threads", "fork_title_base"))) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN fork_title_base TEXT + `; + } + if (!(yield* columnExists(sql, "projection_threads", "fork_title_ordinal"))) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN fork_title_ordinal INTEGER + `; + } + + const createdRows = yield* sql` + SELECT + sequence, + stream_id AS "threadId", + json_extract(payload_json, '$.projectId') AS "projectId", + json_extract(payload_json, '$.title') AS title, + json_extract(payload_json, '$.forkSourceThreadId') AS "forkSourceThreadId", + json_extract(payload_json, '$.sidechatSourceThreadId') AS "sidechatSourceThreadId", + json_extract(payload_json, '$.forkTitleBase') AS "forkTitleBase", + json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal" + FROM orchestration_events + WHERE event_type = 'thread.created' + AND json_valid(payload_json) + ORDER BY sequence ASC + `; + const rowsByThreadId = new Map(createdRows.map((row) => [row.threadId, row])); + const highestOrdinalBySeries = new Map(); + + for (const row of createdRows) { + if (!row.projectId || !row.title || !row.forkSourceThreadId || row.sidechatSourceThreadId) { + continue; + } + + const rootId = familyRootId(row, rowsByThreadId); + const hasStoredSeries = + row.forkTitleBase !== null && + Number.isSafeInteger(row.forkTitleOrdinal) && + (row.forkTitleOrdinal ?? 0) >= 2; + const forkTitleBase = hasStoredSeries ? row.forkTitleBase! : row.title; + const seriesKey = JSON.stringify([row.projectId, rootId, forkTitleBase]); + const highestOrdinal = highestOrdinalBySeries.get(seriesKey) ?? 1; + const forkTitleOrdinal = hasStoredSeries ? row.forkTitleOrdinal! : highestOrdinal + 1; + highestOrdinalBySeries.set(seriesKey, Math.max(highestOrdinal, forkTitleOrdinal)); + + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.forkTitleBase', + ${forkTitleBase}, + '$.forkTitleOrdinal', + ${forkTitleOrdinal} + ) + WHERE sequence = ${row.sequence} + `; + yield* sql` + UPDATE projection_threads + SET + fork_title_base = ${forkTitleBase}, + fork_title_ordinal = ${forkTitleOrdinal} + WHERE thread_id = ${row.threadId} + `; + } + + // Some older databases deliberately retain projection-only thread history when + // their original event is unavailable. Give those forks stable metadata too so + // a future fork cannot reuse an already-visible ordinal. + const projectionRows = yield* sql` + SELECT + 0 AS sequence, + thread_id AS "threadId", + project_id AS "projectId", + title, + fork_source_thread_id AS "forkSourceThreadId", + sidechat_source_thread_id AS "sidechatSourceThreadId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + ORDER BY created_at ASC, thread_id ASC + `; + const projectionRowsByThreadId = new Map(projectionRows.map((row) => [row.threadId, row])); + const projectionHighestOrdinalBySeries = new Map(); + + for (const row of projectionRows) { + if ( + !row.projectId || + !row.forkSourceThreadId || + row.sidechatSourceThreadId || + row.forkTitleBase === null || + !Number.isSafeInteger(row.forkTitleOrdinal) || + (row.forkTitleOrdinal ?? 0) < 2 + ) { + continue; + } + const seriesKey = JSON.stringify([ + row.projectId, + familyRootId(row, projectionRowsByThreadId), + row.forkTitleBase, + ]); + projectionHighestOrdinalBySeries.set( + seriesKey, + Math.max(projectionHighestOrdinalBySeries.get(seriesKey) ?? 1, row.forkTitleOrdinal!), + ); + } + + for (const row of projectionRows) { + if ( + !row.projectId || + !row.title || + !row.forkSourceThreadId || + row.sidechatSourceThreadId || + (row.forkTitleBase !== null && + Number.isSafeInteger(row.forkTitleOrdinal) && + (row.forkTitleOrdinal ?? 0) >= 2) + ) { + continue; + } + + const rootId = familyRootId(row, projectionRowsByThreadId); + const seriesKey = JSON.stringify([row.projectId, rootId, row.title]); + const forkTitleOrdinal = (projectionHighestOrdinalBySeries.get(seriesKey) ?? 1) + 1; + projectionHighestOrdinalBySeries.set(seriesKey, forkTitleOrdinal); + yield* sql` + UPDATE projection_threads + SET + fork_title_base = ${row.title}, + fork_title_ordinal = ${forkTitleOrdinal} + WHERE thread_id = ${row.threadId} + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index d274e958f..017940f23 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -8,8 +8,10 @@ */ import { IsoDateTime, + MessageId, ModelSelection, NonNegativeInt, + PositiveInt, OrchestrationThreadPullRequest, ThreadNotes, ThreadPinnedMessages, @@ -47,6 +49,9 @@ export const ProjectionThread = Schema.Struct({ subagentNickname: Schema.optional(Schema.NullOr(Schema.String)), subagentRole: Schema.optional(Schema.NullOr(Schema.String)), forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)), + forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)), + forkTitleBase: Schema.optional(Schema.NullOr(Schema.String)), + forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)), sidechatSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)), lastKnownPr: Schema.NullOr(OrchestrationThreadPullRequest), latestTurnId: Schema.NullOr(TurnId), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 10f598ac2..d53674e8e 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1002,6 +1002,146 @@ function recordProjectCreateCommand(command: unknown): boolean { return true; } +function recordThreadForkCreateCommand(command: unknown): boolean { + if ( + !command || + typeof command !== "object" || + !("type" in command) || + command.type !== "thread.fork.create" || + !("threadId" in command) || + !("sourceThreadId" in command) || + !("importedMessages" in command) || + !Array.isArray(command.importedMessages) + ) { + return false; + } + + const sourceThread = fixture.snapshot.threads.find( + (thread) => thread.id === command.sourceThreadId, + ); + if (!sourceThread) { + return false; + } + + const createdAt = + "createdAt" in command && typeof command.createdAt === "string" ? command.createdAt : NOW_ISO; + const importedMessages = command.importedMessages.map((message) => { + const imported = message as { + messageId: MessageId; + role: "user" | "assistant"; + text: string; + attachments?: OrchestrationReadModel["threads"][number]["messages"][number]["attachments"]; + createdAt: string; + updatedAt: string; + }; + return { + id: imported.messageId, + role: imported.role, + text: imported.text, + ...(imported.attachments ? { attachments: imported.attachments } : {}), + turnId: null, + streaming: false, + source: "fork-import" as const, + createdAt: imported.createdAt, + updatedAt: imported.updatedAt, + }; + }); + const forkedThread: OrchestrationReadModel["threads"][number] = { + ...sourceThread, + id: command.threadId as ThreadId, + title: `${sourceThread.title} (2)`, + modelSelection: + "modelSelection" in command && + command.modelSelection && + typeof command.modelSelection === "object" + ? (command.modelSelection as typeof sourceThread.modelSelection) + : sourceThread.modelSelection, + runtimeMode: + "runtimeMode" in command && + (command.runtimeMode === "approval-required" || command.runtimeMode === "full-access") + ? command.runtimeMode + : sourceThread.runtimeMode, + interactionMode: + "interactionMode" in command && + (command.interactionMode === "default" || command.interactionMode === "plan") + ? command.interactionMode + : sourceThread.interactionMode, + envMode: + "envMode" in command && (command.envMode === "local" || command.envMode === "worktree") + ? command.envMode + : sourceThread.envMode, + branch: "branch" in command && typeof command.branch === "string" ? command.branch : null, + worktreePath: + "worktreePath" in command && typeof command.worktreePath === "string" + ? command.worktreePath + : null, + associatedWorktreePath: + "associatedWorktreePath" in command && typeof command.associatedWorktreePath === "string" + ? command.associatedWorktreePath + : null, + associatedWorktreeBranch: + "associatedWorktreeBranch" in command && typeof command.associatedWorktreeBranch === "string" + ? command.associatedWorktreeBranch + : null, + associatedWorktreeRef: + "associatedWorktreeRef" in command && typeof command.associatedWorktreeRef === "string" + ? command.associatedWorktreeRef + : null, + createBranchFlowCompleted: false, + isPinned: false, + parentThreadId: null, + subagentAgentId: null, + subagentNickname: null, + subagentRole: null, + forkSourceThreadId: sourceThread.id, + forkSourceMessageId: + "sourceMessageId" in command && typeof command.sourceMessageId === "string" + ? MessageId.makeUnsafe(command.sourceMessageId) + : null, + forkTitleBase: sourceThread.title, + forkTitleOrdinal: 2, + sidechatSourceThreadId: null, + lastKnownPr: null, + latestTurn: null, + createdAt, + updatedAt: createdAt, + archivedAt: null, + deletedAt: null, + handoff: null, + messages: importedMessages, + activities: [], + proposedPlans: [], + checkpoints: [], + session: null, + }; + + fixture = { + ...fixture, + snapshot: { + ...fixture.snapshot, + snapshotSequence: fixture.snapshot.snapshotSequence + 1, + threads: [ + ...fixture.snapshot.threads.filter((thread) => thread.id !== forkedThread.id), + forkedThread, + ], + updatedAt: createdAt, + }, + }; + return true; +} + +function findRecordedThreadForkCreateCommand(): Record | null { + const request = wsRequests.find( + (entry) => + entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof entry.command === "object" && + entry.command !== null && + "type" in entry.command && + entry.command.type === "thread.fork.create", + ); + return (request?.command as Record | undefined) ?? null; +} + function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { const tag = body._tag; if (tag === ORCHESTRATION_WS_METHODS.getShellSnapshot) { @@ -1014,6 +1154,9 @@ function resolveWsRpc(body: WsRequestEnvelope["body"]): unknown { if (recordProjectCreateCommand(body.command)) { return { sequence: fixture.snapshot.snapshotSequence }; } + if (recordThreadForkCreateCommand(body.command)) { + return { sequence: fixture.snapshot.snapshotSequence }; + } return { sequence: fixture.snapshot.snapshotSequence + 1 }; } if (tag === WS_METHODS.automationCreate) { @@ -1162,7 +1305,12 @@ function installDeterministicActionNativeApi(): () => void { _tag: ORCHESTRATION_WS_METHODS.dispatchCommand, command, }); - return { sequence: fixture.snapshot.snapshotSequence + 1 }; + const recordedFork = recordThreadForkCreateCommand(command); + return { + sequence: recordedFork + ? fixture.snapshot.snapshotSequence + : fixture.snapshot.snapshotSequence + 1, + }; }, }, automation: { @@ -1788,6 +1936,227 @@ describe("ChatView timeline estimator parity (full app)", () => { document.body.innerHTML = ""; }); + it("dispatches a bounded fork command from the message action and navigates to the new task", async () => { + const sourceMessageId = MessageId.makeUnsafe("msg-user-message-fork-source"); + const sourceSnapshot = createSnapshotForTargetUser({ + targetMessageId: sourceMessageId, + targetText: "Fork exactly here", + }); + const sourceThread = sourceSnapshot.threads[0]!; + const sourceMessageIndex = sourceThread.messages.findIndex( + (message) => message.id === sourceMessageId, + ); + const snapshot: OrchestrationReadModel = { + ...sourceSnapshot, + threads: [ + { + ...sourceThread, + messages: sourceThread.messages.slice(sourceMessageIndex, sourceMessageIndex + 2), + }, + ], + }; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot, + }); + + try { + const sourceRow = await waitForElement( + () => + document.querySelector( + `[data-message-id="${sourceMessageId}"][data-message-role="user"]`, + ), + "Unable to find source message for the fork action.", + ); + await userEvent.hover(sourceRow); + const forkButton = sourceRow.querySelector( + 'button[aria-label="Fork conversation from this message"]', + ); + expect(forkButton).not.toBeNull(); + forkButton?.click(); + forkButton?.click(); + + await vi.waitFor( + () => { + expect(findRecordedThreadForkCreateCommand()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + const forkCommand = findRecordedThreadForkCreateCommand(); + if (!forkCommand) { + throw new Error("Expected a recorded thread fork command."); + } + + expect(forkCommand).toMatchObject({ + type: "thread.fork.create", + sourceThreadId: THREAD_ID, + sourceMessageId, + projectId: PROJECT_ID, + envMode: "local", + branch: "main", + }); + expect(forkCommand?.importedMessages).toEqual([ + expect.objectContaining({ + role: "user", + text: "Fork exactly here", + }), + ]); + expect( + wsRequests.filter( + (entry) => + entry._tag === ORCHESTRATION_WS_METHODS.dispatchCommand && + typeof entry.command === "object" && + entry.command !== null && + "type" in entry.command && + entry.command.type === "thread.fork.create", + ), + ).toHaveLength(1); + + const forkThreadId = forkCommand?.threadId; + expect(typeof forkThreadId).toBe("string"); + await vi.waitFor( + () => { + expect(mounted.router.state.location.pathname).toBe(`/${forkThreadId}`); + expect( + useStore.getState().threads.find((thread) => thread.id === forkThreadId)?.title, + ).toBe(`${THREAD_TITLE} (2)`); + }, + { timeout: 8_000, interval: 16 }, + ); + } finally { + await mounted.cleanup(); + } + }); + + it("forks from an assistant row that first rendered while streaming", async () => { + const targetUserMessageId = MessageId.makeUnsafe("msg-user-message-fork-settling-source"); + const sourceSnapshot = createSnapshotForTargetUser({ + targetMessageId: targetUserMessageId, + targetText: "Wait for the answer", + }); + const sourceThread = sourceSnapshot.threads[0]!; + const sourceMessageIndex = sourceThread.messages.findIndex( + (message) => message.id === targetUserMessageId, + ); + const settledMessages = sourceThread.messages.slice(sourceMessageIndex, sourceMessageIndex + 2); + const assistantMessage = settledMessages[1]!; + const activeTurnId = TurnId.makeUnsafe("turn-message-fork-settling"); + const streamingSnapshot: OrchestrationReadModel = { + ...sourceSnapshot, + threads: [ + { + ...sourceThread, + latestTurn: { + turnId: activeTurnId, + state: "running", + requestedAt: isoAt(1_100), + startedAt: isoAt(1_101), + completedAt: null, + assistantMessageId: assistantMessage.id, + }, + messages: [ + settledMessages[0]!, + { + ...assistantMessage, + turnId: activeTurnId, + streaming: true, + }, + ], + session: sourceThread.session + ? { + ...sourceThread.session, + status: "running", + activeTurnId, + } + : null, + }, + ], + }; + const mounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: streamingSnapshot, + }); + + try { + const assistantRowSelector = `[data-message-id="${assistantMessage.id}"][data-message-role="assistant"]`; + const assistantRow = await waitForElement( + () => document.querySelector(assistantRowSelector), + "Unable to find the streaming assistant message for the fork action.", + ); + expect( + assistantRow.querySelector('button[aria-label="Fork conversation from this message"]'), + ).toBeNull(); + + const settledSnapshot: OrchestrationReadModel = { + ...streamingSnapshot, + snapshotSequence: streamingSnapshot.snapshotSequence + 1, + threads: [ + { + ...streamingSnapshot.threads[0]!, + latestTurn: { + ...streamingSnapshot.threads[0]!.latestTurn!, + state: "completed", + completedAt: isoAt(1_108), + }, + messages: [ + settledMessages[0]!, + { + ...assistantMessage, + turnId: activeTurnId, + // The provider lifecycle is settled, but a delayed transport snapshot can + // briefly leave the raw message flag true. The terminal footer intentionally + // treats lifecycle state as authoritative in this case. + streaming: true, + }, + ], + session: sourceThread.session + ? { + ...sourceThread.session, + status: "ready", + activeTurnId: null, + } + : null, + }, + ], + updatedAt: isoAt(1_109), + }; + fixture = { ...fixture, snapshot: settledSnapshot }; + useStore.getState().syncServerReadModel(settledSnapshot); + + const forkButton = await waitForElement( + () => + document.querySelector( + `${assistantRowSelector} button[aria-label="Fork conversation from this message"]`, + ), + "Unable to find the fork action after the assistant message settled.", + ); + forkButton.click(); + + await vi.waitFor( + () => { + expect(findRecordedThreadForkCreateCommand()).not.toBeNull(); + }, + { timeout: 8_000, interval: 16 }, + ); + const forkCommand = findRecordedThreadForkCreateCommand(); + if (!forkCommand) { + throw new Error("Expected a recorded thread fork command."); + } + + expect(forkCommand).toMatchObject({ + type: "thread.fork.create", + sourceThreadId: THREAD_ID, + sourceMessageId: assistantMessage.id, + }); + expect(forkCommand?.importedMessages).toEqual([ + expect.objectContaining({ role: "user", text: "Wait for the answer" }), + expect.objectContaining({ role: "assistant", text: assistantMessage.text }), + ]); + } finally { + await mounted.cleanup(); + } + }); + it.each(TEXT_VIEWPORT_MATRIX)( "[geometry:linux] keeps long user message estimate close at the $name viewport", async (viewport) => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f7dcbb705..c7f793ddd 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -9475,6 +9475,7 @@ export default function ChatView({ ); const { + handleForkFromMessage, handleForkTargetSelection, handleReviewTargetSelection, isSlashStatusDialogOpen, @@ -11161,6 +11162,7 @@ export default function ChatView({ onRevertUserMessage={onRevertUserMessage} onUndoTurnFiles={onUndoTurnFiles} onEditUserMessage={onEditUserMessage} + {...(isServerThread ? { onForkFromMessage: handleForkFromMessage } : {})} isRevertingCheckpoint={isRevertingCheckpoint} onExpandTimelineImage={onExpandTimelineImage} followLiveOutput={hasStreamingAssistantText} diff --git a/apps/web/src/components/chat/ChatTranscriptPane.tsx b/apps/web/src/components/chat/ChatTranscriptPane.tsx index c2a873315..e8e6b11c0 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.tsx @@ -77,6 +77,7 @@ interface ChatTranscriptPaneProps { onRevertUserMessage: (messageId: MessageId) => void; onUndoTurnFiles?: ComponentProps["onUndoTurnFiles"]; onEditUserMessage?: (messageId: MessageId, text: string) => boolean | Promise; + onForkFromMessage?: (messageId: MessageId) => void; onScrollToBottom: () => void; onToggleWorkGroup?: (groupId: string) => void; resolvedTheme: "light" | "dark"; @@ -134,6 +135,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onRevertUserMessage, onUndoTurnFiles, onEditUserMessage, + onForkFromMessage, onScrollToBottom, onToggleWorkGroup, resolvedTheme, @@ -218,6 +220,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onRevertUserMessage={onRevertUserMessage} {...(onUndoTurnFiles ? { onUndoTurnFiles } : {})} {...(onEditUserMessage ? { onEditUserMessage } : {})} + {...(onForkFromMessage ? { onForkFromMessage } : {})} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} followLiveOutput={followLiveOutput} diff --git a/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx new file mode 100644 index 000000000..d27c17254 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx @@ -0,0 +1,106 @@ +// FILE: MessagesTimeline.messageFork.browser.tsx +// Purpose: Browser regression for accessible message-fork actions and exact boundary dispatch. +// Layer: Vitest browser tests + +import "../../index.css"; + +import { MessageId } from "@synara/contracts"; +import { useState } from "react"; +import { afterEach, describe, expect, it } from "vitest"; +import { render } from "vitest-browser-react"; + +import { MessagesTimeline } from "./MessagesTimeline"; + +function MessageForkTimeline() { + const [selectedMessageId, setSelectedMessageId] = useState(null); + + return ( +
+ {selectedMessageId} + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + onForkFromMessage={setSelectedMessageId} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + /> +
+ ); +} + +describe("MessagesTimeline message fork action", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("places Fork after Copy and dispatches the exact clicked message id", async () => { + const screen = await render(); + + try { + const forkButtons = Array.from( + document.querySelectorAll( + 'button[aria-label="Fork conversation from this message"]', + ), + ); + expect(forkButtons).toHaveLength(2); + + for (const forkButton of forkButtons) { + const actionButtons = Array.from( + forkButton.parentElement?.querySelectorAll("button") ?? [], + ); + const copyIndex = actionButtons.findIndex( + (button) => button.getAttribute("aria-label") === "Copy message", + ); + const forkIndex = actionButtons.indexOf(forkButton); + expect(copyIndex).toBeGreaterThanOrEqual(0); + expect(forkIndex).toBe(copyIndex + 1); + } + + forkButtons[1]?.click(); + await expect + .poll(() => document.querySelector("output")?.getAttribute("data-selected-message-id")) + .toBe("message-fork-browser-assistant"); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 7f814070c..c83dce357 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -360,6 +360,66 @@ describe("MessagesTimeline", () => { expect(markup).toContain("size-[1.125em]"); }); + it("renders message fork actions immediately after copy for user and settled assistant messages", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + onForkFromMessage={() => {}} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + const copyLabels = [...markup.matchAll(/aria-label="Copy message"/g)]; + const forkLabels = [...markup.matchAll(/aria-label="Fork conversation from this message"/g)]; + expect(copyLabels).toHaveLength(2); + expect(forkLabels).toHaveLength(2); + expect(copyLabels[0]?.index).toBeLessThan(forkLabels[0]?.index ?? 0); + expect(copyLabels[1]?.index).toBeLessThan(forkLabels[1]?.index ?? 0); + expect(markup).toContain("/central-icons-reversed/fork-simple.svg"); + }); + it("keeps edit available and hides undo before a revert checkpoint exists", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 5c663faba..b24fc7e70 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -51,6 +51,7 @@ import { CircleCheckIcon, CircleQuestionIcon, ClockIcon, + ConversationForkIcon, EyeIcon, GitHubIcon, HammerIcon, @@ -415,6 +416,8 @@ interface MessagesTimelineProps { onRevertUserMessage: (messageId: MessageId) => void; onUndoTurnFiles?: (turnCounts: readonly number[]) => void; onEditUserMessage?: (messageId: MessageId, text: string) => boolean | Promise; + /** Create an independent conversation whose imported transcript ends at this message. */ + onForkFromMessage?: (messageId: MessageId) => void; activeTurnId?: TurnId | null; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; @@ -472,6 +475,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onUndoTurnFiles, onEditUserMessage, + onForkFromMessage, activeTurnId, isRevertingCheckpoint, onImageExpand, @@ -1194,6 +1198,16 @@ export const MessagesTimeline = memo(function MessagesTimeline({ className={MESSAGE_HOVER_REVEAL_CLASS_NAME} /> )} + {onForkFromMessage ? ( + onForkFromMessage(row.message.id)} + > + + + ) : null} {showEditUserMessage && ( ) : null} + {assistantCopyState.visible && onForkFromMessage ? ( + onForkFromMessage(row.message.id)} + > + + + ) : null} {assistantMeta.length > 0 ? (

{assistantMeta} diff --git a/apps/web/src/hooks/useComposerSlashCommands.ts b/apps/web/src/hooks/useComposerSlashCommands.ts index a4c1734de..84bf8ab76 100644 --- a/apps/web/src/hooks/useComposerSlashCommands.ts +++ b/apps/web/src/hooks/useComposerSlashCommands.ts @@ -1,5 +1,6 @@ import { type ModelSelection, + type MessageId, type OrchestrationShellSnapshot, type ProviderInteractionMode, type ProviderKind, @@ -10,7 +11,7 @@ import { } from "@synara/contracts"; import { buildPromptThreadTitleFallback } from "@synara/shared/chatThreads"; import { deriveAssociatedWorktreeMetadata } from "@synara/shared/threadWorkspace"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { newCommandId, newMessageId, newThreadId } from "../lib/utils"; import { readNativeApi } from "../nativeApi"; import type { Project, Thread } from "../types"; @@ -26,7 +27,10 @@ import { parseForkSlashCommandArgs, type ForkSlashCommandTarget, } from "../composerSlashCommands"; -import { buildThreadHandoffImportedMessages } from "../lib/threadHandoff"; +import { + buildThreadForkImportedMessagesThrough, + buildThreadHandoffImportedMessages, +} from "../lib/threadHandoff"; import { toastManager } from "../components/ui/toast"; import type { ComposerCommandItem } from "../components/chat/ComposerCommandMenu"; import { buildNextProviderOptions } from "../providerModelOptions"; @@ -100,6 +104,7 @@ export function useComposerSlashCommands(input: { }; }) { const [isSlashStatusDialogOpen, setIsSlashStatusDialogOpen] = useState(false); + const forkCreationInFlightRef = useRef(false); const openGlobalFeedbackDialog = useFeedbackDialogStore((state) => state.openDialog); const { activeProject, @@ -243,7 +248,7 @@ export function useComposerSlashCommands(input: { ); const createForkThreadFromSlashCommand = useCallback( - async (inputOptions?: { target?: ForkSlashCommandTarget }) => { + async (inputOptions?: { target?: ForkSlashCommandTarget; sourceMessageId?: MessageId }) => { const api = readNativeApi(); if (!api || !activeProject || !activeThread || !isServerThread) { toastManager.add({ @@ -254,7 +259,17 @@ export function useComposerSlashCommands(input: { return true; } - const importedMessages = buildThreadHandoffImportedMessages(activeThread); + const importedMessages = inputOptions?.sourceMessageId + ? buildThreadForkImportedMessagesThrough(activeThread, inputOptions.sourceMessageId) + : buildThreadHandoffImportedMessages(activeThread); + if (inputOptions?.sourceMessageId && importedMessages.length === 0) { + toastManager.add({ + type: "warning", + title: "Fork is unavailable", + description: "That message is not available as a completed conversation boundary.", + }); + return true; + } const nextThreadId = newThreadId(); const createdAt = new Date().toISOString(); @@ -270,6 +285,7 @@ export function useComposerSlashCommands(input: { commandId: newCommandId(), threadId: nextThreadId, sourceThreadId: activeThread.id, + ...(inputOptions?.sourceMessageId ? { sourceMessageId: inputOptions.sourceMessageId } : {}), projectId: activeProject.id, title: activeThread.title, modelSelection: selectedModelSelection, @@ -301,7 +317,6 @@ export function useComposerSlashCommands(input: { syncServerShellSnapshot, ], ); - const createSidechatFromSlashCommand = useCallback( async (inputOptions?: { initialPrompt?: string }) => { const api = readNativeApi(); @@ -511,6 +526,8 @@ export function useComposerSlashCommands(input: { const handleForkTargetSelection = useCallback( async (target: ForkSlashCommandTarget) => { + if (forkCreationInFlightRef.current) return; + forkCreationInFlightRef.current = true; try { await createForkThreadFromSlashCommand({ target }); } catch (error) { @@ -522,6 +539,33 @@ export function useComposerSlashCommands(input: { ? error.message : "An error occurred while creating the forked thread.", }); + } finally { + forkCreationInFlightRef.current = false; + } + }, + [createForkThreadFromSlashCommand], + ); + + const handleForkFromMessage = useCallback( + async (sourceMessageId: MessageId) => { + if (forkCreationInFlightRef.current) return; + forkCreationInFlightRef.current = true; + try { + await createForkThreadFromSlashCommand({ + target: "local", + sourceMessageId, + }); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not fork from message", + description: + error instanceof Error + ? error.message + : "An error occurred while creating the message fork.", + }); + } finally { + forkCreationInFlightRef.current = false; } }, [createForkThreadFromSlashCommand], @@ -1003,6 +1047,7 @@ export function useComposerSlashCommands(input: { ); return { + handleForkFromMessage, handleForkTargetSelection, handleReviewTargetSelection, isSlashStatusDialogOpen, diff --git a/apps/web/src/lib/assistantSelections.ts b/apps/web/src/lib/assistantSelections.ts index f78d13267..acf51e93b 100644 --- a/apps/web/src/lib/assistantSelections.ts +++ b/apps/web/src/lib/assistantSelections.ts @@ -3,14 +3,15 @@ // Layer: Chat composer and transcript helpers import { CHAT_ASSISTANT_SELECTION_TEXT_MAX_CHARS } from "@synara/contracts"; +import { stripEmbeddedAssistantSelections } from "@synara/shared/assistantSelections"; + +export { stripEmbeddedAssistantSelections } from "@synara/shared/assistantSelections"; import type { ChatAssistantSelectionAttachment } from "../types"; import { randomUUID } from "./utils"; const TRAILING_ASSISTANT_SELECTIONS_PATTERN = /\n*\n([\s\S]*?)\n<\/assistant_selection>\s*$/; -const EMBEDDED_ASSISTANT_SELECTIONS_PATTERN = - /\n*\n[\s\S]*?\n<\/assistant_selection>(?=\n*(\n[\s\S]*?\n<\/terminal_context>\s*)?(\n[\s\S]*?\n<\/file_comments>\s*)?(\n[\s\S]*?\n<\/pasted_text>\s*)?$)/; const ASSISTANT_SELECTION_PREVIEW_MAX_CHARS = 44; export interface ExtractedAssistantSelections { @@ -155,10 +156,6 @@ export function stripTrailingAssistantSelections(prompt: string): string { return extractTrailingAssistantSelections(prompt).promptText; } -export function stripEmbeddedAssistantSelections(prompt: string): string { - return prompt.replace(EMBEDDED_ASSISTANT_SELECTIONS_PATTERN, ""); -} - function parseAssistantSelectionEntries(block: string): ParsedAssistantSelectionEntry[] { const entries: ParsedAssistantSelectionEntry[] = []; let current: { assistantMessageId: string; lines: string[] } | null = null; diff --git a/apps/web/src/lib/icons.tsx b/apps/web/src/lib/icons.tsx index 8746e43a0..e26bcb58e 100644 --- a/apps/web/src/lib/icons.tsx +++ b/apps/web/src/lib/icons.tsx @@ -189,6 +189,7 @@ export const FoldersIcon: LucideIcon = centralIconWrapper("folders"); export const GitCommitIcon: LucideIcon = centralIconWrapper("commits"); export const GitBranchIcon: LucideIcon = centralIconWrapper("branch"); export const GitForkIcon = centralIconWrapper("fork"); +export const ConversationForkIcon: LucideIcon = centralIconWrapper("fork-simple"); export const GitMergeIcon: LucideIcon = centralIconWrapper("merged"); export const GitMergedSimpleIcon: LucideIcon = centralIconWrapper("merged-simple"); export const PushIcon: LucideIcon = centralIconWrapper("cloud-simple-upload"); diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index 0475b9dc5..a84465d3b 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -1,12 +1,171 @@ -import { type ModelSelection } from "@synara/contracts"; +import { MessageId, type ModelSelection, TurnId } from "@synara/contracts"; import { describe, expect, it } from "vitest"; import { + buildThreadForkImportedMessagesThrough, resolveAvailableHandoffTargetProviders, resolveThreadHandoffTitle, resolveThreadHandoffModelSelection, } from "./threadHandoff"; describe("threadHandoff", () => { + it("imports only completed conversation messages through the selected fork boundary", () => { + const firstUserId = MessageId.makeUnsafe("message-user-1"); + const firstAssistantId = MessageId.makeUnsafe("message-assistant-1"); + const secondUserId = MessageId.makeUnsafe("message-user-2"); + + const imported = buildThreadForkImportedMessagesThrough( + { + messages: [ + { + id: firstUserId, + role: "user", + text: "First question", + createdAt: "2026-07-22T08:00:00.000Z", + streaming: false, + }, + { + id: MessageId.makeUnsafe("message-system-1"), + role: "system", + text: "Internal status", + createdAt: "2026-07-22T08:00:01.000Z", + streaming: false, + }, + { + id: firstAssistantId, + role: "assistant", + text: "First answer", + createdAt: "2026-07-22T08:00:02.000Z", + completedAt: "2026-07-22T08:00:03.000Z", + streaming: false, + }, + { + id: secondUserId, + role: "user", + text: "Later question", + createdAt: "2026-07-22T08:01:00.000Z", + streaming: false, + }, + ], + }, + firstAssistantId, + ); + + expect(imported).toHaveLength(2); + expect(imported.map(({ role, text }) => ({ role, text }))).toEqual([ + { role: "user", text: "First question" }, + { role: "assistant", text: "First answer" }, + ]); + expect(imported.every((message) => message.messageId !== firstUserId)).toBe(true); + expect(imported.every((message) => message.messageId !== firstAssistantId)).toBe(true); + }); + + it("rejects missing or streaming fork boundaries", () => { + const streamingId = MessageId.makeUnsafe("message-streaming"); + const thread = { + messages: [ + { + id: streamingId, + role: "assistant" as const, + text: "Still running", + createdAt: "2026-07-22T08:00:00.000Z", + streaming: true, + }, + ], + }; + + expect(buildThreadForkImportedMessagesThrough(thread, streamingId)).toEqual([]); + expect( + buildThreadForkImportedMessagesThrough(thread, MessageId.makeUnsafe("message-missing")), + ).toEqual([]); + }); + + it("imports the settled latest assistant when its raw streaming flag is stale", () => { + const assistantId = MessageId.makeUnsafe("message-stale-streaming-assistant"); + const turnId = TurnId.makeUnsafe("turn-stale-streaming-assistant"); + + const imported = buildThreadForkImportedMessagesThrough( + { + messages: [ + { + id: MessageId.makeUnsafe("message-stale-streaming-user"), + role: "user", + text: "Question", + createdAt: "2026-07-22T08:00:00.000Z", + streaming: false, + }, + { + id: assistantId, + role: "assistant", + text: "Completed answer", + turnId, + createdAt: "2026-07-22T08:00:01.000Z", + completedAt: "2026-07-22T08:00:02.000Z", + streaming: true, + }, + ], + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-07-22T08:00:00.000Z", + startedAt: "2026-07-22T08:00:00.500Z", + completedAt: "2026-07-22T08:00:02.000Z", + assistantMessageId: assistantId, + }, + session: null, + }, + assistantId, + ); + + expect(imported.map(({ role, text }) => ({ role, text }))).toEqual([ + { role: "user", text: "Question" }, + { role: "assistant", text: "Completed answer" }, + ]); + }); + + it("imports every settled stale-streaming assistant row from the terminal turn", () => { + const firstAssistantId = MessageId.makeUnsafe("message-stale-streaming-assistant-first"); + const latestAssistantId = MessageId.makeUnsafe("message-stale-streaming-assistant-latest"); + const turnId = TurnId.makeUnsafe("turn-stale-streaming-assistants"); + + const imported = buildThreadForkImportedMessagesThrough( + { + messages: [ + { + id: firstAssistantId, + role: "assistant", + text: "First completed row", + turnId, + createdAt: "2026-07-22T08:00:01.000Z", + streaming: true, + }, + { + id: latestAssistantId, + role: "assistant", + text: "Latest completed row", + turnId, + createdAt: "2026-07-22T08:00:02.000Z", + streaming: true, + }, + ], + latestTurn: { + turnId, + state: "completed", + requestedAt: "2026-07-22T08:00:00.000Z", + startedAt: "2026-07-22T08:00:00.500Z", + completedAt: "2026-07-22T08:00:03.000Z", + assistantMessageId: latestAssistantId, + }, + session: null, + }, + latestAssistantId, + ); + + expect(imported.map(({ text }) => text)).toEqual([ + "First completed row", + "Latest completed row", + ]); + }); + it("lists all supported handoff targets except the active provider", () => { const providers = [ "codex", diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 1235d4894..0c12bcddc 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -13,6 +13,7 @@ import { type ThreadHandoffImportedMessage, } from "@synara/contracts"; import { getDefaultModel } from "@synara/shared/model"; +import { isLatestTurnSettled } from "../session-logic"; import { type Thread } from "../types"; import { stripEmbeddedAssistantSelections } from "./assistantSelections"; import { randomUUID } from "./utils"; @@ -43,6 +44,21 @@ function isImportableThreadMessage( return (message.role === "user" || message.role === "assistant") && message.streaming === false; } +type ForkSourceThread = Pick & Partial>; + +function isSettledTerminalTurnAssistantMessage( + thread: ForkSourceThread, + message: Thread["messages"][number], +): message is Thread["messages"][number] & { role: "assistant" } { + return ( + message.role === "assistant" && + message.streaming === true && + message.turnId != null && + thread.latestTurn?.turnId === message.turnId && + isLatestTurnSettled(thread.latestTurn, thread.session ?? null) + ); +} + function isImportableThreadActivity( activity: Thread["activities"][number], ): activity is OrchestrationThreadActivity { @@ -71,37 +87,64 @@ export function resolveThreadHandoffTitle(thread: Pick): string export function buildThreadHandoffImportedMessages( thread: Pick, ): ReadonlyArray { - return thread.messages.filter(isImportableThreadMessage).map((message) => { - const importedText = - message.role === "user" ? stripEmbeddedAssistantSelections(message.text) : message.text; - const importedMessage: ThreadHandoffImportedMessage = { - messageId: MessageId.makeUnsafe(randomUUID()), - role: message.role, - text: importedText, - createdAt: message.createdAt, - updatedAt: message.completedAt ?? message.createdAt, - }; - const attachments = - message.attachments && message.attachments.length > 0 - ? message.attachments.map((attachment) => - attachment.type === "assistant-selection" - ? { - type: attachment.type, - id: attachment.id, - assistantMessageId: attachment.assistantMessageId, - text: attachment.text, - } - : { - type: attachment.type, - id: attachment.id, - name: attachment.name, - mimeType: attachment.mimeType, - sizeBytes: attachment.sizeBytes, - }, - ) - : null; - return attachments ? Object.assign(importedMessage, { attachments }) : importedMessage; - }); + return buildImportedMessages(thread.messages); +} + +function buildImportedMessages( + messages: ReadonlyArray, + isImportable: (message: Thread["messages"][number]) => boolean = isImportableThreadMessage, +): ReadonlyArray { + return messages + .filter( + (message): message is Thread["messages"][number] & { role: "user" | "assistant" } => + isImportable(message) && (message.role === "user" || message.role === "assistant"), + ) + .map((message) => { + const importedText = + message.role === "user" ? stripEmbeddedAssistantSelections(message.text) : message.text; + const importedMessage: ThreadHandoffImportedMessage = { + messageId: MessageId.makeUnsafe(randomUUID()), + role: message.role, + text: importedText, + createdAt: message.createdAt, + updatedAt: message.completedAt ?? message.createdAt, + }; + const attachments = + message.attachments && message.attachments.length > 0 + ? message.attachments.map((attachment) => + attachment.type === "assistant-selection" + ? { + type: attachment.type, + id: attachment.id, + assistantMessageId: attachment.assistantMessageId, + text: attachment.text, + } + : { + type: attachment.type, + id: attachment.id, + name: attachment.name, + mimeType: attachment.mimeType, + sizeBytes: attachment.sizeBytes, + }, + ) + : null; + return attachments ? Object.assign(importedMessage, { attachments }) : importedMessage; + }); +} + +export function buildThreadForkImportedMessagesThrough( + thread: ForkSourceThread, + sourceMessageId: MessageId, +): ReadonlyArray { + const sourceMessageIndex = thread.messages.findIndex((message) => message.id === sourceMessageId); + const sourceMessage = thread.messages[sourceMessageIndex]; + const isForkImportable = (message: Thread["messages"][number]) => + isImportableThreadMessage(message) || isSettledTerminalTurnAssistantMessage(thread, message); + if (sourceMessageIndex < 0 || !sourceMessage || !isForkImportable(sourceMessage)) { + return []; + } + + return buildImportedMessages(thread.messages.slice(0, sourceMessageIndex + 1), isForkImportable); } export function buildThreadHandoffImportedActivities( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 556ca9278..ba3b767ee 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -387,6 +387,9 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b (left.subagentNickname ?? null) === (right.subagentNickname ?? null) && (left.subagentRole ?? null) === (right.subagentRole ?? null) && (left.forkSourceThreadId ?? null) === (right.forkSourceThreadId ?? null) && + (left.forkSourceMessageId ?? null) === (right.forkSourceMessageId ?? null) && + (left.forkTitleBase ?? null) === (right.forkTitleBase ?? null) && + (left.forkTitleOrdinal ?? null) === (right.forkTitleOrdinal ?? null) && (left.sidechatSourceThreadId ?? null) === (right.sidechatSourceThreadId ?? null) && deepEqualJson(left.lastKnownPr ?? null, right.lastKnownPr ?? null) && (left.handoff ?? null) === (right.handoff ?? null) && @@ -435,6 +438,9 @@ function toThreadShell(thread: Thread): ThreadShell { subagentNickname: thread.subagentNickname ?? null, subagentRole: thread.subagentRole ?? null, forkSourceThreadId: thread.forkSourceThreadId ?? null, + forkSourceMessageId: thread.forkSourceMessageId ?? null, + forkTitleBase: thread.forkTitleBase ?? null, + forkTitleOrdinal: thread.forkTitleOrdinal ?? null, sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null, lastKnownPr: thread.lastKnownPr ?? null, handoff: thread.handoff ?? null, @@ -1748,6 +1754,9 @@ function normalizeThreadFromReadModel( previous.hasPendingUserInput === resolvedHasPendingUserInput && previous.hasActionableProposedPlan === resolvedHasActionableProposedPlan && (previous.forkSourceThreadId ?? null) === (incoming.forkSourceThreadId ?? null) && + (previous.forkSourceMessageId ?? null) === (incoming.forkSourceMessageId ?? null) && + (previous.forkTitleBase ?? null) === (incoming.forkTitleBase ?? null) && + (previous.forkTitleOrdinal ?? null) === (incoming.forkTitleOrdinal ?? null) && (previous.sidechatSourceThreadId ?? null) === (incoming.sidechatSourceThreadId ?? null) && deepEqualJson(previous.lastKnownPr ?? null, lastKnownPr) && (previous.handoff ?? null) === handoff && @@ -1791,6 +1800,9 @@ function normalizeThreadFromReadModel( associatedWorktreeRef: nextAssociatedWorktreeRef, createBranchFlowCompleted: resolvedCreateBranchFlowCompleted, forkSourceThreadId: incoming.forkSourceThreadId ?? null, + forkSourceMessageId: incoming.forkSourceMessageId ?? null, + forkTitleBase: incoming.forkTitleBase ?? null, + forkTitleOrdinal: incoming.forkTitleOrdinal ?? null, sidechatSourceThreadId: incoming.sidechatSourceThreadId ?? null, lastKnownPr, handoff, @@ -1884,6 +1896,9 @@ function normalizeThreadShellSnapshot( subagentNickname: incoming.subagentNickname ?? null, subagentRole: incoming.subagentRole ?? null, forkSourceThreadId: incoming.forkSourceThreadId ?? null, + forkSourceMessageId: incoming.forkSourceMessageId ?? null, + forkTitleBase: incoming.forkTitleBase ?? null, + forkTitleOrdinal: incoming.forkTitleOrdinal ?? null, sidechatSourceThreadId: incoming.sidechatSourceThreadId ?? null, lastKnownPr, handoff, @@ -2188,6 +2203,7 @@ function sidebarThreadSummariesEqual( left.hasActionableProposedPlan === right.hasActionableProposedPlan && left.hasLiveTailWork === right.hasLiveTailWork && (left.forkSourceThreadId ?? null) === (right.forkSourceThreadId ?? null) && + (left.forkSourceMessageId ?? null) === (right.forkSourceMessageId ?? null) && (left.sidechatSourceThreadId ?? null) === (right.sidechatSourceThreadId ?? null) && deepEqualJson(left.lastKnownPr ?? null, right.lastKnownPr ?? null) && (left.handoff ?? null) === (right.handoff ?? null) @@ -2230,6 +2246,7 @@ function buildSidebarThreadSummary( hasActionableProposedPlan: metadata.hasActionableProposedPlan, hasLiveTailWork: metadata.hasLiveTailWork, forkSourceThreadId: thread.forkSourceThreadId ?? null, + forkSourceMessageId: thread.forkSourceMessageId ?? null, sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null, lastKnownPr: thread.lastKnownPr ?? null, handoff: thread.handoff ?? null, diff --git a/apps/web/src/test/effectRpcWebSocketMock.ts b/apps/web/src/test/effectRpcWebSocketMock.ts index 360a22156..41f827b60 100644 --- a/apps/web/src/test/effectRpcWebSocketMock.ts +++ b/apps/web/src/test/effectRpcWebSocketMock.ts @@ -147,6 +147,9 @@ export function createShellSnapshotFromReadModel( subagentNickname: thread.subagentNickname ?? null, subagentRole: thread.subagentRole ?? null, forkSourceThreadId: thread.forkSourceThreadId ?? null, + forkSourceMessageId: thread.forkSourceMessageId ?? null, + forkTitleBase: thread.forkTitleBase ?? null, + forkTitleOrdinal: thread.forkTitleOrdinal ?? null, sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null, latestTurn: thread.latestTurn, latestUserMessageAt: thread.latestUserMessageAt ?? null, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index bd77646ed..13b77a4b8 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -226,6 +226,9 @@ export interface Thread extends ThreadWorkspaceState { subagentNickname?: string | null; subagentRole?: string | null; forkSourceThreadId?: ThreadId | null; + forkSourceMessageId?: MessageId | null; + forkTitleBase?: string | null; + forkTitleOrdinal?: number | null; sidechatSourceThreadId?: ThreadId | null; handoff?: ThreadHandoff | null; lastKnownPr?: OrchestrationThreadPullRequest | null; @@ -262,6 +265,9 @@ export interface ThreadShell extends ThreadWorkspaceState { subagentNickname?: string | null; subagentRole?: string | null; forkSourceThreadId?: ThreadId | null; + forkSourceMessageId?: MessageId | null; + forkTitleBase?: string | null; + forkTitleOrdinal?: number | null; sidechatSourceThreadId?: ThreadId | null; handoff?: ThreadHandoff | null; lastKnownPr?: OrchestrationThreadPullRequest | null; @@ -306,6 +312,7 @@ export interface SidebarThreadSummary { hasActionableProposedPlan: boolean; hasLiveTailWork: boolean; forkSourceThreadId?: ThreadId | null; + forkSourceMessageId?: MessageId | null; sidechatSourceThreadId?: ThreadId | null; handoff?: ThreadHandoff | null; lastKnownPr?: OrchestrationThreadPullRequest | null; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 169ae6f67..6f916284b 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -670,6 +670,15 @@ export const OrchestrationThread = Schema.Struct({ forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe( Schema.withDecodingDefault(() => null), ), + forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe( + Schema.withDecodingDefault(() => null), + ), + forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe( + Schema.withDecodingDefault(() => null), + ), + forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)).pipe( + Schema.withDecodingDefault(() => null), + ), sidechatSourceThreadId: SidechatSourceThreadId, lastKnownPr: Schema.optional(Schema.NullOr(OrchestrationThreadPullRequest)).pipe( Schema.withDecodingDefault(() => null), @@ -737,6 +746,15 @@ export const OrchestrationThreadShell = Schema.Struct({ forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe( Schema.withDecodingDefault(() => null), ), + forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe( + Schema.withDecodingDefault(() => null), + ), + forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe( + Schema.withDecodingDefault(() => null), + ), + forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)).pipe( + Schema.withDecodingDefault(() => null), + ), sidechatSourceThreadId: SidechatSourceThreadId, lastKnownPr: Schema.optional(Schema.NullOr(OrchestrationThreadPullRequest)).pipe( Schema.withDecodingDefault(() => null), @@ -920,6 +938,7 @@ const ThreadForkCreateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, sourceThreadId: ThreadId, + sourceMessageId: Schema.optional(MessageId), projectId: ProjectId, title: TrimmedNonEmptyString, modelSelection: ModelSelection, @@ -1490,6 +1509,15 @@ export const ThreadCreatedPayload = Schema.Struct({ forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)).pipe( Schema.withDecodingDefault(() => null), ), + forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe( + Schema.withDecodingDefault(() => null), + ), + forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe( + Schema.withDecodingDefault(() => null), + ), + forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)).pipe( + Schema.withDecodingDefault(() => null), + ), sidechatSourceThreadId: SidechatSourceThreadId, lastKnownPr: Schema.optional(Schema.NullOr(OrchestrationThreadPullRequest)).pipe( Schema.withDecodingDefault(() => null), diff --git a/packages/shared/package.json b/packages/shared/package.json index 03d29e24c..57805fbb3 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -72,6 +72,10 @@ "types": "./src/chatThreads.ts", "import": "./src/chatThreads.ts" }, + "./assistantSelections": { + "types": "./src/assistantSelections.ts", + "import": "./src/assistantSelections.ts" + }, "./localServers": { "types": "./src/localServers.ts", "import": "./src/localServers.ts" diff --git a/packages/shared/src/assistantSelections.ts b/packages/shared/src/assistantSelections.ts new file mode 100644 index 000000000..038b439f4 --- /dev/null +++ b/packages/shared/src/assistantSelections.ts @@ -0,0 +1,10 @@ +// FILE: assistantSelections.ts +// Purpose: Share persisted-prompt cleanup used when conversation history is copied. +// Layer: Shared text normalization + +const EMBEDDED_ASSISTANT_SELECTIONS_PATTERN = + /\n*\n[\s\S]*?\n<\/assistant_selection>(?=\n*(\n[\s\S]*?\n<\/terminal_context>\s*)?(\n[\s\S]*?\n<\/file_comments>\s*)?(\n[\s\S]*?\n<\/pasted_text>\s*)?$)/; + +export function stripEmbeddedAssistantSelections(prompt: string): string { + return prompt.replace(EMBEDDED_ASSISTANT_SELECTIONS_PATTERN, ""); +} From ee5d387085a4196beb609a44c6d8ce5629a97065 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 21:12:44 +0300 Subject: [PATCH 02/11] Stabilize hosted fork browser test --- apps/web/src/components/ChatView.browser.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index d53674e8e..aee861c7c 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1473,6 +1473,7 @@ async function waitForProductionStyles(): Promise { async function waitForElement( query: () => T | null, errorMessage: string, + timeout = 8_000, ): Promise { let element: T | null = null; await vi.waitFor( @@ -1481,7 +1482,7 @@ async function waitForElement( expect(element, errorMessage).toBeTruthy(); }, { - timeout: 8_000, + timeout, interval: 16, }, ); @@ -1967,6 +1968,7 @@ describe("ChatView timeline estimator parity (full app)", () => { `[data-message-id="${sourceMessageId}"][data-message-role="user"]`, ), "Unable to find source message for the fork action.", + 20_000, ); await userEvent.hover(sourceRow); const forkButton = sourceRow.querySelector( From 3b78217cdd813cdbbe277836e9473174f49081cb Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 22:01:53 +0300 Subject: [PATCH 03/11] Close message fork boundary gaps --- .../src/orchestration/forkTitle.test.ts | 47 ++++++++++ apps/server/src/orchestration/forkTitle.ts | 28 ++++-- .../src/orchestration/messageFork.test.ts | 65 ++++++++++++++ apps/server/src/orchestration/messageFork.ts | 28 +++++- apps/web/src/components/ChatView.tsx | 6 ++ .../components/chat/ChatTranscriptPane.tsx | 3 + .../MessagesTimeline.messageFork.browser.tsx | 85 +++++++++++++++++++ .../components/chat/MessagesTimeline.test.tsx | 69 +++++++++++++++ .../src/components/chat/MessagesTimeline.tsx | 11 ++- apps/web/src/lib/threadHandoff.test.ts | 75 ++++++++++++++++ apps/web/src/lib/threadHandoff.ts | 60 ++++++++++++- 11 files changed, 461 insertions(+), 16 deletions(-) diff --git a/apps/server/src/orchestration/forkTitle.test.ts b/apps/server/src/orchestration/forkTitle.test.ts index 28eba89d1..75e29bae1 100644 --- a/apps/server/src/orchestration/forkTitle.test.ts +++ b/apps/server/src/orchestration/forkTitle.test.ts @@ -94,6 +94,53 @@ describe("resolveNextForkTitle", () => { }); }); + it("keeps independently renamed siblings in separate families", () => { + const root = thread("root", "Greeting"); + const renamedA = thread("fork-a", "Experiment", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + const renamedB = thread("fork-b", "Experiment", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }); + const experimentA2 = thread("experiment-a-2", "Experiment (2)", { + forkSourceThreadId: renamedA.id, + forkTitleBase: "Experiment", + forkTitleOrdinal: 2, + }); + + expect( + resolveNextForkTitle({ + sourceThread: renamedB, + threads: [root, renamedA, renamedB, experimentA2], + }).title, + ).toBe("Experiment (2)"); + }); + + it("starts a new family when a child is manually renamed to its original base", () => { + const root = thread("root", "Greeting"); + const renamedChild = thread("fork-2", "Greeting", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + const originalFork3 = thread("fork-3", "Greeting (3)", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }); + + expect( + resolveNextForkTitle({ + sourceThread: renamedChild, + threads: [root, renamedChild, originalFork3], + }).title, + ).toBe("Greeting (2)"); + }); + it("preserves natural numeric parentheses as part of the base title", () => { const root = thread("root", "Plan (2026)"); const renamedFork = thread("fork-2", "Experiment (2026)", { diff --git a/apps/server/src/orchestration/forkTitle.ts b/apps/server/src/orchestration/forkTitle.ts index d6c1c9de3..b9f870511 100644 --- a/apps/server/src/orchestration/forkTitle.ts +++ b/apps/server/src/orchestration/forkTitle.ts @@ -28,6 +28,18 @@ function isStoredForkOrdinal(value: number | null | undefined): value is number return Number.isSafeInteger(value) && (value ?? 0) >= 2; } +function usesAutomaticForkTitle(thread: ForkTitleThread): thread is ForkTitleThread & { + readonly forkTitleBase: string; + readonly forkTitleOrdinal: number; +} { + return ( + thread.forkTitleBase !== null && + thread.forkTitleBase !== undefined && + isStoredForkOrdinal(thread.forkTitleOrdinal) && + thread.title === formatForkTitle(thread.forkTitleBase, thread.forkTitleOrdinal) + ); +} + function resolveForkFamilyRootId( thread: ForkTitleThread, threadsById: ReadonlyMap, @@ -35,7 +47,10 @@ function resolveForkFamilyRootId( let current = thread; const visited = new Set(); - while (current.forkSourceThreadId) { + // An automatically named fork inherits its source's family. A manual rename + // intentionally breaks that chain, making the renamed thread the root of a + // new family even when its visible title matches an older family. + while (current.forkSourceThreadId && usesAutomaticForkTitle(current)) { if (visited.has(current.id)) { return [...visited, current.id].toSorted()[0] ?? current.id; } @@ -62,21 +77,18 @@ export function resolveNextForkTitle(input: { threadsById.set(source.id, source); } - const sourceUsesAutomaticTitle = - source.forkTitleBase !== null && - source.forkTitleBase !== undefined && - isStoredForkOrdinal(source.forkTitleOrdinal) && - source.title === formatForkTitle(source.forkTitleBase, source.forkTitleOrdinal); - const forkTitleBase = sourceUsesAutomaticTitle ? source.forkTitleBase : source.title; + const forkTitleBase = usesAutomaticForkTitle(source) ? source.forkTitleBase : source.title; const familyRootId = resolveForkFamilyRootId(source, threadsById); let highestOrdinal = 1; for (const thread of input.threads) { + if (!usesAutomaticForkTitle(thread)) { + continue; + } if ( thread.projectId !== source.projectId || thread.sidechatSourceThreadId || thread.forkTitleBase !== forkTitleBase || - !isStoredForkOrdinal(thread.forkTitleOrdinal) || resolveForkFamilyRootId(thread, threadsById) !== familyRootId ) { continue; diff --git a/apps/server/src/orchestration/messageFork.test.ts b/apps/server/src/orchestration/messageFork.test.ts index 6c940f456..eb1ee6871 100644 --- a/apps/server/src/orchestration/messageFork.test.ts +++ b/apps/server/src/orchestration/messageFork.test.ts @@ -117,6 +117,71 @@ describe("validateMessageForkImport", () => { }); }); + it("rejects a queued user boundary after a live assistant instead of omitting the live row", () => { + const queuedUserId = MessageId.makeUnsafe("message-queued-user"); + expect( + validateMessageForkImport({ + sourceThread: { + messages: [ + sourceMessages[0]!, + sourceMessages[1]!, + { + id: queuedUserId, + role: "user", + text: "Queued follow-up", + turnId: TurnId.makeUnsafe("turn-queued"), + streaming: false, + source: "native", + createdAt: "2026-07-22T10:00:03.000Z", + updatedAt: "2026-07-22T10:00:03.000Z", + }, + ], + latestTurn: { + ...sourceThread.latestTurn!, + state: "running", + completedAt: null, + }, + }, + sourceMessageId: queuedUserId, + importedMessages: [ + importedMessages[0]!, + { + messageId: MessageId.makeUnsafe("import-queued-user"), + role: "user", + text: "Queued follow-up", + createdAt: "2026-07-22T10:00:03.000Z", + updatedAt: "2026-07-22T10:00:03.000Z", + }, + ], + }), + ).toEqual({ + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }); + }); + + it("rejects a non-streaming assistant while its authoritative turn is running", () => { + expect( + validateMessageForkImport({ + sourceThread: { + messages: [{ ...sourceMessages[1]!, streaming: false }], + latestTurn: { + ...sourceThread.latestTurn!, + state: "running", + completedAt: null, + }, + }, + sourceMessageId: assistantMessageId, + importedMessages: [importedMessages[1]!], + }), + ).toEqual({ + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }); + }); + it("rejects same-length imports whose text or attachments do not match the source prefix", () => { expect( validateMessageForkImport({ diff --git a/apps/server/src/orchestration/messageFork.ts b/apps/server/src/orchestration/messageFork.ts index dfca5f8fe..cf1b284f5 100644 --- a/apps/server/src/orchestration/messageFork.ts +++ b/apps/server/src/orchestration/messageFork.ts @@ -35,6 +35,14 @@ function isCompletedConversationMessage( if (message.role !== "user" && message.role !== "assistant") { return false; } + if ( + message.role === "assistant" && + message.turnId != null && + thread.latestTurn?.turnId === message.turnId && + thread.latestTurn.state === "running" + ) { + return false; + } if (!message.streaming) { return true; } @@ -120,9 +128,25 @@ export function validateMessageForkImport(input: { }; } - const expectedMessages = input.sourceThread.messages + const conversationPrefix = input.sourceThread.messages .slice(0, sourceMessageIndex + 1) - .filter((message) => isCompletedConversationMessage(input.sourceThread, message)); + .filter( + (message): message is OrchestrationMessage & { readonly role: "user" | "assistant" } => + message.role === "user" || message.role === "assistant", + ); + if ( + conversationPrefix.some( + (message) => !isCompletedConversationMessage(input.sourceThread, message), + ) + ) { + return { + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }; + } + + const expectedMessages = conversationPrefix; const importedMessagesMatch = input.importedMessages.length === expectedMessages.length && input.importedMessages.every((message, index) => { diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index c7f793ddd..bd96a4c08 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -540,6 +540,7 @@ import { collapseCursorModelVariants } from "../cursorModelVariants"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { canCreateThreadHandoff, + resolveThreadForkableMessageIds, resolveAvailableHandoffTargetProviders, resolveThreadHandoffBadgeLabel, } from "../lib/threadHandoff"; @@ -3060,6 +3061,10 @@ export default function ChatView({ () => new Set(optimisticUserMessages.map((message) => message.id)), [optimisticUserMessages], ); + const forkableMessageIds = useMemo( + () => (activeThread ? resolveThreadForkableMessageIds(activeThread) : new Set()), + [activeThread], + ); // --- Pinned messages & notes (per-thread, server-synced through sidepanel commands) --- const pinnedMessages = activeThread?.pinnedMessages ?? EMPTY_PINNED_MESSAGES; const threadMarkers = activeThread?.threadMarkers ?? EMPTY_THREAD_MARKERS; @@ -11163,6 +11168,7 @@ export default function ChatView({ onUndoTurnFiles={onUndoTurnFiles} onEditUserMessage={onEditUserMessage} {...(isServerThread ? { onForkFromMessage: handleForkFromMessage } : {})} + {...(isServerThread ? { forkableMessageIds } : {})} isRevertingCheckpoint={isRevertingCheckpoint} onExpandTimelineImage={onExpandTimelineImage} followLiveOutput={hasStreamingAssistantText} diff --git a/apps/web/src/components/chat/ChatTranscriptPane.tsx b/apps/web/src/components/chat/ChatTranscriptPane.tsx index e8e6b11c0..c1069a104 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.tsx @@ -78,6 +78,7 @@ interface ChatTranscriptPaneProps { onUndoTurnFiles?: ComponentProps["onUndoTurnFiles"]; onEditUserMessage?: (messageId: MessageId, text: string) => boolean | Promise; onForkFromMessage?: (messageId: MessageId) => void; + forkableMessageIds?: ReadonlySet; onScrollToBottom: () => void; onToggleWorkGroup?: (groupId: string) => void; resolvedTheme: "light" | "dark"; @@ -136,6 +137,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onUndoTurnFiles, onEditUserMessage, onForkFromMessage, + forkableMessageIds, onScrollToBottom, onToggleWorkGroup, resolvedTheme, @@ -221,6 +223,7 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ {...(onUndoTurnFiles ? { onUndoTurnFiles } : {})} {...(onEditUserMessage ? { onEditUserMessage } : {})} {...(onForkFromMessage ? { onForkFromMessage } : {})} + {...(forkableMessageIds ? { forkableMessageIds } : {})} isRevertingCheckpoint={isRevertingCheckpoint} onImageExpand={onExpandTimelineImage} followLiveOutput={followLiveOutput} diff --git a/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx b/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx index d27c17254..5f1689857 100644 --- a/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx @@ -67,6 +67,73 @@ function MessageForkTimeline() { ); } +function LiveBoundaryMessageForkTimeline() { + const safeUserId = MessageId.makeUnsafe("message-fork-browser-safe-user"); + return ( +

+ {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + onForkFromMessage={() => {}} + forkableMessageIds={new Set([safeUserId])} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="dark" + timestampFormat="locale" + workspaceRoot={undefined} + /> +
+ ); +} + describe("MessagesTimeline message fork action", () => { afterEach(() => { document.body.innerHTML = ""; @@ -103,4 +170,22 @@ describe("MessagesTimeline message fork action", () => { await screen.unmount(); } }); + + it("hides fork actions at and after a live assistant boundary", async () => { + const screen = await render(); + + try { + const forkButtons = Array.from( + document.querySelectorAll( + 'button[aria-label="Fork conversation from this message"]', + ), + ); + expect(forkButtons).toHaveLength(1); + expect( + forkButtons[0]?.closest("[data-message-id]")?.getAttribute("data-message-id"), + ).toBe("message-fork-browser-safe-user"); + } finally { + await screen.unmount(); + } + }); }); diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index c83dce357..820600825 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -420,6 +420,75 @@ describe("MessagesTimeline", () => { expect(markup).toContain("/central-icons-reversed/fork-simple.svg"); }); + it("hides message fork actions after an unsafe live boundary", async () => { + const { MessagesTimeline } = await import("./MessagesTimeline"); + const priorUserId = MessageId.makeUnsafe("message-fork-safe-user"); + const markup = renderToStaticMarkup( + {}} + onOpenTurnDiff={() => {}} + revertTurnCountByUserMessageId={new Map()} + onRevertUserMessage={() => {}} + onForkFromMessage={() => {}} + forkableMessageIds={new Set([priorUserId])} + isRevertingCheckpoint={false} + onImageExpand={() => {}} + markdownCwd={undefined} + resolvedTheme="light" + timestampFormat="locale" + workspaceRoot={undefined} + />, + ); + + expect(markup.match(/aria-label="Fork conversation from this message"/g)).toHaveLength(1); + expect(markup).toContain('data-message-id="message-fork-queued-user"'); + }); + it("keeps edit available and hides undo before a revert checkpoint exists", async () => { const { MessagesTimeline } = await import("./MessagesTimeline"); const markup = renderToStaticMarkup( diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index b24fc7e70..a42b1c86b 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -418,6 +418,8 @@ interface MessagesTimelineProps { onEditUserMessage?: (messageId: MessageId, text: string) => boolean | Promise; /** Create an independent conversation whose imported transcript ends at this message. */ onForkFromMessage?: (messageId: MessageId) => void; + /** Server-backed boundaries whose complete prefix is safe to import. */ + forkableMessageIds?: ReadonlySet; activeTurnId?: TurnId | null; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; @@ -476,6 +478,7 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onUndoTurnFiles, onEditUserMessage, onForkFromMessage, + forkableMessageIds, activeTurnId, isRevertingCheckpoint, onImageExpand, @@ -1198,7 +1201,9 @@ export const MessagesTimeline = memo(function MessagesTimeline({ className={MESSAGE_HOVER_REVEAL_CLASS_NAME} /> )} - {onForkFromMessage ? ( + {onForkFromMessage && + (forkableMessageIds === undefined || + forkableMessageIds.has(row.message.id)) ? ( ) : null} - {assistantCopyState.visible && onForkFromMessage ? ( + {assistantCopyState.visible && + onForkFromMessage && + (forkableMessageIds === undefined || forkableMessageIds.has(row.message.id)) ? ( { ).toEqual([]); }); + it("fails closed for a queued user boundary after a live assistant", () => { + const priorUserId = MessageId.makeUnsafe("message-prior-user"); + const liveAssistantId = MessageId.makeUnsafe("message-live-assistant"); + const queuedUserId = MessageId.makeUnsafe("message-queued-user"); + const liveTurnId = TurnId.makeUnsafe("turn-live"); + const thread = { + messages: [ + { + id: priorUserId, + role: "user" as const, + text: "Prior question", + createdAt: "2026-07-22T08:00:00.000Z", + streaming: false, + }, + { + id: liveAssistantId, + role: "assistant" as const, + text: "Still answering", + turnId: liveTurnId, + createdAt: "2026-07-22T08:00:01.000Z", + streaming: true, + }, + { + id: queuedUserId, + role: "user" as const, + text: "Queued follow-up", + createdAt: "2026-07-22T08:00:02.000Z", + streaming: false, + }, + ], + latestTurn: { + turnId: liveTurnId, + state: "running" as const, + requestedAt: "2026-07-22T08:00:00.000Z", + startedAt: "2026-07-22T08:00:00.500Z", + completedAt: null, + assistantMessageId: liveAssistantId, + }, + session: null, + }; + + expect(buildThreadForkImportedMessagesThrough(thread, queuedUserId)).toEqual([]); + expect([...resolveThreadForkableMessageIds(thread)]).toEqual([priorUserId]); + }); + + it("treats an active-turn assistant as unsafe even before its streaming flag arrives", () => { + const assistantId = MessageId.makeUnsafe("message-active-nonstreaming-assistant"); + const turnId = TurnId.makeUnsafe("turn-active-nonstreaming-assistant"); + const thread = { + messages: [ + { + id: assistantId, + role: "assistant" as const, + text: "Projected before the stream flag", + turnId, + createdAt: "2026-07-22T08:00:01.000Z", + streaming: false, + }, + ], + latestTurn: { + turnId, + state: "running" as const, + requestedAt: "2026-07-22T08:00:00.000Z", + startedAt: "2026-07-22T08:00:00.500Z", + completedAt: null, + assistantMessageId: assistantId, + }, + session: null, + }; + + expect(buildThreadForkImportedMessagesThrough(thread, assistantId)).toEqual([]); + expect(resolveThreadForkableMessageIds(thread).size).toBe(0); + }); + it("imports the settled latest assistant when its raw streaming flag is stale", () => { const assistantId = MessageId.makeUnsafe("message-stale-streaming-assistant"); const turnId = TurnId.makeUnsafe("turn-stale-streaming-assistant"); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 0c12bcddc..2ffdcd4ed 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -59,6 +59,26 @@ function isSettledTerminalTurnAssistantMessage( ); } +function isForkImportableThreadMessage( + thread: ForkSourceThread, + message: Thread["messages"][number], +): message is Thread["messages"][number] & { role: "user" | "assistant" } { + if (message.role !== "user" && message.role !== "assistant") { + return false; + } + if ( + message.role === "assistant" && + message.turnId != null && + thread.latestTurn?.turnId === message.turnId && + !isLatestTurnSettled(thread.latestTurn, thread.session ?? null) + ) { + return false; + } + return ( + isImportableThreadMessage(message) || isSettledTerminalTurnAssistantMessage(thread, message) + ); +} + function isImportableThreadActivity( activity: Thread["activities"][number], ): activity is OrchestrationThreadActivity { @@ -138,13 +158,45 @@ export function buildThreadForkImportedMessagesThrough( ): ReadonlyArray { const sourceMessageIndex = thread.messages.findIndex((message) => message.id === sourceMessageId); const sourceMessage = thread.messages[sourceMessageIndex]; - const isForkImportable = (message: Thread["messages"][number]) => - isImportableThreadMessage(message) || isSettledTerminalTurnAssistantMessage(thread, message); - if (sourceMessageIndex < 0 || !sourceMessage || !isForkImportable(sourceMessage)) { + if (sourceMessageIndex < 0 || !sourceMessage) { + return []; + } + + const conversationPrefix = thread.messages + .slice(0, sourceMessageIndex + 1) + .filter((message) => message.role === "user" || message.role === "assistant"); + if ( + conversationPrefix.length === 0 || + conversationPrefix.some((message) => !isForkImportableThreadMessage(thread, message)) + ) { return []; } - return buildImportedMessages(thread.messages.slice(0, sourceMessageIndex + 1), isForkImportable); + return buildImportedMessages(conversationPrefix, (message) => + isForkImportableThreadMessage(thread, message), + ); +} + +/** + * Message actions are offered only while the entire conversation prefix is safe + * to import. Once a live assistant row appears, that row and every later user + * or assistant boundary stay hidden until the lifecycle settles. + */ +export function resolveThreadForkableMessageIds(thread: ForkSourceThread): ReadonlySet { + const forkableMessageIds = new Set(); + let prefixIsImportable = true; + + for (const message of thread.messages) { + if (message.role !== "user" && message.role !== "assistant") { + continue; + } + prefixIsImportable = prefixIsImportable && isForkImportableThreadMessage(thread, message); + if (prefixIsImportable) { + forkableMessageIds.add(message.id); + } + } + + return forkableMessageIds; } export function buildThreadHandoffImportedActivities( From b8b573fe6776160b69fda9f5deef2b0f8202f445 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 22:32:07 +0300 Subject: [PATCH 04/11] Close final fork review gaps --- .../Layers/ProjectionPipeline.ts | 8 ++ .../Layers/ProviderCommandReactor.test.ts | 68 +++++++++- .../Layers/ProviderCommandReactor.ts | 28 +++- .../orchestration/decider.forkTitle.test.ts | 31 +++++ .../src/orchestration/forkTitle.test.ts | 54 ++++++-- apps/server/src/orchestration/forkTitle.ts | 9 +- apps/server/src/orchestration/handoff.ts | 50 ++++++- .../src/orchestration/messageFork.test.ts | 87 ++++++++++++ apps/server/src/orchestration/messageFork.ts | 67 ++++++++- apps/server/src/orchestration/projector.ts | 9 ++ apps/server/src/persistence/Migrations.ts | 2 + ...ProjectionThreadsForkTitleSequence.test.ts | 42 +++++- .../057_ProjectionThreadsForkTitleSequence.ts | 20 ++- .../058_ClearRenamedForkTitleLineage.test.ts | 128 ++++++++++++++++++ .../058_ClearRenamedForkTitleLineage.ts | 36 +++++ .../web/src/hooks/useComposerSlashCommands.ts | 8 +- apps/web/src/lib/threadHandoff.test.ts | 116 +++++++++++++++- apps/web/src/lib/threadHandoff.ts | 59 ++++++-- 18 files changed, 763 insertions(+), 59 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts create mode 100644 apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index e3de4aaca..3e0b3fb43 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -688,9 +688,17 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { event.payload.branch !== existingRow.value.branch ? false : undefined; + const titleBreaksAutomaticForkLineage = + event.payload.title !== undefined && + event.payload.title !== existingRow.value.title && + existingRow.value.forkTitleBase !== null && + existingRow.value.forkTitleBase !== undefined; yield* projectionThreadRepository.upsert({ ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), + ...(titleBreaksAutomaticForkLineage + ? { forkTitleBase: null, forkTitleOrdinal: null } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index d79ed04c0..c383fcae4 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -734,6 +734,15 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("source-prefix-user"), role: "user", text: "Question before the branch", + attachments: [ + { + type: "file", + id: "attachment-before-boundary", + name: "before.txt", + mimeType: "text/plain", + sizeBytes: 12, + }, + ], createdAt: now, updatedAt: now, }, @@ -748,6 +757,15 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("source-after-boundary-user"), role: "user", text: "This later source message must not be inherited", + attachments: [ + { + type: "file", + id: "attachment-after-boundary", + name: "after.txt", + mimeType: "text/plain", + sizeBytes: 11, + }, + ], createdAt: now, updatedAt: now, }, @@ -776,14 +794,23 @@ describe("ProviderCommandReactor", () => { worktreePath: null, importedMessages: [ { - messageId: asMessageId("fork-prefix-user"), + messageId: asMessageId("fork-00000000-prefix-user"), role: "user", text: "Question before the branch", + attachments: [ + { + type: "file", + id: "attachment-before-boundary", + name: "before.txt", + mimeType: "text/plain", + sizeBytes: 12, + }, + ], createdAt: now, updatedAt: now, }, { - messageId: asMessageId("fork-boundary-assistant"), + messageId: asMessageId("fork-00000001-boundary-assistant"), role: "assistant", text: "Answer at the branch boundary", createdAt: now, @@ -814,12 +841,17 @@ describe("ProviderCommandReactor", () => { await waitFor(() => harness.sendTurn.mock.calls.length === 1); expect(harness.forkThread).not.toHaveBeenCalled(); expect(harness.startSession).toHaveBeenCalled(); - const input = harness.sendTurn.mock.calls[0]?.[0] as { input?: string } | undefined; + const input = harness.sendTurn.mock.calls[0]?.[0] as + | { input?: string; attachments?: ReadonlyArray<{ id: string }> } + | undefined; expect(input?.input).toContain(""); expect(input?.input).toContain("Question before the branch"); expect(input?.input).toContain("Answer at the branch boundary"); expect(input?.input).not.toContain("This later source message must not be inherited"); expect(input?.input).toContain("Take this in a different direction"); + expect(input?.attachments?.map((attachment) => attachment.id)).toEqual([ + "attachment-before-boundary", + ]); const readModel = await Effect.runPromise(harness.engine.getReadModel()); const forkedThread = readModel.threads.find((thread) => thread.id === forkThreadId); @@ -847,6 +879,15 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("source-message-fork-restart-user"), role: "user", text: "Persisted question before restart", + attachments: [ + { + type: "file", + id: "attachment-before-restart", + name: "restart.txt", + mimeType: "text/plain", + sizeBytes: 15, + }, + ], createdAt, updatedAt: createdAt, }, @@ -878,14 +919,23 @@ describe("ProviderCommandReactor", () => { worktreePath: null, importedMessages: [ { - messageId: asMessageId("fork-message-restart-user"), + messageId: asMessageId("fork-00000000-message-restart-user"), role: "user", text: "Persisted question before restart", + attachments: [ + { + type: "file", + id: "attachment-before-restart", + name: "restart.txt", + mimeType: "text/plain", + sizeBytes: 15, + }, + ], createdAt, updatedAt: createdAt, }, { - messageId: asMessageId("fork-message-restart-assistant"), + messageId: asMessageId("fork-00000001-message-restart-assistant"), role: "assistant", text: "Persisted answer before restart", createdAt, @@ -924,12 +974,18 @@ describe("ProviderCommandReactor", () => { await waitFor(() => restartedHarness.sendTurn.mock.calls.length === 1); const providerInput = restartedHarness.sendTurn.mock.calls[0]?.[0] as - | { input?: string } + | { input?: string; attachments?: ReadonlyArray<{ id: string }> } | undefined; expect(providerInput?.input).toContain(""); expect(providerInput?.input).toContain("Persisted question before restart"); expect(providerInput?.input).toContain("Persisted answer before restart"); + expect(providerInput?.input?.indexOf("Persisted question before restart")).toBeLessThan( + providerInput?.input?.indexOf("Persisted answer before restart") ?? -1, + ); expect(providerInput?.input).toContain("Continue after restart"); + expect(providerInput?.attachments?.map((attachment) => attachment.id)).toEqual([ + "attachment-before-restart", + ]); }); it("bootstraps Droid sidechat context after a native provider fork", async () => { diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3b293df71..3ce5966f2 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -61,11 +61,13 @@ import { ServerConfig } from "../../config.ts"; import { buildScientBuiltInSkillTriggerInstructions } from "../../scientBuiltInSkills.ts"; import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts"; import { + buildMessageForkBootstrapText, buildPriorTranscriptBootstrapText, buildForkBootstrapText, buildHandoffBootstrapText, hasNativeAssistantMessagesBefore, listImportedForkMessages, + listImportedForkProviderAttachments, listPriorTranscriptMessages, } from "../handoff.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -1102,6 +1104,8 @@ const make = Effect.gen(function* () { const hasPendingPriorTranscriptBootstrap = freshSessionContextBootstrapThreadIds.has(input.threadId) || rollbackContextBootstrapThreadIds.has(input.threadId); + const isPendingMessageBoundaryForkBootstrap = + hasPendingPriorTranscriptBootstrap && thread.forkSourceMessageId != null; const shouldBootstrapSidechatContext = thread.sidechatSourceThreadId !== null && sidechatContextBootstrapThreadIds.has(input.threadId) && @@ -1140,7 +1144,9 @@ const make = Effect.gen(function* () { !shouldBootstrapSidechatContext; const hasPriorTranscriptBootstrapContent = shouldBootstrapPriorTranscriptContext && - listPriorTranscriptMessages(thread, input.messageId).length > 0; + (isPendingMessageBoundaryForkBootstrap + ? listImportedForkMessages(thread).length > 0 + : listPriorTranscriptMessages(thread, input.messageId).length > 0); const priorTranscriptBootstrapAvailableChars = availableProviderContextChars({ tag: "thread_context", messageText: boundaryMessageText, @@ -1163,11 +1169,13 @@ const make = Effect.gen(function* () { } const priorTranscriptBootstrapText = shouldBootstrapPriorTranscriptContext && priorTranscriptBootstrapAvailableChars > 0 - ? buildPriorTranscriptBootstrapText( - thread, - input.messageId, - priorTranscriptBootstrapAvailableChars, - ) + ? isPendingMessageBoundaryForkBootstrap + ? buildMessageForkBootstrapText(thread, priorTranscriptBootstrapAvailableChars) + : buildPriorTranscriptBootstrapText( + thread, + input.messageId, + priorTranscriptBootstrapAvailableChars, + ) : null; const providerInput = handoffBootstrapText ? wrapProviderContext({ @@ -1238,7 +1246,13 @@ const make = Effect.gen(function* () { ...(input.skills !== undefined ? { skills: input.skills } : {}), }), ); - const normalizedAttachments = input.attachments ?? []; + const normalizedAttachments = [ + ...(isPendingMessageBoundaryForkBootstrap ? listImportedForkProviderAttachments(thread) : []), + ...(input.attachments ?? []), + ].filter( + (attachment, index, attachments) => + attachments.findIndex((candidate) => candidate.id === attachment.id) === index, + ); const activeSession = yield* providerService .listSessions() .pipe( diff --git a/apps/server/src/orchestration/decider.forkTitle.test.ts b/apps/server/src/orchestration/decider.forkTitle.test.ts index e718d8b4a..984d3b4be 100644 --- a/apps/server/src/orchestration/decider.forkTitle.test.ts +++ b/apps/server/src/orchestration/decider.forkTitle.test.ts @@ -174,6 +174,9 @@ describe("thread.fork.create title allocation", () => { throw new Error("Expected thread.meta-updated event"); } const renamedModel = await applyEvent(withFork2, renameEvent, 4); + const renamedThread = renamedModel.threads.find((thread) => thread.id === "fork-2"); + expect(renamedThread?.forkTitleBase).toBeNull(); + expect(renamedThread?.forkTitleOrdinal).toBeNull(); const renamedForkEvent = await decideCreatedEvent( renamedModel, @@ -184,6 +187,34 @@ describe("thread.fork.create title allocation", () => { forkTitleBase: "Experiment (2026)", forkTitleOrdinal: 2, }); + + const renameBackResult = await Effect.runPromise( + decideOrchestrationCommand({ + readModel: renamedModel, + command: { + type: "thread.meta.update", + commandId: commandId("command-rename-fork-2-back"), + threadId: ThreadId.makeUnsafe("fork-2"), + title: "Greeting (2)", + }, + }), + ); + const renameBackEvent = ( + Array.isArray(renameBackResult) ? renameBackResult : [renameBackResult] + )[0]; + if (!renameBackEvent || renameBackEvent.type !== "thread.meta-updated") { + throw new Error("Expected rename-back thread.meta-updated event"); + } + const renamedBackModel = await applyEvent(renamedModel, renameBackEvent, 5); + const renamedBackForkEvent = await decideCreatedEvent( + renamedBackModel, + forkCommand({ threadId: "renamed-back-child", sourceThreadId: "fork-2" }), + ); + expect(renamedBackForkEvent.payload).toMatchObject({ + title: "Greeting (2) (2)", + forkTitleBase: "Greeting (2)", + forkTitleOrdinal: 2, + }); }); it("preserves sidechat-owned titles and excludes them from fork numbering", async () => { diff --git a/apps/server/src/orchestration/forkTitle.test.ts b/apps/server/src/orchestration/forkTitle.test.ts index 75e29bae1..bde6c4399 100644 --- a/apps/server/src/orchestration/forkTitle.test.ts +++ b/apps/server/src/orchestration/forkTitle.test.ts @@ -66,8 +66,8 @@ describe("resolveNextForkTitle", () => { const root = thread("root", "Greeting"); const renamedFork = thread("fork-2", "Experiment", { forkSourceThreadId: root.id, - forkTitleBase: "Greeting", - forkTitleOrdinal: 2, + forkTitleBase: null, + forkTitleOrdinal: null, }); const experiment2 = thread("experiment-2", "Experiment (2)", { forkSourceThreadId: renamedFork.id, @@ -98,13 +98,13 @@ describe("resolveNextForkTitle", () => { const root = thread("root", "Greeting"); const renamedA = thread("fork-a", "Experiment", { forkSourceThreadId: root.id, - forkTitleBase: "Greeting", - forkTitleOrdinal: 2, + forkTitleBase: null, + forkTitleOrdinal: null, }); const renamedB = thread("fork-b", "Experiment", { forkSourceThreadId: root.id, - forkTitleBase: "Greeting", - forkTitleOrdinal: 3, + forkTitleBase: null, + forkTitleOrdinal: null, }); const experimentA2 = thread("experiment-a-2", "Experiment (2)", { forkSourceThreadId: renamedA.id, @@ -124,8 +124,8 @@ describe("resolveNextForkTitle", () => { const root = thread("root", "Greeting"); const renamedChild = thread("fork-2", "Greeting", { forkSourceThreadId: root.id, - forkTitleBase: "Greeting", - forkTitleOrdinal: 2, + forkTitleBase: null, + forkTitleOrdinal: null, }); const originalFork3 = thread("fork-3", "Greeting (3)", { forkSourceThreadId: root.id, @@ -141,12 +141,46 @@ describe("resolveNextForkTitle", () => { ).toBe("Greeting (2)"); }); + it("does not rejoin the old family after renaming back to the generated title", () => { + const root = thread("root", "Greeting"); + const renamedBack = thread("fork-2", "Greeting (2)", { + forkSourceThreadId: root.id, + forkTitleBase: null, + forkTitleOrdinal: null, + }); + const originalFork3 = thread("fork-3", "Greeting (3)", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }); + + expect( + resolveNextForkTitle({ + sourceThread: renamedBack, + threads: [root, renamedBack, originalFork3], + }).title, + ).toBe("Greeting (2) (2)"); + }); + + it("counts migrated legacy forks whose visible title was intentionally preserved", () => { + const root = thread("root", "Greeting"); + const legacyFork = thread("legacy-fork", "Greeting", { + forkSourceThreadId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }); + + expect(resolveNextForkTitle({ sourceThread: root, threads: [root, legacyFork] }).title).toBe( + "Greeting (3)", + ); + }); + it("preserves natural numeric parentheses as part of the base title", () => { const root = thread("root", "Plan (2026)"); const renamedFork = thread("fork-2", "Experiment (2026)", { forkSourceThreadId: root.id, - forkTitleBase: "Plan (2026)", - forkTitleOrdinal: 2, + forkTitleBase: null, + forkTitleOrdinal: null, }); expect(resolveNextForkTitle({ sourceThread: root, threads: [root] }).title).toBe( diff --git a/apps/server/src/orchestration/forkTitle.ts b/apps/server/src/orchestration/forkTitle.ts index b9f870511..bf6fc8760 100644 --- a/apps/server/src/orchestration/forkTitle.ts +++ b/apps/server/src/orchestration/forkTitle.ts @@ -35,8 +35,7 @@ function usesAutomaticForkTitle(thread: ForkTitleThread): thread is ForkTitleThr return ( thread.forkTitleBase !== null && thread.forkTitleBase !== undefined && - isStoredForkOrdinal(thread.forkTitleOrdinal) && - thread.title === formatForkTitle(thread.forkTitleBase, thread.forkTitleOrdinal) + isStoredForkOrdinal(thread.forkTitleOrdinal) ); } @@ -47,9 +46,9 @@ function resolveForkFamilyRootId( let current = thread; const visited = new Set(); - // An automatically named fork inherits its source's family. A manual rename - // intentionally breaks that chain, making the renamed thread the root of a - // new family even when its visible title matches an older family. + // Automatic lineage is persisted in the fork metadata. Title updates clear + // that metadata in both projectors, so a rename remains a permanent family + // boundary even when the user later restores the generated title verbatim. while (current.forkSourceThreadId && usesAutomaticForkTitle(current)) { if (visited.has(current.id)) { return [...visited, current.id].toSorted()[0] ?? current.id; diff --git a/apps/server/src/orchestration/handoff.ts b/apps/server/src/orchestration/handoff.ts index 80babc430..4b90a8154 100644 --- a/apps/server/src/orchestration/handoff.ts +++ b/apps/server/src/orchestration/handoff.ts @@ -1,5 +1,6 @@ import { PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type ChatAttachment, type OrchestrationMessage, type OrchestrationThread, } from "@synara/contracts"; @@ -31,6 +32,16 @@ function roleLabel(message: Pick): "User" | "Assis return message.role === "assistant" ? "Assistant" : "User"; } +function bootstrapMessageContent(message: OrchestrationMessage): string { + const text = normalizeMessageText(message.text); + const attachmentLines = (message.attachments ?? []).flatMap((attachment) => + attachment.type === "assistant-selection" + ? [] + : [`[Attachment: ${attachment.name} (${attachment.mimeType})]`], + ); + return [text, ...attachmentLines].filter((part) => part.length > 0).join("\n"); +} + function earlierSummaryHeader(omittedCount: number): string { return omittedCount > 0 ? `Earlier conversation summary (${omittedCount} older ${ @@ -61,6 +72,20 @@ export function listImportedForkMessages( ); } +export function listImportedForkProviderAttachments( + thread: Pick, +): ReadonlyArray { + const attachmentsById = new Map(); + for (const message of listImportedForkMessages(thread)) { + for (const attachment of message.attachments ?? []) { + if (attachment.type !== "assistant-selection") { + attachmentsById.set(attachment.id, attachment); + } + } + } + return [...attachmentsById.values()]; +} + export function hasNativeHandoffMessages(thread: Pick): boolean { return thread.messages.some( (message) => @@ -131,7 +156,7 @@ function buildImportedMessagesBootstrapText(input: { recentMessages .map((message) => { const normalized = truncateText( - normalizeMessageText(message.text), + bootstrapMessageContent(message), RECENT_MESSAGE_CHAR_LIMIT, ); return `${roleLabel(message)}:\n${normalized}`; @@ -149,10 +174,7 @@ function buildImportedMessagesBootstrapText(input: { const summaryLines: string[] = []; for (let index = earlierMessages.length - 1; index >= 0; index -= 1) { const message = earlierMessages[index]!; - const normalized = truncateText( - normalizeMessageText(message.text), - EARLIER_MESSAGE_CHAR_LIMIT, - ); + const normalized = truncateText(bootstrapMessageContent(message), EARLIER_MESSAGE_CHAR_LIMIT); const line = `- ${roleLabel(message)}: ${normalized}`; if (remaining < line.length + 1) { break; @@ -227,3 +249,21 @@ export function buildForkBootstrapText( ceilingChars: AUTOMATIC_BOOTSTRAP_TRANSCRIPT_CHAR_BUDGET, }); } + +export function buildMessageForkBootstrapText( + thread: Pick, + maxChars = HANDOFF_BOOTSTRAP_CHAR_BUDGET, +): string | null { + const importedMessages = listImportedForkMessages(thread); + if (importedMessages.length === 0) { + return null; + } + + return buildImportedMessagesBootstrapText({ + thread, + importedMessages, + intro: "This task was forked from an exact earlier conversation boundary.", + maxChars, + ceilingChars: AUTOMATIC_BOOTSTRAP_TRANSCRIPT_CHAR_BUDGET, + }); +} diff --git a/apps/server/src/orchestration/messageFork.test.ts b/apps/server/src/orchestration/messageFork.test.ts index eb1ee6871..b23c04efb 100644 --- a/apps/server/src/orchestration/messageFork.test.ts +++ b/apps/server/src/orchestration/messageFork.test.ts @@ -161,6 +161,93 @@ describe("validateMessageForkImport", () => { }); }); + it("rejects queued input before the running turn has projected an assistant", () => { + const activeCreatedAt = "2026-07-22T10:01:00.000Z"; + const queuedUserId = MessageId.makeUnsafe("message-queued-before-assistant"); + expect( + validateMessageForkImport({ + sourceThread: { + messages: [ + sourceMessages[0]!, + { + id: MessageId.makeUnsafe("message-active-before-assistant"), + role: "user", + text: "Prompt currently running", + turnId: null, + streaming: false, + source: "native", + createdAt: activeCreatedAt, + updatedAt: activeCreatedAt, + }, + { + id: queuedUserId, + role: "user", + text: "Queued before an assistant row exists", + turnId: null, + streaming: false, + source: "native", + createdAt: "2026-07-22T10:01:01.000Z", + updatedAt: "2026-07-22T10:01:01.000Z", + }, + ], + latestTurn: { + turnId: TurnId.makeUnsafe("turn-before-assistant"), + state: "running", + requestedAt: activeCreatedAt, + startedAt: activeCreatedAt, + completedAt: null, + assistantMessageId: null, + }, + }, + sourceMessageId: queuedUserId, + importedMessages: [], + }), + ).toEqual({ + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }); + }); + + it("rejects equal-timestamp ids whose persisted order would differ from the prefix", () => { + const equalTimestampThread = { + messages: [ + { ...sourceMessages[0]!, createdAt: now }, + { ...sourceMessages[1]!, createdAt: now, streaming: false }, + ], + latestTurn: { + ...sourceThread.latestTurn!, + completedAt: now, + }, + }; + const reversedIds = [ + { ...importedMessages[0]!, messageId: MessageId.makeUnsafe("fork:00000001"), createdAt: now }, + { ...importedMessages[1]!, messageId: MessageId.makeUnsafe("fork:00000000"), createdAt: now }, + ]; + + expect( + validateMessageForkImport({ + sourceThread: equalTimestampThread, + sourceMessageId: assistantMessageId, + importedMessages: reversedIds, + }), + ).toEqual({ + ok: false, + reason: "import-mismatch", + expectedImportedMessageCount: 2, + }); + expect( + validateMessageForkImport({ + sourceThread: equalTimestampThread, + sourceMessageId: assistantMessageId, + importedMessages: [ + { ...reversedIds[0]!, messageId: MessageId.makeUnsafe("fork:00000000") }, + { ...reversedIds[1]!, messageId: MessageId.makeUnsafe("fork:00000001") }, + ], + }), + ).toEqual({ ok: true }); + }); + it("rejects a non-streaming assistant while its authoritative turn is running", () => { expect( validateMessageForkImport({ diff --git a/apps/server/src/orchestration/messageFork.ts b/apps/server/src/orchestration/messageFork.ts index cf1b284f5..120845723 100644 --- a/apps/server/src/orchestration/messageFork.ts +++ b/apps/server/src/orchestration/messageFork.ts @@ -28,13 +28,42 @@ export interface ImportedMessageIdValidation { type MessageForkSourceThread = Pick; +function resolveRunningTurnBoundaryIndex(thread: MessageForkSourceThread): number | null { + if (thread.latestTurn?.state !== "running") { + return null; + } + + const activeAssistantIndex = thread.messages.findIndex( + (message) => message.role === "assistant" && message.turnId === thread.latestTurn?.turnId, + ); + if (activeAssistantIndex >= 0) { + return activeAssistantIndex; + } + + const requestedUserIndex = thread.messages.findIndex( + (message) => message.role === "user" && message.createdAt === thread.latestTurn?.requestedAt, + ); + if (requestedUserIndex >= 0) { + return requestedUserIndex + 1; + } + + // A running turn without a projected prompt or assistant is an incomplete + // lifecycle snapshot. Fail closed until the authoritative boundary appears. + return 0; +} + function isCompletedConversationMessage( thread: MessageForkSourceThread, message: OrchestrationMessage, + messageIndex: number, + runningTurnBoundaryIndex: number | null, ): message is OrchestrationMessage & { readonly role: "user" | "assistant" } { if (message.role !== "user" && message.role !== "assistant") { return false; } + if (runningTurnBoundaryIndex !== null && messageIndex >= runningTurnBoundaryIndex) { + return false; + } if ( message.role === "assistant" && message.turnId != null && @@ -112,6 +141,7 @@ export function validateMessageForkImport(input: { readonly sourceMessageId: MessageId; readonly importedMessages: ReadonlyArray; }): MessageForkValidation { + const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(input.sourceThread); const sourceMessageIndex = input.sourceThread.messages.findIndex( (message) => message.id === input.sourceMessageId, ); @@ -119,7 +149,12 @@ export function validateMessageForkImport(input: { if ( sourceMessageIndex < 0 || !sourceMessage || - !isCompletedConversationMessage(input.sourceThread, sourceMessage) + !isCompletedConversationMessage( + input.sourceThread, + sourceMessage, + sourceMessageIndex, + runningTurnBoundaryIndex, + ) ) { return { ok: false, @@ -135,9 +170,15 @@ export function validateMessageForkImport(input: { message.role === "user" || message.role === "assistant", ); if ( - conversationPrefix.some( - (message) => !isCompletedConversationMessage(input.sourceThread, message), - ) + conversationPrefix.some((message) => { + const messageIndex = input.sourceThread.messages.indexOf(message); + return !isCompletedConversationMessage( + input.sourceThread, + message, + messageIndex, + runningTurnBoundaryIndex, + ); + }) ) { return { ok: false, @@ -146,6 +187,24 @@ export function validateMessageForkImport(input: { }; } + const importOrderIsPersistent = input.importedMessages.every((message, index, messages) => { + const previous = messages[index - 1]; + if (!previous) { + return true; + } + return ( + previous.createdAt < message.createdAt || + (previous.createdAt === message.createdAt && previous.messageId < message.messageId) + ); + }); + if (!importOrderIsPersistent) { + return { + ok: false, + reason: "import-mismatch", + expectedImportedMessageCount: conversationPrefix.length, + }; + } + const expectedMessages = conversationPrefix; const importedMessagesMatch = input.importedMessages.length === expectedMessages.length && diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 55f60ca06..9fe03d8b1 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -483,10 +483,19 @@ export function projectEvent( payload.branch !== existingThread.branch ? false : undefined; + const titleBreaksAutomaticForkLineage = + payload.title !== undefined && + existingThread !== null && + payload.title !== existingThread.title && + existingThread.forkTitleBase !== null && + existingThread.forkTitleBase !== undefined; return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), + ...(titleBreaksAutomaticForkLineage + ? { forkTitleBase: null, forkTitleOrdinal: null } + : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } : {}), diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 581d6ddb1..f75ec4745 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -73,6 +73,7 @@ import Migration0054 from "./Migrations/054_ProjectPullRequestPins.ts"; import Migration0055 from "./Migrations/055_ProjectionThreadSessionLastErrorEvent.ts"; import Migration0056 from "./Migrations/056_ProjectionThreadsForkSourceMessage.ts"; import Migration0057 from "./Migrations/057_ProjectionThreadsForkTitleSequence.ts"; +import Migration0058 from "./Migrations/058_ClearRenamedForkTitleLineage.ts"; /** * Migration loader with all migrations defined inline. @@ -142,6 +143,7 @@ export const migrationEntries = [ [55, "ProjectionThreadSessionLastErrorEvent", Migration0055], [56, "ProjectionThreadsForkSourceMessage", Migration0056], [57, "ProjectionThreadsForkTitleSequence", Migration0057], + [58, "ClearRenamedForkTitleLineage", Migration0058], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts index 6f4ffe61d..91bb84b8b 100644 --- a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts +++ b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts @@ -5,6 +5,7 @@ import { describe } from "vitest"; import { runMigrations } from "../Migrations.ts"; import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import { resolveNextForkTitle, type ForkTitleThread } from "../../orchestration/forkTitle.ts"; const projectionThreadColumnNames = (sql: SqlClient.SqlClient) => sql<{ readonly name: string }>` @@ -229,14 +230,14 @@ describe("057_ProjectionThreadsForkTitleSequence", () => { { id: "fork-2", title: "Renamed first fork", - forkTitleBase: "Greeting", - forkTitleOrdinal: 2, + forkTitleBase: null, + forkTitleOrdinal: null, }, { id: "fork-3", title: "Greeting", forkTitleBase: "Greeting", - forkTitleOrdinal: 3, + forkTitleOrdinal: 2, }, { id: "pre-numbered", @@ -259,6 +260,37 @@ describe("057_ProjectionThreadsForkTitleSequence", () => { }, ]); + const allocatorRows = yield* sql<{ + readonly id: string; + readonly projectId: string; + readonly title: string; + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; + }>` + SELECT + thread_id AS id, + project_id AS "projectId", + title, + fork_source_thread_id AS "forkSourceThreadId", + sidechat_source_thread_id AS "sidechatSourceThreadId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + `; + const allocatorRoot = allocatorRows.find((row) => row.id === "root"); + if (!allocatorRoot) { + throw new Error("Expected migrated root thread"); + } + assert.strictEqual( + resolveNextForkTitle({ + sourceThread: allocatorRoot, + threads: allocatorRows satisfies ReadonlyArray, + }).title, + "Greeting (9)", + ); + const eventRows = yield* sql<{ readonly id: string; readonly forkTitleBase: string | null; @@ -274,8 +306,8 @@ describe("057_ProjectionThreadsForkTitleSequence", () => { `; assert.deepEqual(eventRows, [ { id: "experiment-2", forkTitleBase: "Experiment", forkTitleOrdinal: 2 }, - { id: "fork-2", forkTitleBase: "Greeting", forkTitleOrdinal: 2 }, - { id: "fork-3", forkTitleBase: "Greeting", forkTitleOrdinal: 3 }, + { id: "fork-2", forkTitleBase: null, forkTitleOrdinal: null }, + { id: "fork-3", forkTitleBase: "Greeting", forkTitleOrdinal: 2 }, { id: "pre-numbered", forkTitleBase: "Greeting", forkTitleOrdinal: 7 }, { id: "root", forkTitleBase: null, forkTitleOrdinal: null }, { id: "sidechat", forkTitleBase: null, forkTitleOrdinal: null }, diff --git a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts index 651aa9282..ee789364c 100644 --- a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts +++ b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts @@ -16,6 +16,7 @@ interface ThreadCreatedRow { readonly sidechatSourceThreadId: string | null; readonly forkTitleBase: string | null; readonly forkTitleOrdinal: number | null; + readonly currentProjectionTitle?: string | null; } const familyRootId = ( @@ -65,7 +66,12 @@ export default Effect.gen(function* () { json_extract(payload_json, '$.forkSourceThreadId') AS "forkSourceThreadId", json_extract(payload_json, '$.sidechatSourceThreadId') AS "sidechatSourceThreadId", json_extract(payload_json, '$.forkTitleBase') AS "forkTitleBase", - json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal" + json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal", + ( + SELECT projection_threads.title + FROM projection_threads + WHERE projection_threads.thread_id = orchestration_events.stream_id + ) AS "currentProjectionTitle" FROM orchestration_events WHERE event_type = 'thread.created' AND json_valid(payload_json) @@ -75,7 +81,15 @@ export default Effect.gen(function* () { const highestOrdinalBySeries = new Map(); for (const row of createdRows) { - if (!row.projectId || !row.title || !row.forkSourceThreadId || row.sidechatSourceThreadId) { + if ( + !row.projectId || + !row.title || + !row.forkSourceThreadId || + row.sidechatSourceThreadId || + (row.currentProjectionTitle !== null && + row.currentProjectionTitle !== undefined && + row.currentProjectionTitle !== row.title) + ) { continue; } @@ -127,6 +141,7 @@ export default Effect.gen(function* () { ORDER BY created_at ASC, thread_id ASC `; const projectionRowsByThreadId = new Map(projectionRows.map((row) => [row.threadId, row])); + const eventBackedThreadIds = new Set(createdRows.map((row) => row.threadId)); const projectionHighestOrdinalBySeries = new Map(); for (const row of projectionRows) { @@ -157,6 +172,7 @@ export default Effect.gen(function* () { !row.title || !row.forkSourceThreadId || row.sidechatSourceThreadId || + eventBackedThreadIds.has(row.threadId) || (row.forkTitleBase !== null && Number.isSafeInteger(row.forkTitleOrdinal) && (row.forkTitleOrdinal ?? 0) >= 2) diff --git a/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts new file mode 100644 index 000000000..662e5c427 --- /dev/null +++ b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts @@ -0,0 +1,128 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { describe } from "vitest"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +describe("058_ClearRenamedForkTitleLineage", () => { + it.effect("clears backfilled lineage after a durable title update", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 57 }); + const createdAt = "2026-07-22T10:00:00.000Z"; + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + env_mode, + create_branch_flow_completed, + is_pinned, + fork_source_thread_id, + fork_title_base, + fork_title_ordinal, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at + ) + VALUES ( + 'renamed-fork', + 'project-1', + 'Greeting (2)', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'local', + 0, + 0, + 'root', + 'Greeting', + 2, + 0, + 0, + 0, + ${createdAt}, + ${createdAt} + ) + `; + + for (const [eventType, payload, version] of [ + [ + "thread.created", + { + threadId: "renamed-fork", + projectId: "project-1", + title: "Greeting (2)", + forkSourceThreadId: "root", + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + createdAt, + updatedAt: createdAt, + }, + 1, + ], + [ + "thread.meta-updated", + { + threadId: "renamed-fork", + title: "Greeting (2)", + updatedAt: "2026-07-22T10:01:00.000Z", + }, + 2, + ], + ] as const) { + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${`event-${version}`}, + 'thread', + 'renamed-fork', + ${version}, + ${eventType}, + ${createdAt}, + ${`command-${version}`}, + NULL, + ${`command-${version}`}, + 'client', + ${JSON.stringify(payload)}, + '{}' + ) + `; + } + + yield* runMigrations(); + const rows = yield* sql<{ + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; + }>` + SELECT + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + WHERE thread_id = 'renamed-fork' + `; + assert.deepEqual(rows, [{ forkTitleBase: null, forkTitleOrdinal: null }]); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); +}); diff --git a/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts new file mode 100644 index 000000000..93827b0c2 --- /dev/null +++ b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts @@ -0,0 +1,36 @@ +// FILE: 058_ClearRenamedForkTitleLineage.ts +// Purpose: Make title changes a durable fork-family boundary for databases that ran migration 057. +// Layer: Server persistence migration + +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + // Migration 057 may already have backfilled automatic-title metadata before + // this build learned that any subsequent title update permanently starts a + // new family. Clear the read-model metadata when the event history proves a + // title update occurred after creation. Replay reaches the same state because + // the in-memory and durable projectors now clear these fields on that event. + yield* sql` + UPDATE projection_threads + SET + fork_title_base = NULL, + fork_title_ordinal = NULL + WHERE fork_title_base IS NOT NULL + AND fork_title_ordinal IS NOT NULL + AND EXISTS ( + SELECT 1 + FROM orchestration_events AS created + JOIN orchestration_events AS renamed + ON renamed.stream_id = created.stream_id + AND renamed.sequence > created.sequence + WHERE created.event_type = 'thread.created' + AND renamed.event_type = 'thread.meta-updated' + AND created.stream_id = projection_threads.thread_id + AND json_valid(renamed.payload_json) + AND json_type(renamed.payload_json, '$.title') = 'text' + ) + `; +}); diff --git a/apps/web/src/hooks/useComposerSlashCommands.ts b/apps/web/src/hooks/useComposerSlashCommands.ts index 84bf8ab76..aeeb5216c 100644 --- a/apps/web/src/hooks/useComposerSlashCommands.ts +++ b/apps/web/src/hooks/useComposerSlashCommands.ts @@ -259,8 +259,13 @@ export function useComposerSlashCommands(input: { return true; } + const nextThreadId = newThreadId(); const importedMessages = inputOptions?.sourceMessageId - ? buildThreadForkImportedMessagesThrough(activeThread, inputOptions.sourceMessageId) + ? buildThreadForkImportedMessagesThrough( + activeThread, + inputOptions.sourceMessageId, + nextThreadId, + ) : buildThreadHandoffImportedMessages(activeThread); if (inputOptions?.sourceMessageId && importedMessages.length === 0) { toastManager.add({ @@ -271,7 +276,6 @@ export function useComposerSlashCommands(input: { return true; } - const nextThreadId = newThreadId(); const createdAt = new Date().toISOString(); // Fork first, then let the normal first-send worktree bootstrap create the cwd if needed. const resolvedTarget = resolveForkThreadEnvironment({ diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index 0260eb313..67f3ae8ef 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -1,4 +1,4 @@ -import { MessageId, type ModelSelection, TurnId } from "@synara/contracts"; +import { MessageId, type ModelSelection, ThreadId, TurnId } from "@synara/contracts"; import { describe, expect, it } from "vitest"; import { buildThreadForkImportedMessagesThrough, @@ -49,6 +49,7 @@ describe("threadHandoff", () => { ], }, firstAssistantId, + ThreadId.makeUnsafe("thread-fork-destination"), ); expect(imported).toHaveLength(2); @@ -74,9 +75,19 @@ describe("threadHandoff", () => { ], }; - expect(buildThreadForkImportedMessagesThrough(thread, streamingId)).toEqual([]); expect( - buildThreadForkImportedMessagesThrough(thread, MessageId.makeUnsafe("message-missing")), + buildThreadForkImportedMessagesThrough( + thread, + streamingId, + ThreadId.makeUnsafe("thread-fork-streaming"), + ), + ).toEqual([]); + expect( + buildThreadForkImportedMessagesThrough( + thread, + MessageId.makeUnsafe("message-missing"), + ThreadId.makeUnsafe("thread-fork-missing"), + ), ).toEqual([]); }); @@ -121,10 +132,97 @@ describe("threadHandoff", () => { session: null, }; - expect(buildThreadForkImportedMessagesThrough(thread, queuedUserId)).toEqual([]); + expect( + buildThreadForkImportedMessagesThrough( + thread, + queuedUserId, + ThreadId.makeUnsafe("thread-fork-queued"), + ), + ).toEqual([]); expect([...resolveThreadForkableMessageIds(thread)]).toEqual([priorUserId]); }); + it("fails closed for queued input before the running turn projects an assistant", () => { + const activeUserId = MessageId.makeUnsafe("message-active-user-before-assistant"); + const queuedUserId = MessageId.makeUnsafe("message-queued-before-assistant"); + const turnId = TurnId.makeUnsafe("turn-before-assistant"); + const thread = { + messages: [ + { + id: MessageId.makeUnsafe("message-prior-settled-user"), + role: "user" as const, + text: "Settled prompt", + createdAt: "2026-07-22T07:59:00.000Z", + streaming: false, + }, + { + id: activeUserId, + role: "user" as const, + text: "Prompt currently running", + createdAt: "2026-07-22T08:00:00.000Z", + streaming: false, + }, + { + id: queuedUserId, + role: "user" as const, + text: "Queued before an assistant row exists", + createdAt: "2026-07-22T08:00:01.000Z", + streaming: false, + }, + ], + latestTurn: { + turnId, + state: "running" as const, + requestedAt: "2026-07-22T08:00:00.000Z", + startedAt: "2026-07-22T08:00:00.500Z", + completedAt: null, + assistantMessageId: null, + }, + session: null, + }; + + expect( + buildThreadForkImportedMessagesThrough( + thread, + queuedUserId, + ThreadId.makeUnsafe("thread-fork-before-assistant"), + ), + ).toEqual([]); + expect([...resolveThreadForkableMessageIds(thread)]).toEqual([ + MessageId.makeUnsafe("message-prior-settled-user"), + activeUserId, + ]); + }); + + it("assigns equal-timestamp imports ids whose persisted sort preserves source order", () => { + const boundaryId = MessageId.makeUnsafe("message-equal-time-assistant"); + const imported = buildThreadForkImportedMessagesThrough( + { + messages: [ + { + id: MessageId.makeUnsafe("message-equal-time-user"), + role: "user", + text: "First", + createdAt: "2026-07-22T08:00:00.000Z", + streaming: false, + }, + { + id: boundaryId, + role: "assistant", + text: "Second", + createdAt: "2026-07-22T08:00:00.000Z", + streaming: false, + }, + ], + }, + boundaryId, + ThreadId.makeUnsafe("thread-fork-equal-time"), + ); + + expect(imported.map((message) => message.text)).toEqual(["First", "Second"]); + expect(imported[0]!.messageId < imported[1]!.messageId).toBe(true); + }); + it("treats an active-turn assistant as unsafe even before its streaming flag arrives", () => { const assistantId = MessageId.makeUnsafe("message-active-nonstreaming-assistant"); const turnId = TurnId.makeUnsafe("turn-active-nonstreaming-assistant"); @@ -150,7 +248,13 @@ describe("threadHandoff", () => { session: null, }; - expect(buildThreadForkImportedMessagesThrough(thread, assistantId)).toEqual([]); + expect( + buildThreadForkImportedMessagesThrough( + thread, + assistantId, + ThreadId.makeUnsafe("thread-fork-active"), + ), + ).toEqual([]); expect(resolveThreadForkableMessageIds(thread).size).toBe(0); }); @@ -189,6 +293,7 @@ describe("threadHandoff", () => { session: null, }, assistantId, + ThreadId.makeUnsafe("thread-fork-settled"), ); expect(imported.map(({ role, text }) => ({ role, text }))).toEqual([ @@ -233,6 +338,7 @@ describe("threadHandoff", () => { session: null, }, latestAssistantId, + ThreadId.makeUnsafe("thread-fork-settled-multiple"), ); expect(imported.map(({ text }) => text)).toEqual([ diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index 2ffdcd4ed..d9e515a9e 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -6,6 +6,7 @@ import { EventId, MessageId, + ThreadId, type OrchestrationThreadActivity, PROVIDER_DISPLAY_NAMES, type ModelSelection, @@ -46,6 +47,28 @@ function isImportableThreadMessage( type ForkSourceThread = Pick & Partial>; +function resolveRunningTurnBoundaryIndex(thread: ForkSourceThread): number | null { + if (!thread.latestTurn || isLatestTurnSettled(thread.latestTurn, thread.session ?? null)) { + return null; + } + + const activeAssistantIndex = thread.messages.findIndex( + (message) => message.role === "assistant" && message.turnId === thread.latestTurn?.turnId, + ); + if (activeAssistantIndex >= 0) { + return activeAssistantIndex; + } + + const requestedUserIndex = thread.messages.findIndex( + (message) => message.role === "user" && message.createdAt === thread.latestTurn?.requestedAt, + ); + if (requestedUserIndex >= 0) { + return requestedUserIndex + 1; + } + + return 0; +} + function isSettledTerminalTurnAssistantMessage( thread: ForkSourceThread, message: Thread["messages"][number], @@ -113,17 +136,18 @@ export function buildThreadHandoffImportedMessages( function buildImportedMessages( messages: ReadonlyArray, isImportable: (message: Thread["messages"][number]) => boolean = isImportableThreadMessage, + makeMessageId: (index: number) => MessageId = () => MessageId.makeUnsafe(randomUUID()), ): ReadonlyArray { return messages .filter( (message): message is Thread["messages"][number] & { role: "user" | "assistant" } => isImportable(message) && (message.role === "user" || message.role === "assistant"), ) - .map((message) => { + .map((message, index) => { const importedText = message.role === "user" ? stripEmbeddedAssistantSelections(message.text) : message.text; const importedMessage: ThreadHandoffImportedMessage = { - messageId: MessageId.makeUnsafe(randomUUID()), + messageId: makeMessageId(index), role: message.role, text: importedText, createdAt: message.createdAt, @@ -155,10 +179,16 @@ function buildImportedMessages( export function buildThreadForkImportedMessagesThrough( thread: ForkSourceThread, sourceMessageId: MessageId, + destinationThreadId: ThreadId, ): ReadonlyArray { const sourceMessageIndex = thread.messages.findIndex((message) => message.id === sourceMessageId); const sourceMessage = thread.messages[sourceMessageIndex]; - if (sourceMessageIndex < 0 || !sourceMessage) { + const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread); + if ( + sourceMessageIndex < 0 || + !sourceMessage || + (runningTurnBoundaryIndex !== null && sourceMessageIndex >= runningTurnBoundaryIndex) + ) { return []; } @@ -167,13 +197,22 @@ export function buildThreadForkImportedMessagesThrough( .filter((message) => message.role === "user" || message.role === "assistant"); if ( conversationPrefix.length === 0 || - conversationPrefix.some((message) => !isForkImportableThreadMessage(thread, message)) + conversationPrefix.some((message) => !isForkImportableThreadMessage(thread, message)) || + conversationPrefix.some((message, index, messages) => { + const previous = messages[index - 1]; + return previous !== undefined && previous.createdAt > message.createdAt; + }) ) { return []; } - return buildImportedMessages(conversationPrefix, (message) => - isForkImportableThreadMessage(thread, message), + return buildImportedMessages( + conversationPrefix, + (message) => isForkImportableThreadMessage(thread, message), + (index) => + MessageId.makeUnsafe( + `fork:${destinationThreadId}:${String(index).padStart(8, "0")}:${randomUUID()}`, + ), ); } @@ -185,12 +224,16 @@ export function buildThreadForkImportedMessagesThrough( export function resolveThreadForkableMessageIds(thread: ForkSourceThread): ReadonlySet { const forkableMessageIds = new Set(); let prefixIsImportable = true; + const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread); - for (const message of thread.messages) { + for (const [messageIndex, message] of thread.messages.entries()) { if (message.role !== "user" && message.role !== "assistant") { continue; } - prefixIsImportable = prefixIsImportable && isForkImportableThreadMessage(thread, message); + prefixIsImportable = + prefixIsImportable && + (runningTurnBoundaryIndex === null || messageIndex < runningTurnBoundaryIndex) && + isForkImportableThreadMessage(thread, message); if (prefixIsImportable) { forkableMessageIds.add(message.id); } From 654d008d2e7bab2ca83169afb3fffea33e4f110f Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 22:59:44 +0300 Subject: [PATCH 05/11] Resolve final fork recertification blockers --- .../Layers/ProjectionPipeline.test.ts | 103 ++++++ .../Layers/ProviderCommandReactor.test.ts | 335 ++++++++++++++++-- .../Layers/ProviderCommandReactor.ts | 70 +++- apps/server/src/orchestration/handoff.test.ts | 29 +- apps/server/src/orchestration/handoff.ts | 2 +- .../src/orchestration/messageFork.test.ts | 62 ++++ apps/server/src/orchestration/messageFork.ts | 13 +- .../057_ProjectionThreadsForkTitleSequence.ts | 43 ++- .../058_ClearRenamedForkTitleLineage.test.ts | 264 ++++++++++---- .../058_ClearRenamedForkTitleLineage.ts | 79 ++++- apps/web/src/lib/threadHandoff.test.ts | 60 ++++ apps/web/src/lib/threadHandoff.ts | 13 +- 12 files changed, 921 insertions(+), 152 deletions(-) diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts index 1df332861..e1375a01e 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts @@ -255,6 +255,109 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { }), ); + it.effect("keeps no-op fork titles but makes rename-away-and-back a replay-stable boundary", () => + Effect.gen(function* () { + const projectionPipeline = yield* OrchestrationProjectionPipeline; + const eventStore = yield* OrchestrationEventStore; + const sql = yield* SqlClient.SqlClient; + const projectId = ProjectId.makeUnsafe("project-fork-title-replay"); + const sameTitleThreadId = ThreadId.makeUnsafe("thread-fork-title-same"); + const renamedThreadId = ThreadId.makeUnsafe("thread-fork-title-renamed"); + const occurredAt = "2026-07-22T10:20:00.000Z"; + + for (const [threadId, suffix] of [ + [sameTitleThreadId, "same"], + [renamedThreadId, "renamed"], + ] as const) { + yield* eventStore.append({ + type: "thread.created", + eventId: EventId.makeUnsafe(`event-fork-title-${suffix}-created`), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt, + commandId: CommandId.makeUnsafe(`command-fork-title-${suffix}-created`), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe(`command-fork-title-${suffix}-created`), + metadata: {}, + payload: { + threadId, + projectId, + title: "Greeting (2)", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "full-access", + branch: null, + worktreePath: null, + forkSourceThreadId: ThreadId.makeUnsafe("thread-fork-title-root"), + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }); + } + + yield* eventStore.append({ + type: "thread.meta-updated", + eventId: EventId.makeUnsafe("event-fork-title-same-noop"), + aggregateKind: "thread", + aggregateId: sameTitleThreadId, + occurredAt, + commandId: CommandId.makeUnsafe("command-fork-title-same-noop"), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe("command-fork-title-same-noop"), + metadata: {}, + payload: { threadId: sameTitleThreadId, title: "Greeting (2)", updatedAt: occurredAt }, + }); + for (const [title, suffix] of [ + ["Experiment", "away"], + ["Greeting (2)", "back"], + ] as const) { + yield* eventStore.append({ + type: "thread.meta-updated", + eventId: EventId.makeUnsafe(`event-fork-title-renamed-${suffix}`), + aggregateKind: "thread", + aggregateId: renamedThreadId, + occurredAt, + commandId: CommandId.makeUnsafe(`command-fork-title-renamed-${suffix}`), + causationEventId: null, + correlationId: CorrelationId.makeUnsafe(`command-fork-title-renamed-${suffix}`), + metadata: {}, + payload: { threadId: renamedThreadId, title, updatedAt: occurredAt }, + }); + } + + const readRows = () => + sql<{ + readonly id: string; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; + }>` + SELECT + thread_id AS id, + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + WHERE thread_id IN (${sameTitleThreadId}, ${renamedThreadId}) + ORDER BY thread_id ASC + `; + const expectedRows = [ + { id: renamedThreadId, forkTitleBase: null, forkTitleOrdinal: null }, + { id: sameTitleThreadId, forkTitleBase: "Greeting", forkTitleOrdinal: 2 }, + ]; + + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readRows(), expectedRows); + + yield* sql`DELETE FROM projection_threads WHERE thread_id IN (${sameTitleThreadId}, ${renamedThreadId})`; + yield* sql` + DELETE FROM projection_state + WHERE projector = ${ORCHESTRATION_PROJECTOR_NAMES.threads} + `; + yield* projectionPipeline.bootstrap; + assert.deepEqual(yield* readRows(), expectedRows); + }), + ); + it.effect("persists turn-start thread settings into projection rows", () => Effect.gen(function* () { const projectionPipeline = yield* OrchestrationProjectionPipeline; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index c383fcae4..737acd29f 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -723,6 +723,20 @@ describe("ProviderCommandReactor", () => { }, }); const now = new Date().toISOString(); + const importedAttachments = Array.from({ length: 9 }, (_, index) => ({ + type: "file" as const, + id: `attachment-before-boundary-${index + 1}`, + name: `before-${index + 1}.txt`, + mimeType: "text/plain", + sizeBytes: index + 1, + })); + const currentAttachment = { + type: "file" as const, + id: "attachment-current-fork-turn", + name: "current.txt", + mimeType: "text/plain", + sizeBytes: 10, + }; await Effect.runPromise( harness.engine.dispatch({ @@ -734,15 +748,7 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("source-prefix-user"), role: "user", text: "Question before the branch", - attachments: [ - { - type: "file", - id: "attachment-before-boundary", - name: "before.txt", - mimeType: "text/plain", - sizeBytes: 12, - }, - ], + attachments: importedAttachments.slice(0, 8), createdAt: now, updatedAt: now, }, @@ -750,6 +756,7 @@ describe("ProviderCommandReactor", () => { messageId: sourceBoundaryId, role: "assistant", text: "Answer at the branch boundary", + attachments: importedAttachments.slice(8), createdAt: now, updatedAt: now, }, @@ -797,15 +804,7 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("fork-00000000-prefix-user"), role: "user", text: "Question before the branch", - attachments: [ - { - type: "file", - id: "attachment-before-boundary", - name: "before.txt", - mimeType: "text/plain", - sizeBytes: 12, - }, - ], + attachments: importedAttachments.slice(0, 8), createdAt: now, updatedAt: now, }, @@ -813,6 +812,7 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("fork-00000001-boundary-assistant"), role: "assistant", text: "Answer at the branch boundary", + attachments: importedAttachments.slice(8), createdAt: now, updatedAt: now, }, @@ -830,7 +830,7 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("message-boundary-fork-new-user"), role: "user", text: "Take this in a different direction", - attachments: [], + attachments: [currentAttachment], }, runtimeMode: "approval-required", interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, @@ -849,8 +849,43 @@ describe("ProviderCommandReactor", () => { expect(input?.input).toContain("Answer at the branch boundary"); expect(input?.input).not.toContain("This later source message must not be inherited"); expect(input?.input).toContain("Take this in a different direction"); + expect(input?.input).toContain("before-9.txt"); expect(input?.attachments?.map((attachment) => attachment.id)).toEqual([ - "attachment-before-boundary", + currentAttachment.id, + ...importedAttachments.slice(0, 7).map((attachment) => attachment.id), + ]); + expect(input?.attachments).toHaveLength(8); + + const followUpAttachment = { + type: "file" as const, + id: "attachment-after-bootstrap", + name: "follow-up.txt", + mimeType: "text/plain", + sizeBytes: 11, + }; + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-message-boundary-fork-follow-up"), + threadId: forkThreadId, + message: { + messageId: asMessageId("message-boundary-fork-follow-up-user"), + role: "user", + text: "Continue the fork", + attachments: [followUpAttachment], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: now, + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + const followUpInput = harness.sendTurn.mock.calls[1]?.[0] as + | { input?: string; attachments?: ReadonlyArray<{ id: string }> } + | undefined; + expect(followUpInput?.input).not.toContain(""); + expect(followUpInput?.attachments?.map((attachment) => attachment.id)).toEqual([ + followUpAttachment.id, ]); const readModel = await Effect.runPromise(harness.engine.getReadModel()); @@ -1571,17 +1606,53 @@ describe("ProviderCommandReactor", () => { }); const now = new Date().toISOString(); const importedAt = new Date(Date.parse(now) - 1_000).toISOString(); + const importedAssistantAt = new Date(Date.parse(importedAt) + 1).toISOString(); + const sourceBoundaryId = asMessageId("droid-async-bootstrap-source-boundary"); + const importedAttachment = { + type: "file" as const, + id: "droid-async-bootstrap-imported-file", + name: "retained-context.txt", + mimeType: "text/plain", + sizeBytes: 21, + }; harness.sendTurn .mockImplementationOnce(() => Effect.succeed({ threadId, turnId: firstTurnId })) .mockImplementationOnce(() => Effect.succeed({ threadId, turnId: retryTurnId })) .mockImplementationOnce(() => Effect.succeed({ threadId, turnId: followUpTurnId })); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-droid-async-bootstrap-source"), + threadId: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + messageId: asMessageId("droid-async-bootstrap-source-user"), + role: "user", + text: "Context retained across the failed prompt", + attachments: [importedAttachment], + createdAt: importedAt, + updatedAt: importedAt, + }, + { + messageId: sourceBoundaryId, + role: "assistant", + text: "Source answer at the fork boundary", + createdAt: importedAssistantAt, + updatedAt: importedAssistantAt, + }, + ], + createdAt: importedAt, + }), + ); + await Effect.runPromise( harness.engine.dispatch({ type: "thread.fork.create", commandId: CommandId.makeUnsafe("cmd-droid-async-bootstrap-fork"), threadId, sourceThreadId: ThreadId.makeUnsafe("thread-1"), + sourceMessageId: sourceBoundaryId, projectId: asProjectId("project-1"), title: "Droid async bootstrap failure", modelSelection: { @@ -1598,9 +1669,17 @@ describe("ProviderCommandReactor", () => { messageId: asMessageId("droid-async-bootstrap-imported-user"), role: "user", text: "Context retained across the failed prompt", + attachments: [importedAttachment], createdAt: importedAt, updatedAt: importedAt, }, + { + messageId: asMessageId("droid-async-bootstrap-imported-assistant"), + role: "assistant", + text: "Source answer at the fork boundary", + createdAt: importedAssistantAt, + updatedAt: importedAssistantAt, + }, ], createdAt: now, }), @@ -1623,8 +1702,13 @@ describe("ProviderCommandReactor", () => { }), ); await waitFor(() => harness.sendTurn.mock.calls.length === 1); - const firstInput = harness.sendTurn.mock.calls[0]?.[0] as { input?: string } | undefined; + const firstInput = harness.sendTurn.mock.calls[0]?.[0] as + | { input?: string; attachments?: ReadonlyArray<{ id: string }> } + | undefined; expect(firstInput?.input).toContain(""); + expect(firstInput?.attachments?.map((attachment) => attachment.id)).toEqual([ + importedAttachment.id, + ]); await harness.emitRuntimeEvent({ type: "turn.completed", @@ -1659,10 +1743,15 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.sendTurn.mock.calls.length === 2); - const retryInput = harness.sendTurn.mock.calls[1]?.[0] as { input?: string } | undefined; + const retryInput = harness.sendTurn.mock.calls[1]?.[0] as + | { input?: string; attachments?: ReadonlyArray<{ id: string }> } + | undefined; expect(retryInput?.input).toContain(""); expect(retryInput?.input).toContain("Context retained across the failed prompt"); expect(retryInput?.input).toContain("Retry after async failure"); + expect(retryInput?.attachments?.map((attachment) => attachment.id)).toEqual([ + importedAttachment.id, + ]); await harness.emitRuntimeEvent({ type: "turn.completed", @@ -1696,8 +1785,11 @@ describe("ProviderCommandReactor", () => { ); await waitFor(() => harness.sendTurn.mock.calls.length === 3); - const followUpInput = harness.sendTurn.mock.calls[2]?.[0] as { input?: string } | undefined; + const followUpInput = harness.sendTurn.mock.calls[2]?.[0] as + | { input?: string; attachments?: ReadonlyArray<{ id: string }> } + | undefined; expect(followUpInput?.input).not.toContain(""); + expect(followUpInput?.attachments).toBeUndefined(); expectSkillAwareProviderInput(followUpInput?.input, "Continue after successful retry"); }); @@ -1968,6 +2060,203 @@ describe("ProviderCommandReactor", () => { expect(resent?.input).not.toContain("old prompt"); }); + it("replays retained native context after rollback in a message fork without imported files", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "droid", model: "claude-opus-4-8" }, + conversationRollback: "restart-session", + }); + const forkThreadId = ThreadId.makeUnsafe("thread-message-fork-later-rollback"); + const sourceBoundaryId = asMessageId("message-fork-later-rollback-source-assistant"); + const importedFile = { + type: "file" as const, + id: "message-fork-later-rollback-imported-file", + name: "source-only.txt", + mimeType: "text/plain", + sizeBytes: 17, + }; + const sourceUserAt = "2026-07-22T10:10:00.000Z"; + const sourceAssistantAt = "2026-07-22T10:10:01.000Z"; + const nativeAt = "2026-07-22T10:11:00.000Z"; + const targetAt = "2026-07-22T10:12:00.000Z"; + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-message-fork-later-rollback-source"), + threadId: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + messageId: asMessageId("message-fork-later-rollback-source-user"), + role: "user", + text: "Imported source question", + attachments: [importedFile], + createdAt: sourceUserAt, + updatedAt: sourceUserAt, + }, + { + messageId: sourceBoundaryId, + role: "assistant", + text: "Imported source answer", + createdAt: sourceAssistantAt, + updatedAt: sourceAssistantAt, + }, + ], + createdAt: sourceUserAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-message-fork-later-rollback-create"), + threadId: forkThreadId, + sourceThreadId: ThreadId.makeUnsafe("thread-1"), + sourceMessageId: sourceBoundaryId, + projectId: asProjectId("project-1"), + title: "Message fork later rollback", + modelSelection: { provider: "droid", model: "claude-opus-4-8" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("fork-00000000-later-rollback-user"), + role: "user", + text: "Imported source question", + attachments: [importedFile], + createdAt: sourceUserAt, + updatedAt: sourceUserAt, + }, + { + messageId: asMessageId("fork-00000001-later-rollback-assistant"), + role: "assistant", + text: "Imported source answer", + createdAt: sourceAssistantAt, + updatedAt: sourceAssistantAt, + }, + ], + createdAt: sourceAssistantAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-message-fork-retained-native-turn"), + threadId: forkThreadId, + message: { + messageId: asMessageId("message-fork-retained-native-user"), + role: "user", + text: "Retained native question", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: nativeAt, + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + await harness.emitRuntimeEvent({ + type: "turn.completed", + eventId: asEventId("evt-message-fork-initial-bootstrap-completed"), + provider: "droid", + threadId: forkThreadId, + createdAt: nativeAt, + turnId: asTurnId("turn-1"), + payload: { state: "completed" }, + providerRefs: {}, + } as ProviderRuntimeEvent); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.message.assistant.delta", + commandId: CommandId.makeUnsafe("cmd-message-fork-retained-native-assistant-delta"), + threadId: forkThreadId, + messageId: asMessageId("message-fork-retained-native-assistant"), + delta: "Retained native answer", + turnId: asTurnId("turn-message-fork-retained-native"), + createdAt: nativeAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.message.assistant.complete", + commandId: CommandId.makeUnsafe("cmd-message-fork-retained-native-assistant-complete"), + threadId: forkThreadId, + messageId: asMessageId("message-fork-retained-native-assistant"), + turnId: asTurnId("turn-message-fork-retained-native"), + createdAt: nativeAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-message-fork-later-edit-target"), + threadId: forkThreadId, + message: { + messageId: asMessageId("message-fork-later-edit-target"), + role: "user", + text: "old later prompt", + attachments: [], + }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: targetAt, + }), + ); + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + harness.sendTurn.mockClear(); + harness.setRuntimeSessionTurnState({ + threadId: forkThreadId, + status: "running", + activeTurnId: asTurnId("turn-message-fork-later-edit-active"), + }); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.session.set", + commandId: CommandId.makeUnsafe("cmd-message-fork-later-edit-session"), + threadId: forkThreadId, + session: { + threadId: forkThreadId, + status: "running", + providerName: "droid", + runtimeMode: "approval-required", + activeTurnId: asTurnId("turn-message-fork-later-edit-active"), + lastError: null, + updatedAt: targetAt, + }, + createdAt: targetAt, + }), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.message.edit-and-resend", + commandId: CommandId.makeUnsafe("cmd-message-fork-later-edit-resend"), + threadId: forkThreadId, + messageId: asMessageId("message-fork-later-edit-target"), + text: "edited later prompt", + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: targetAt, + }), + ); + + await waitFor(() => harness.clearSessionResumeCursor.mock.calls.length === 1); + await waitFor(() => harness.sendTurn.mock.calls.length === 1); + const resent = harness.sendTurn.mock.calls[0]?.[0] as + | { input?: string; attachments?: ReadonlyArray<{ id: string }> } + | undefined; + expect(resent?.input).toContain(""); + expect(resent?.input).toContain("Imported source question"); + expect(resent?.input).toContain("Retained native question"); + expect(resent?.input).toContain("Retained native answer"); + expect(resent?.input).toContain("edited later prompt"); + expect(resent?.input).not.toContain("old later prompt"); + expect(resent?.attachments).toBeUndefined(); + }); + it("keeps queued-message edits queued while an active provider turn continues", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 3ce5966f2..6719ade3d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -10,6 +10,7 @@ import { type ModelSelection, MessageId, type OrchestrationEvent, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_INPUT_CHARS, type ProviderMentionReference, type ProviderRuntimeEvent, @@ -145,6 +146,24 @@ function attachmentTitleSeed(attachment: ChatAttachment | undefined): string { return attachment.text.trim(); } +function isInitialMessageForkContextBootstrap( + thread: Pick, + currentMessageId: string, +): boolean { + if (thread.forkSourceMessageId == null) { + return false; + } + const currentMessageIndex = thread.messages.findIndex( + (message) => message.id === currentMessageId, + ); + if (currentMessageIndex < 0) { + return false; + } + return !thread.messages.slice(0, currentMessageIndex).some((message) => { + return message.role === "assistant" && message.source === "native"; + }); +} + function mapProviderSessionStatusToOrchestrationStatus( status: "connecting" | "ready" | "running" | "error" | "closed", ): OrchestrationSession["status"] { @@ -374,6 +393,10 @@ const make = Effect.gen(function* () { // Fresh sessions that cannot inherit native conversation state need one // transcript bootstrap (fork fallbacks and non-resumable Droid model changes). const freshSessionContextBootstrapThreadIds = new Set(); + // Message-boundary forks attach imported physical files only while their + // initial fresh-session bootstrap is pending. Later rollback/session restart + // replays the retained native transcript without resending those files. + const initialMessageForkContextBootstrapThreadIds = new Set(); // Providers without native rewind restart after rollback and receive the // retained projection transcript once on their next prompt. const rollbackContextBootstrapThreadIds = new Set(); @@ -390,6 +413,7 @@ const make = Effect.gen(function* () { const clearPendingContextBootstraps = (threadId: string) => { sidechatContextBootstrapThreadIds.delete(threadId); freshSessionContextBootstrapThreadIds.delete(threadId); + initialMessageForkContextBootstrapThreadIds.delete(threadId); rollbackContextBootstrapThreadIds.delete(threadId); pendingContextBootstrapAttempts.delete(threadId); }; @@ -410,6 +434,7 @@ const make = Effect.gen(function* () { } if (attempt.clearPriorTranscript) { freshSessionContextBootstrapThreadIds.delete(threadId); + initialMessageForkContextBootstrapThreadIds.delete(threadId); rollbackContextBootstrapThreadIds.delete(threadId); sidechatContextBootstrapThreadIds.delete(threadId); } @@ -796,6 +821,7 @@ const make = Effect.gen(function* () { readonly modelSelection?: ModelSelection; readonly providerOptions?: ProviderStartOptions; readonly runtimeMode?: RuntimeMode; + readonly initialMessageForkContextBootstrap?: boolean; }, ) { const thread = yield* resolveThread(threadId); @@ -807,6 +833,16 @@ const make = Effect.gen(function* () { const shouldRegisterContextBootstrap = thread.session?.status !== "stopped" && !suppressContextBootstrapOnNextStartThreadIds.has(threadId); + const registerInitialMessageForkContextBootstrap = () => { + if ( + shouldRegisterContextBootstrap && + options?.initialMessageForkContextBootstrap === true && + thread.forkSourceMessageId != null + ) { + freshSessionContextBootstrapThreadIds.add(threadId); + initialMessageForkContextBootstrapThreadIds.add(threadId); + } + }; const desiredRuntimeMode = options?.runtimeMode ?? thread.runtimeMode; const currentProvider: ProviderKind | undefined = Schema.is(ProviderKind)( @@ -918,6 +954,7 @@ const make = Effect.gen(function* () { !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { + registerInitialMessageForkContextBootstrap(); return existingSessionThreadId; } @@ -950,6 +987,7 @@ const make = Effect.gen(function* () { ) { freshSessionContextBootstrapThreadIds.add(threadId); } + registerInitialMessageForkContextBootstrap(); threadSessionModelSelections.set(threadId, desiredModelSelection); yield* Effect.logInfo("provider command reactor restarted provider session", { threadId, @@ -1015,6 +1053,7 @@ const make = Effect.gen(function* () { // fork must instead start fresh and bootstrap only the persisted imported prefix, or // messages after the selected boundary could silently leak into the new conversation. freshSessionContextBootstrapThreadIds.add(threadId); + registerInitialMessageForkContextBootstrap(); } if ( @@ -1063,6 +1102,10 @@ const make = Effect.gen(function* () { ...(input.modelSelection !== undefined ? { modelSelection: input.modelSelection } : {}), ...(input.providerOptions !== undefined ? { providerOptions: input.providerOptions } : {}), ...(input.runtimeMode !== undefined ? { runtimeMode: input.runtimeMode } : {}), + initialMessageForkContextBootstrap: isInitialMessageForkContextBootstrap( + thread, + input.messageId, + ), }); if (input.providerOptions !== undefined) { threadProviderOptions.set(input.threadId, input.providerOptions); @@ -1105,7 +1148,8 @@ const make = Effect.gen(function* () { freshSessionContextBootstrapThreadIds.has(input.threadId) || rollbackContextBootstrapThreadIds.has(input.threadId); const isPendingMessageBoundaryForkBootstrap = - hasPendingPriorTranscriptBootstrap && thread.forkSourceMessageId != null; + hasPendingPriorTranscriptBootstrap && + initialMessageForkContextBootstrapThreadIds.has(input.threadId); const shouldBootstrapSidechatContext = thread.sidechatSourceThreadId !== null && sidechatContextBootstrapThreadIds.has(input.threadId) && @@ -1246,13 +1290,24 @@ const make = Effect.gen(function* () { ...(input.skills !== undefined ? { skills: input.skills } : {}), }), ); - const normalizedAttachments = [ - ...(isPendingMessageBoundaryForkBootstrap ? listImportedForkProviderAttachments(thread) : []), - ...(input.attachments ?? []), - ].filter( + const currentAttachments = (input.attachments ?? []).filter( (attachment, index, attachments) => attachments.findIndex((candidate) => candidate.id === attachment.id) === index, ); + const currentAttachmentIds = new Set(currentAttachments.map((attachment) => attachment.id)); + const importedAttachmentBudget = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_ATTACHMENTS - currentAttachments.length, + ); + const importedBootstrapAttachments = isPendingMessageBoundaryForkBootstrap + ? listImportedForkProviderAttachments(thread) + .filter((attachment) => !currentAttachmentIds.has(attachment.id)) + .slice(0, importedAttachmentBudget) + : []; + // Keep the user's current prompt complete, then fill the remaining provider + // slots deterministically from the imported prefix. All omitted file names + // remain visible in the serialized transcript bootstrap. + const normalizedAttachments = [...currentAttachments, ...importedBootstrapAttachments]; const activeSession = yield* providerService .listSessions() .pipe( @@ -1386,6 +1441,10 @@ const make = Effect.gen(function* () { ? { providerOptions: input.providerOptions } : {}), ...(input.runtimeMode !== undefined ? { runtimeMode: input.runtimeMode } : {}), + initialMessageForkContextBootstrap: isInitialMessageForkContextBootstrap( + thread, + input.messageId, + ), }); const retryBootstrapText = @@ -1484,6 +1543,7 @@ const make = Effect.gen(function* () { (priorTranscriptBootstrapText !== null || !hasPriorTranscriptBootstrapContent) ) { freshSessionContextBootstrapThreadIds.delete(input.threadId); + initialMessageForkContextBootstrapThreadIds.delete(input.threadId); rollbackContextBootstrapThreadIds.delete(input.threadId); sidechatContextBootstrapThreadIds.delete(input.threadId); } diff --git a/apps/server/src/orchestration/handoff.test.ts b/apps/server/src/orchestration/handoff.test.ts index b76c3c419..705d900b7 100644 --- a/apps/server/src/orchestration/handoff.test.ts +++ b/apps/server/src/orchestration/handoff.test.ts @@ -6,7 +6,12 @@ import { MessageId, ThreadId, type OrchestrationMessage } from "@synara/contracts"; import { describe, expect, it } from "vitest"; -import { buildHandoffBootstrapText, buildPriorTranscriptBootstrapText } from "./handoff.ts"; +import { + buildHandoffBootstrapText, + buildMessageForkBootstrapText, + buildPriorTranscriptBootstrapText, + listImportedForkProviderAttachments, +} from "./handoff.ts"; const message = ( index: number, @@ -101,4 +106,26 @@ describe("transcript bootstrap budgets", () => { expect(text!.length).toBeLessThanOrEqual(90_000); expect(text).toContain("handoff-179"); }); + + it("serializes assistant selections as context without treating them as provider files", () => { + const selectedMessage: OrchestrationMessage = { + ...message(1, "user", "Rewrite this", "native"), + source: "fork-import", + attachments: [ + { + type: "assistant-selection", + id: "selection-critical-excerpt", + assistantMessageId: MessageId.makeUnsafe("selected-assistant-message"), + text: "critical selected excerpt", + }, + ], + }; + const selectedThread = thread([selectedMessage]); + + const text = buildMessageForkBootstrapText(selectedThread); + + expect(text).toContain("Rewrite this"); + expect(text).toContain("critical selected excerpt"); + expect(listImportedForkProviderAttachments(selectedThread)).toEqual([]); + }); }); diff --git a/apps/server/src/orchestration/handoff.ts b/apps/server/src/orchestration/handoff.ts index 4b90a8154..7e21ae3bc 100644 --- a/apps/server/src/orchestration/handoff.ts +++ b/apps/server/src/orchestration/handoff.ts @@ -36,7 +36,7 @@ function bootstrapMessageContent(message: OrchestrationMessage): string { const text = normalizeMessageText(message.text); const attachmentLines = (message.attachments ?? []).flatMap((attachment) => attachment.type === "assistant-selection" - ? [] + ? [`[Selected assistant text: ${normalizeMessageText(attachment.text)}]`] : [`[Attachment: ${attachment.name} (${attachment.mimeType})]`], ); return [text, ...attachmentLines].filter((part) => part.length > 0).join("\n"); diff --git a/apps/server/src/orchestration/messageFork.test.ts b/apps/server/src/orchestration/messageFork.test.ts index b23c04efb..cd9b440f5 100644 --- a/apps/server/src/orchestration/messageFork.test.ts +++ b/apps/server/src/orchestration/messageFork.test.ts @@ -209,6 +209,68 @@ describe("validateMessageForkImport", () => { }); }); + it("rejects queued input projected before the active assistant row", () => { + const activeCreatedAt = "2026-07-22T10:02:00.000Z"; + const activeTurnId = TurnId.makeUnsafe("turn-assistant-after-queue"); + const queuedUserId = MessageId.makeUnsafe("message-queue-before-active-assistant"); + const activeAssistantId = MessageId.makeUnsafe("message-active-assistant-after-queue"); + const messages: OrchestrationMessage[] = [ + sourceMessages[0]!, + { + id: MessageId.makeUnsafe("message-active-user-before-queue"), + role: "user", + text: "Prompt currently running", + turnId: null, + streaming: false, + source: "native", + createdAt: activeCreatedAt, + updatedAt: activeCreatedAt, + }, + { + id: queuedUserId, + role: "user", + text: "Queued before the delayed assistant row", + turnId: null, + streaming: false, + source: "native", + createdAt: "2026-07-22T10:02:01.000Z", + updatedAt: "2026-07-22T10:02:01.000Z", + }, + { + id: activeAssistantId, + role: "assistant", + text: "Delayed active assistant", + turnId: activeTurnId, + streaming: true, + source: "native", + createdAt: "2026-07-22T10:02:02.000Z", + updatedAt: "2026-07-22T10:02:02.000Z", + }, + ]; + + expect( + validateMessageForkImport({ + sourceThread: { + messages, + latestTurn: { + turnId: activeTurnId, + state: "running", + requestedAt: activeCreatedAt, + startedAt: activeCreatedAt, + completedAt: null, + assistantMessageId: activeAssistantId, + }, + }, + sourceMessageId: queuedUserId, + importedMessages: [], + }), + ).toEqual({ + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }); + }); + it("rejects equal-timestamp ids whose persisted order would differ from the prefix", () => { const equalTimestampThread = { messages: [ diff --git a/apps/server/src/orchestration/messageFork.ts b/apps/server/src/orchestration/messageFork.ts index 120845723..903fb064a 100644 --- a/apps/server/src/orchestration/messageFork.ts +++ b/apps/server/src/orchestration/messageFork.ts @@ -36,15 +36,16 @@ function resolveRunningTurnBoundaryIndex(thread: MessageForkSourceThread): numbe const activeAssistantIndex = thread.messages.findIndex( (message) => message.role === "assistant" && message.turnId === thread.latestTurn?.turnId, ); - if (activeAssistantIndex >= 0) { - return activeAssistantIndex; - } - const requestedUserIndex = thread.messages.findIndex( (message) => message.role === "user" && message.createdAt === thread.latestTurn?.requestedAt, ); - if (requestedUserIndex >= 0) { - return requestedUserIndex + 1; + + const knownUnsafeIndexes = [ + ...(activeAssistantIndex >= 0 ? [activeAssistantIndex] : []), + ...(requestedUserIndex >= 0 ? [requestedUserIndex + 1] : []), + ]; + if (knownUnsafeIndexes.length > 0) { + return Math.min(...knownUnsafeIndexes); } // A running turn without a projected prompt or assistant is an incomplete diff --git a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts index ee789364c..8ec9eb9a7 100644 --- a/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts +++ b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts @@ -17,6 +17,7 @@ interface ThreadCreatedRow { readonly forkTitleBase: string | null; readonly forkTitleOrdinal: number | null; readonly currentProjectionTitle?: string | null; + readonly hasHistoricalTitleChange?: number; } const familyRootId = ( @@ -26,7 +27,7 @@ const familyRootId = ( let current = row; const visited = new Set(); - while (current.forkSourceThreadId) { + while (current.forkSourceThreadId && current.hasHistoricalTitleChange !== 1) { if (visited.has(current.threadId)) { return [...visited, current.threadId].toSorted()[0] ?? current.threadId; } @@ -59,23 +60,34 @@ export default Effect.gen(function* () { const createdRows = yield* sql` SELECT - sequence, - stream_id AS "threadId", - json_extract(payload_json, '$.projectId') AS "projectId", - json_extract(payload_json, '$.title') AS title, - json_extract(payload_json, '$.forkSourceThreadId') AS "forkSourceThreadId", - json_extract(payload_json, '$.sidechatSourceThreadId') AS "sidechatSourceThreadId", - json_extract(payload_json, '$.forkTitleBase') AS "forkTitleBase", - json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal", + created_event.sequence, + created_event.stream_id AS "threadId", + json_extract(created_event.payload_json, '$.projectId') AS "projectId", + json_extract(created_event.payload_json, '$.title') AS title, + json_extract(created_event.payload_json, '$.forkSourceThreadId') AS "forkSourceThreadId", + json_extract(created_event.payload_json, '$.sidechatSourceThreadId') AS "sidechatSourceThreadId", + json_extract(created_event.payload_json, '$.forkTitleBase') AS "forkTitleBase", + json_extract(created_event.payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal", ( SELECT projection_threads.title FROM projection_threads - WHERE projection_threads.thread_id = orchestration_events.stream_id - ) AS "currentProjectionTitle" - FROM orchestration_events - WHERE event_type = 'thread.created' - AND json_valid(payload_json) - ORDER BY sequence ASC + WHERE projection_threads.thread_id = created_event.stream_id + ) AS "currentProjectionTitle", + EXISTS ( + SELECT 1 + FROM orchestration_events AS renamed_event + WHERE renamed_event.stream_id = created_event.stream_id + AND renamed_event.sequence > created_event.sequence + AND renamed_event.event_type = 'thread.meta-updated' + AND json_valid(renamed_event.payload_json) + AND json_type(renamed_event.payload_json, '$.title') = 'text' + AND json_extract(renamed_event.payload_json, '$.title') + <> json_extract(created_event.payload_json, '$.title') + ) AS "hasHistoricalTitleChange" + FROM orchestration_events AS created_event + WHERE created_event.event_type = 'thread.created' + AND json_valid(created_event.payload_json) + ORDER BY created_event.sequence ASC `; const rowsByThreadId = new Map(createdRows.map((row) => [row.threadId, row])); const highestOrdinalBySeries = new Map(); @@ -86,6 +98,7 @@ export default Effect.gen(function* () { !row.title || !row.forkSourceThreadId || row.sidechatSourceThreadId || + row.hasHistoricalTitleChange === 1 || (row.currentProjectionTitle !== null && row.currentProjectionTitle !== undefined && row.currentProjectionTitle !== row.title) diff --git a/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts index 662e5c427..bdc1190c5 100644 --- a/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts +++ b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts @@ -5,81 +5,111 @@ import { describe } from "vitest"; import { runMigrations } from "../Migrations.ts"; import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import { resolveNextForkTitle, type ForkTitleThread } from "../../orchestration/forkTitle.ts"; + +interface SeedThread { + readonly id: string; + readonly title: string; + readonly sourceId: string; + readonly ordinal: number; +} describe("058_ClearRenamedForkTitleLineage", () => { - it.effect("clears backfilled lineage after a durable title update", () => + it.effect("reconciles historical rename boundaries and keeps event replay equivalent", () => Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; yield* runMigrations({ toMigrationInclusive: 57 }); const createdAt = "2026-07-22T10:00:00.000Z"; - yield* sql` - INSERT INTO projection_threads ( - thread_id, - project_id, - title, - model_selection_json, - runtime_mode, - interaction_mode, - env_mode, - create_branch_flow_completed, - is_pinned, - fork_source_thread_id, - fork_title_base, - fork_title_ordinal, - pending_approval_count, - pending_user_input_count, - has_actionable_proposed_plan, - created_at, - updated_at - ) - VALUES ( - 'renamed-fork', - 'project-1', - 'Greeting (2)', - '{"provider":"codex","model":"gpt-5-codex"}', - 'approval-required', - 'default', - 'local', - 0, - 0, - 'root', - 'Greeting', - 2, - 0, - 0, - 0, - ${createdAt}, - ${createdAt} - ) - `; + const insertThread = (thread: SeedThread) => + Effect.gen(function* () { + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + env_mode, + create_branch_flow_completed, + is_pinned, + fork_source_thread_id, + fork_title_base, + fork_title_ordinal, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at + ) + VALUES ( + ${thread.id}, + 'project-1', + ${thread.title}, + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'local', + 0, + 0, + ${thread.sourceId}, + ${thread.title}, + ${thread.ordinal}, + 0, + 0, + 0, + ${createdAt}, + ${createdAt} + ) + `; + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${`event-created-${thread.id}`}, + 'thread', + ${thread.id}, + 1, + 'thread.created', + ${createdAt}, + ${`command-created-${thread.id}`}, + NULL, + ${`command-created-${thread.id}`}, + 'client', + ${JSON.stringify({ + threadId: thread.id, + projectId: "project-1", + title: thread.title, + forkSourceThreadId: thread.sourceId, + forkTitleBase: thread.title, + forkTitleOrdinal: thread.ordinal, + createdAt, + updatedAt: createdAt, + })}, + '{}' + ) + `; + }); - for (const [eventType, payload, version] of [ - [ - "thread.created", - { - threadId: "renamed-fork", - projectId: "project-1", - title: "Greeting (2)", - forkSourceThreadId: "root", - forkTitleBase: "Greeting", - forkTitleOrdinal: 2, - createdAt, - updatedAt: createdAt, - }, - 1, - ], - [ - "thread.meta-updated", - { - threadId: "renamed-fork", - title: "Greeting (2)", - updatedAt: "2026-07-22T10:01:00.000Z", - }, - 2, - ], - ] as const) { - yield* sql` + const insertTitleEvent = (input: { + readonly threadId: string; + readonly version: number; + readonly title: string; + }) => + sql` INSERT INTO orchestration_events ( event_id, aggregate_kind, @@ -95,34 +125,112 @@ describe("058_ClearRenamedForkTitleLineage", () => { metadata_json ) VALUES ( - ${`event-${version}`}, + ${`event-title-${input.threadId}-${input.version}`}, 'thread', - 'renamed-fork', - ${version}, - ${eventType}, + ${input.threadId}, + ${input.version}, + 'thread.meta-updated', ${createdAt}, - ${`command-${version}`}, + ${`command-title-${input.threadId}-${input.version}`}, NULL, - ${`command-${version}`}, + ${`command-title-${input.threadId}-${input.version}`}, 'client', - ${JSON.stringify(payload)}, + ${JSON.stringify({ + threadId: input.threadId, + title: input.title, + updatedAt: createdAt, + })}, '{}' ) `; - } + + // This emulates metadata written by the earlier migration 057. The + // parent was renamed away and back; its child was therefore incorrectly + // numbered in the original root family instead of the parent's new one. + yield* insertThread({ + id: "renamed-parent", + title: "Greeting", + sourceId: "root", + ordinal: 2, + }); + yield* insertTitleEvent({ threadId: "renamed-parent", version: 2, title: "Experiment" }); + yield* insertTitleEvent({ threadId: "renamed-parent", version: 3, title: "Greeting" }); + yield* insertThread({ + id: "renamed-parent-child", + title: "Greeting", + sourceId: "renamed-parent", + ordinal: 3, + }); + + // A no-op title event is not a manual rename and must remain in its + // automatic family after migration and after replay from event truth. + yield* insertThread({ + id: "same-title", + title: "Greeting", + sourceId: "other-root", + ordinal: 2, + }); + yield* insertTitleEvent({ threadId: "same-title", version: 2, title: "Greeting" }); yield* runMigrations(); - const rows = yield* sql<{ + yield* runMigrations(); + + const projectionRows = yield* sql<{ + readonly id: string; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; + }>` + SELECT + thread_id AS id, + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + ORDER BY thread_id ASC + `; + assert.deepEqual(projectionRows, [ + { id: "renamed-parent", forkTitleBase: null, forkTitleOrdinal: null }, + { + id: "renamed-parent-child", + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }, + { id: "same-title", forkTitleBase: "Greeting", forkTitleOrdinal: 2 }, + ]); + + const createdEvents = yield* sql<{ + readonly id: string; readonly forkTitleBase: string | null; readonly forkTitleOrdinal: number | null; }>` SELECT + stream_id AS id, + json_extract(payload_json, '$.forkTitleBase') AS "forkTitleBase", + json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal" + FROM orchestration_events + WHERE event_type = 'thread.created' + ORDER BY stream_id ASC + `; + assert.deepEqual(createdEvents, projectionRows); + + const allocatorRows = yield* sql` + SELECT + thread_id AS id, + project_id AS "projectId", + title, + fork_source_thread_id AS "forkSourceThreadId", + sidechat_source_thread_id AS "sidechatSourceThreadId", fork_title_base AS "forkTitleBase", fork_title_ordinal AS "forkTitleOrdinal" FROM projection_threads - WHERE thread_id = 'renamed-fork' `; - assert.deepEqual(rows, [{ forkTitleBase: null, forkTitleOrdinal: null }]); + const renamedParent = allocatorRows.find((row) => row.id === "renamed-parent"); + if (!renamedParent) { + throw new Error("Expected renamed parent after migration"); + } + assert.strictEqual( + resolveNextForkTitle({ sourceThread: renamedParent, threads: allocatorRows }).title, + "Greeting (3)", + ); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); }); diff --git a/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts index 93827b0c2..8be4af177 100644 --- a/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts +++ b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts @@ -5,32 +5,77 @@ import * as Effect from "effect/Effect"; import * as SqlClient from "effect/unstable/sql/SqlClient"; +import reconcileForkTitleSequence from "./057_ProjectionThreadsForkTitleSequence.ts"; + export default Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; // Migration 057 may already have backfilled automatic-title metadata before - // this build learned that any subsequent title update permanently starts a - // new family. Clear the read-model metadata when the event history proves a - // title update occurred after creation. Replay reaches the same state because - // the in-memory and durable projectors now clear these fields on that event. + // it learned about historical rename boundaries. Reset both its recognizable + // legacy backfills (created title equals stored base) and any genuinely renamed + // thread in projection and event truth, then rerun the boundary-aware allocator. + // Updating the creation event keeps a later projection rebuild replay-equivalent. yield* sql` UPDATE projection_threads SET fork_title_base = NULL, fork_title_ordinal = NULL - WHERE fork_title_base IS NOT NULL - AND fork_title_ordinal IS NOT NULL - AND EXISTS ( - SELECT 1 - FROM orchestration_events AS created - JOIN orchestration_events AS renamed - ON renamed.stream_id = created.stream_id - AND renamed.sequence > created.sequence - WHERE created.event_type = 'thread.created' - AND renamed.event_type = 'thread.meta-updated' - AND created.stream_id = projection_threads.thread_id - AND json_valid(renamed.payload_json) - AND json_type(renamed.payload_json, '$.title') = 'text' + WHERE thread_id IN ( + SELECT created.stream_id + FROM orchestration_events AS created + WHERE created.event_type = 'thread.created' + AND json_valid(created.payload_json) + AND ( + ( + json_type(created.payload_json, '$.forkTitleBase') = 'text' + AND json_extract(created.payload_json, '$.title') + = json_extract(created.payload_json, '$.forkTitleBase') + ) + OR EXISTS ( + SELECT 1 + FROM orchestration_events AS renamed + WHERE renamed.stream_id = created.stream_id + AND renamed.sequence > created.sequence + AND renamed.event_type = 'thread.meta-updated' + AND json_valid(renamed.payload_json) + AND json_type(renamed.payload_json, '$.title') = 'text' + AND json_extract(renamed.payload_json, '$.title') + <> json_extract(created.payload_json, '$.title') + ) + ) + ) + `; + + yield* sql` + UPDATE orchestration_events AS created + SET payload_json = json_set( + created.payload_json, + '$.forkTitleBase', + NULL, + '$.forkTitleOrdinal', + NULL + ) + WHERE created.event_type = 'thread.created' + AND json_valid(created.payload_json) + AND ( + ( + json_type(created.payload_json, '$.forkTitleBase') = 'text' + AND json_extract(created.payload_json, '$.title') + = json_extract(created.payload_json, '$.forkTitleBase') + ) + OR EXISTS ( + SELECT 1 + FROM orchestration_events AS renamed + WHERE renamed.stream_id = created.stream_id + AND renamed.sequence > created.sequence + AND renamed.event_type = 'thread.meta-updated' + AND json_valid(renamed.payload_json) + AND json_type(renamed.payload_json, '$.title') = 'text' + AND json_extract(renamed.payload_json, '$.title') + <> json_extract(created.payload_json, '$.title') + ) ) `; + + yield* reconcileForkTitleSequence; }); diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts index 67f3ae8ef..2ac5e7b15 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -194,6 +194,66 @@ describe("threadHandoff", () => { ]); }); + it("uses the earliest unsafe boundary when the active assistant projects after queued input", () => { + const priorUserId = MessageId.makeUnsafe("message-prior-before-interleaving"); + const activeUserId = MessageId.makeUnsafe("message-active-before-interleaving"); + const queuedUserId = MessageId.makeUnsafe("message-queued-before-active-assistant"); + const activeAssistantId = MessageId.makeUnsafe("message-active-assistant-after-queued"); + const activeTurnId = TurnId.makeUnsafe("turn-active-assistant-after-queued"); + const requestedAt = "2026-07-22T08:02:00.000Z"; + const thread = { + messages: [ + { + id: priorUserId, + role: "user" as const, + text: "Settled prompt", + createdAt: "2026-07-22T08:01:00.000Z", + streaming: false, + }, + { + id: activeUserId, + role: "user" as const, + text: "Active prompt", + createdAt: requestedAt, + streaming: false, + }, + { + id: queuedUserId, + role: "user" as const, + text: "Queued prompt", + createdAt: "2026-07-22T08:02:01.000Z", + streaming: false, + }, + { + id: activeAssistantId, + role: "assistant" as const, + text: "Delayed active response", + turnId: activeTurnId, + createdAt: "2026-07-22T08:02:02.000Z", + streaming: true, + }, + ], + latestTurn: { + turnId: activeTurnId, + state: "running" as const, + requestedAt, + startedAt: requestedAt, + completedAt: null, + assistantMessageId: activeAssistantId, + }, + session: null, + }; + + expect( + buildThreadForkImportedMessagesThrough( + thread, + queuedUserId, + ThreadId.makeUnsafe("thread-fork-interleaved-queue"), + ), + ).toEqual([]); + expect([...resolveThreadForkableMessageIds(thread)]).toEqual([priorUserId, activeUserId]); + }); + it("assigns equal-timestamp imports ids whose persisted sort preserves source order", () => { const boundaryId = MessageId.makeUnsafe("message-equal-time-assistant"); const imported = buildThreadForkImportedMessagesThrough( diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts index d9e515a9e..91b9433cc 100644 --- a/apps/web/src/lib/threadHandoff.ts +++ b/apps/web/src/lib/threadHandoff.ts @@ -55,15 +55,16 @@ function resolveRunningTurnBoundaryIndex(thread: ForkSourceThread): number | nul const activeAssistantIndex = thread.messages.findIndex( (message) => message.role === "assistant" && message.turnId === thread.latestTurn?.turnId, ); - if (activeAssistantIndex >= 0) { - return activeAssistantIndex; - } - const requestedUserIndex = thread.messages.findIndex( (message) => message.role === "user" && message.createdAt === thread.latestTurn?.requestedAt, ); - if (requestedUserIndex >= 0) { - return requestedUserIndex + 1; + + const knownUnsafeIndexes = [ + ...(activeAssistantIndex >= 0 ? [activeAssistantIndex] : []), + ...(requestedUserIndex >= 0 ? [requestedUserIndex + 1] : []), + ]; + if (knownUnsafeIndexes.length > 0) { + return Math.min(...knownUnsafeIndexes); } return 0; From e7d659da6463da6f5800acaaa140f9f856837b21 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Wed, 22 Jul 2026 23:03:55 +0300 Subject: [PATCH 06/11] Avoid replaying completed fork bootstrap --- apps/server/src/orchestration/Layers/ProviderCommandReactor.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 6719ade3d..5dc09bebc 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -954,7 +954,6 @@ const make = Effect.gen(function* () { !shouldRestartForModelChange && !shouldRestartForModelSelectionChange ) { - registerInitialMessageForkContextBootstrap(); return existingSessionThreadId; } From fe6d7952e89c51fe341fb908de74c95594150f97 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 00:03:35 +0300 Subject: [PATCH 07/11] Harden fork lineage and restart recovery --- .../Layers/OrchestrationEngine.test.ts | 176 ++++++++++- .../Layers/OrchestrationEngine.ts | 20 +- .../Layers/ProjectionPipeline.ts | 7 +- .../Layers/ProjectionSnapshotQuery.test.ts | 2 + .../Layers/ProjectionSnapshotQuery.ts | 7 + .../Layers/ProviderCommandReactor.test.ts | 125 ++++++++ .../Layers/ProviderCommandReactor.ts | 38 ++- apps/server/src/orchestration/decider.ts | 6 + .../src/orchestration/forkTitle.test.ts | 54 ++++ apps/server/src/orchestration/forkTitle.ts | 12 +- apps/server/src/orchestration/handoff.test.ts | 23 ++ apps/server/src/orchestration/handoff.ts | 26 +- .../src/orchestration/projector.test.ts | 108 +++++++ apps/server/src/orchestration/projector.ts | 7 +- .../persistence/Layers/ProjectionThreads.ts | 5 + apps/server/src/persistence/Migrations.ts | 2 + ...ojectionThreadsForkTitleFamilyRoot.test.ts | 245 +++++++++++++++ ...59_ProjectionThreadsForkTitleFamilyRoot.ts | 282 ++++++++++++++++++ .../persistence/Services/ProjectionThreads.ts | 1 + apps/web/src/components/ChatView.browser.tsx | 39 +++ apps/web/src/composerSlashCommands.test.ts | 24 +- apps/web/src/composerSlashCommands.ts | 6 +- apps/web/src/store.ts | 5 + apps/web/src/test/effectRpcWebSocketMock.ts | 1 + apps/web/src/types.ts | 2 + packages/contracts/src/orchestration.ts | 9 + 26 files changed, 1207 insertions(+), 25 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts create mode 100644 apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 2989d973e..4ade972b7 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { CheckpointRef, CommandId, @@ -14,7 +18,10 @@ import { describe, expect, it } from "vitest"; import { PersistenceSqlError } from "../../persistence/Errors.ts"; import { OrchestrationCommandReceiptRepositoryLive } from "../../persistence/Layers/OrchestrationCommandReceipts.ts"; import { OrchestrationEventStoreLive } from "../../persistence/Layers/OrchestrationEventStore.ts"; -import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts"; +import { + makeSqlitePersistenceLive, + SqlitePersistenceMemory, +} from "../../persistence/Layers/Sqlite.ts"; import { OrchestrationEventStore, type OrchestrationEventStoreShape, @@ -57,6 +64,31 @@ async function createOrchestrationSystem() { }; } +async function createFileBackedOrchestrationSystem(dbPath: string) { + const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), { + prefix: "synara-orchestration-engine-restart-test-", + }); + const persistenceLayer = makeSqlitePersistenceLive(dbPath).pipe( + Layer.provide(NodeServices.layer), + ); + const orchestrationLayer = OrchestrationEngineLive.pipe( + Layer.provide(OrchestrationProjectionPipelineLive), + Layer.provide(OrchestrationProjectionSnapshotQueryLive), + Layer.provide(OrchestrationEventStoreLive), + Layer.provide(OrchestrationCommandReceiptRepositoryLive), + Layer.provide(persistenceLayer), + Layer.provideMerge(ServerConfigLayer), + Layer.provideMerge(NodeServices.layer), + ); + const runtime = ManagedRuntime.make(orchestrationLayer); + const engine = await runtime.runPromise(Effect.service(OrchestrationEngineService)); + return { + engine, + run: (effect: Effect.Effect) => runtime.runPromise(effect), + dispose: () => runtime.dispose(), + }; +} + function now() { return new Date().toISOString(); } @@ -202,6 +234,148 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("validates a post-restart fork against the complete persisted transcript", async () => { + const tempDirectory = fs.mkdtempSync(path.join(os.tmpdir(), "scient-fork-restart-")); + const dbPath = path.join(tempDirectory, "orchestration.sqlite"); + const projectId = asProjectId("project-restart-fork"); + const sourceThreadId = ThreadId.makeUnsafe("thread-restart-fork-source"); + const oldCreatedAt = "2026-07-22T10:00:00.000Z"; + const newCreatedAt = "2026-07-22T10:01:00.000Z"; + let firstSystem: Awaited> | null = null; + let restartedSystem: Awaited> | null = + null; + try { + firstSystem = await createFileBackedOrchestrationSystem(dbPath); + await firstSystem.run( + firstSystem.engine.dispatch({ + type: "project.create", + commandId: CommandId.makeUnsafe("cmd-project-restart-fork-create"), + projectId, + title: "Restart Fork Project", + workspaceRoot: "/tmp/project-restart-fork", + defaultModelSelection: { provider: "codex", model: "gpt-5-codex" }, + createdAt: oldCreatedAt, + }), + ); + await firstSystem.run( + firstSystem.engine.dispatch({ + type: "thread.create", + commandId: CommandId.makeUnsafe("cmd-thread-restart-fork-create"), + threadId: sourceThreadId, + projectId, + title: "Restart source", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + createdAt: oldCreatedAt, + }), + ); + await firstSystem.run( + firstSystem.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-restart-fork-old-messages"), + threadId: sourceThreadId, + messages: [ + { + messageId: asMessageId("restart-old-user"), + role: "user", + text: "Old question", + createdAt: oldCreatedAt, + updatedAt: oldCreatedAt, + }, + { + messageId: asMessageId("restart-old-assistant"), + role: "assistant", + text: "Old answer", + createdAt: "2026-07-22T10:00:01.000Z", + updatedAt: "2026-07-22T10:00:01.000Z", + }, + ], + createdAt: oldCreatedAt, + }), + ); + await firstSystem.dispose(); + firstSystem = null; + + restartedSystem = await createFileBackedOrchestrationSystem(dbPath); + await restartedSystem.run( + restartedSystem.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-restart-fork-new-messages"), + threadId: sourceThreadId, + messages: [ + { + messageId: asMessageId("restart-new-user"), + role: "user", + text: "New question", + createdAt: newCreatedAt, + updatedAt: newCreatedAt, + }, + { + messageId: asMessageId("restart-new-assistant"), + role: "assistant", + text: "New answer", + createdAt: "2026-07-22T10:01:01.000Z", + updatedAt: "2026-07-22T10:01:01.000Z", + }, + ], + createdAt: newCreatedAt, + }), + ); + + await expect( + restartedSystem.run( + restartedSystem.engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-restart-fork-create-destination"), + threadId: ThreadId.makeUnsafe("thread-restart-fork-destination"), + sourceThreadId, + sourceMessageId: asMessageId("restart-new-assistant"), + projectId, + title: "stale client title", + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + ["fork-old-user", "user", "Old question", oldCreatedAt], + ["fork-old-assistant", "assistant", "Old answer", "2026-07-22T10:00:01.000Z"], + ["fork-new-user", "user", "New question", newCreatedAt], + ["fork-new-assistant", "assistant", "New answer", "2026-07-22T10:01:01.000Z"], + ].map(([id, role, text, createdAt]) => ({ + messageId: asMessageId(id!), + role: role as "user" | "assistant", + text: text!, + createdAt: createdAt!, + updatedAt: createdAt!, + })), + createdAt: "2026-07-22T10:02:00.000Z", + }), + ), + ).resolves.toEqual(expect.objectContaining({ sequence: expect.any(Number) })); + + const destination = ( + await restartedSystem.run(restartedSystem.engine.getReadModel()) + ).threads.find( + (thread) => thread.id === ThreadId.makeUnsafe("thread-restart-fork-destination"), + ); + expect(destination?.messages.map((message) => message.text)).toEqual([ + "Old question", + "Old answer", + "New question", + "New answer", + ]); + } finally { + if (firstSystem) await firstSystem.dispose(); + if (restartedSystem) await restartedSystem.dispose(); + fs.rmSync(tempDirectory, { recursive: true, force: true }); + } + }); + it("rejects a fork import that reuses a source message id without moving the source row", async () => { const createdAt = now(); const system = await createOrchestrationSystem(); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts index acc0c7e17..23108d00f 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.ts @@ -257,13 +257,19 @@ const makeOrchestrationEngine = Effect.gen(function* () { thread: OrchestrationReadModel["threads"][number], ): OrchestrationReadModel => { const existingThread = model.threads.find((entry) => entry.id === thread.id); - const mergedThread = - existingThread && existingThread.messages.length > 0 - ? { - ...thread, - messages: existingThread.messages, - } - : thread; + const hotMessageIds = new Set((existingThread?.messages ?? []).map((message) => message.id)); + // The command model starts with no message bodies after a restart, then + // accumulates new events in their authoritative order. Keep persisted-only + // history first and let the hot model replace matching rows. This also + // avoids reordering same-timestamp messages by the projection's id tie-break. + const mergedMessages = [ + ...thread.messages.filter((message) => !hotMessageIds.has(message.id)), + ...(existingThread?.messages ?? []), + ]; + const mergedThread = { + ...thread, + messages: mergedMessages, + }; const hasThread = existingThread !== undefined; return { ...model, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 3e0b3fb43..14596ce34 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -654,6 +654,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { subagentRole: event.payload.subagentRole ?? null, forkSourceThreadId: event.payload.forkSourceThreadId, forkSourceMessageId: event.payload.forkSourceMessageId, + forkTitleFamilyRootId: event.payload.forkTitleFamilyRootId, forkTitleBase: event.payload.forkTitleBase, forkTitleOrdinal: event.payload.forkTitleOrdinal, sidechatSourceThreadId: event.payload.sidechatSourceThreadId, @@ -697,7 +698,11 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { ...existingRow.value, ...(event.payload.title !== undefined ? { title: event.payload.title } : {}), ...(titleBreaksAutomaticForkLineage - ? { forkTitleBase: null, forkTitleOrdinal: null } + ? { + forkTitleFamilyRootId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + } : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index 3bf4c5f99..d196ca914 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -341,6 +341,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentRole: null, forkSourceThreadId: ThreadId.makeUnsafe("thread-source"), forkSourceMessageId: MessageId.makeUnsafe("message-source-boundary"), + forkTitleFamilyRootId: null, forkTitleBase: "Thread 1", forkTitleOrdinal: 2, sidechatSourceThreadId: null, @@ -1387,6 +1388,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentRole: null, forkSourceThreadId: null, forkSourceMessageId: null, + forkTitleFamilyRootId: null, forkTitleBase: null, forkTitleOrdinal: null, sidechatSourceThreadId: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index df4fdbb06..b998dd8a1 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -593,6 +593,7 @@ function toProjectedThreadShell(input: { subagentRole: threadRow.subagentRole ?? null, forkSourceThreadId: threadRow.forkSourceThreadId ?? null, forkSourceMessageId: threadRow.forkSourceMessageId ?? null, + forkTitleFamilyRootId: threadRow.forkTitleFamilyRootId ?? null, forkTitleBase: threadRow.forkTitleBase ?? null, forkTitleOrdinal: threadRow.forkTitleOrdinal ?? null, sidechatSourceThreadId: threadRow.sidechatSourceThreadId ?? null, @@ -637,6 +638,7 @@ function toProjectedThreadShellFromStoredSummary(input: { subagentRole: threadRow.subagentRole ?? null, forkSourceThreadId: threadRow.forkSourceThreadId ?? null, forkSourceMessageId: threadRow.forkSourceMessageId ?? null, + forkTitleFamilyRootId: threadRow.forkTitleFamilyRootId ?? null, forkTitleBase: threadRow.forkTitleBase ?? null, forkTitleOrdinal: threadRow.forkTitleOrdinal ?? null, sidechatSourceThreadId: threadRow.sidechatSourceThreadId ?? null, @@ -686,6 +688,7 @@ function toProjectedThread(input: { subagentRole: threadRow.subagentRole ?? null, forkSourceThreadId: threadRow.forkSourceThreadId, forkSourceMessageId: threadRow.forkSourceMessageId ?? null, + forkTitleFamilyRootId: threadRow.forkTitleFamilyRootId ?? null, forkTitleBase: threadRow.forkTitleBase ?? null, forkTitleOrdinal: threadRow.forkTitleOrdinal ?? null, sidechatSourceThreadId: threadRow.sidechatSourceThreadId ?? null, @@ -795,6 +798,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", fork_source_message_id AS "forkSourceMessageId", + fork_title_family_root_id AS "forkTitleFamilyRootId", fork_title_base AS "forkTitleBase", fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", @@ -840,6 +844,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", fork_source_message_id AS "forkSourceMessageId", + fork_title_family_root_id AS "forkTitleFamilyRootId", fork_title_base AS "forkTitleBase", fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", @@ -1191,6 +1196,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", fork_source_message_id AS "forkSourceMessageId", + fork_title_family_root_id AS "forkTitleFamilyRootId", fork_title_base AS "forkTitleBase", fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", @@ -1241,6 +1247,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", fork_source_message_id AS "forkSourceMessageId", + fork_title_family_root_id AS "forkTitleFamilyRootId", fork_title_base AS "forkTitleBase", fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts index 737acd29f..ac04f7b31 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts @@ -2887,6 +2887,131 @@ describe("ProviderCommandReactor", () => { expect(retrySendInput.input).toContain("scient-skill-authoring/SKILL.md"); }); + it("preserves the complete message-fork attachment manifest on a stale Claude retry", async () => { + const harness = await createHarness({ + threadModelSelection: { provider: "claudeAgent", model: "claude-opus-4-8" }, + }); + const importedAt = "2026-07-22T10:00:00.000Z"; + const boundaryAt = "2026-07-22T10:00:01.000Z"; + const forkThreadId = ThreadId.makeUnsafe("thread-claude-message-fork-stale-retry"); + const sourceBoundaryId = asMessageId("claude-message-fork-source-boundary"); + const importedAttachments = Array.from({ length: 10 }, (_, index) => ({ + type: "file" as const, + id: `claude-message-fork-imported-${index}`, + name: `claude-imported-${index}.txt`, + mimeType: "text/plain", + sizeBytes: index + 1, + })); + const currentAttachment = { + type: "file" as const, + id: "claude-message-fork-current", + name: "current.txt", + mimeType: "text/plain", + sizeBytes: 1, + }; + harness.sendTurn.mockImplementationOnce(() => + Effect.fail( + new ProviderAdapterRequestError({ + provider: "claudeAgent", + method: "session/prompt", + detail: "No conversation found with session ID: stale-message-fork-session", + }), + ), + ); + + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.messages.import", + commandId: CommandId.makeUnsafe("cmd-claude-message-fork-source-import"), + threadId: ThreadId.makeUnsafe("thread-1"), + messages: [ + { + messageId: asMessageId("claude-message-fork-source-user"), + role: "user", + text: "Source question with files", + attachments: importedAttachments, + createdAt: importedAt, + updatedAt: importedAt, + }, + { + messageId: sourceBoundaryId, + role: "assistant", + text: "Source answer", + createdAt: boundaryAt, + updatedAt: boundaryAt, + }, + ], + createdAt: importedAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.fork.create", + commandId: CommandId.makeUnsafe("cmd-claude-message-fork-create"), + threadId: forkThreadId, + sourceThreadId: ThreadId.makeUnsafe("thread-1"), + sourceMessageId: sourceBoundaryId, + projectId: asProjectId("project-1"), + title: "Claude message fork", + modelSelection: { provider: "claudeAgent", model: "claude-opus-4-8" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + envMode: "local", + branch: null, + worktreePath: null, + importedMessages: [ + { + messageId: asMessageId("claude-message-fork-imported-user"), + role: "user", + text: "Source question with files", + attachments: importedAttachments, + createdAt: importedAt, + updatedAt: importedAt, + }, + { + messageId: asMessageId("claude-message-fork-imported-assistant"), + role: "assistant", + text: "Source answer", + createdAt: boundaryAt, + updatedAt: boundaryAt, + }, + ], + createdAt: boundaryAt, + }), + ); + await Effect.runPromise( + harness.engine.dispatch({ + type: "thread.turn.start", + commandId: CommandId.makeUnsafe("cmd-claude-message-fork-stale-turn"), + threadId: forkThreadId, + message: { + messageId: asMessageId("claude-message-fork-new-user"), + role: "user", + text: "Continue from the exact boundary", + attachments: [currentAttachment], + }, + modelSelection: { provider: "claudeAgent", model: "claude-opus-4-8" }, + runtimeMode: "approval-required", + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: "2026-07-22T10:01:00.000Z", + }), + ); + + await waitFor(() => harness.sendTurn.mock.calls.length === 2); + for (const call of harness.sendTurn.mock.calls) { + const sendInput = call[0] as { + readonly input?: string; + readonly attachments?: ReadonlyArray<{ readonly id: string }>; + }; + expect(sendInput.input).toContain("Imported attachment manifest:"); + expect(sendInput.input).toContain("claude-imported-9.txt"); + expect(sendInput.attachments?.map((attachment) => attachment.id)).toEqual([ + currentAttachment.id, + ...importedAttachments.slice(0, 7).map((attachment) => attachment.id), + ]); + } + }); + it("marks the thread session errored when normal turn start fails", async () => { const harness = await createHarness(); const now = new Date().toISOString(); diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 5dc09bebc..2ec509bff 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -62,6 +62,7 @@ import { ServerConfig } from "../../config.ts"; import { buildScientBuiltInSkillTriggerInstructions } from "../../scientBuiltInSkills.ts"; import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts"; import { + buildImportedForkAttachmentManifest, buildMessageForkBootstrapText, buildPriorTranscriptBootstrapText, buildForkBootstrapText, @@ -1220,6 +1221,20 @@ const make = Effect.gen(function* () { priorTranscriptBootstrapAvailableChars, ) : null; + const requiredForkAttachmentManifest = isPendingMessageBoundaryForkBootstrap + ? buildImportedForkAttachmentManifest(thread) + : null; + if ( + requiredForkAttachmentManifest && + !priorTranscriptBootstrapText?.includes(requiredForkAttachmentManifest) + ) { + return yield* new ProviderAdapterRequestError({ + provider: selectedProvider as ProviderKind, + method: "thread.turn.start", + detail: + "The imported attachment manifest is too long to preserve in the fork context. Remove attachments from the source boundary or shorten the latest message, then retry.", + }); + } const providerInput = handoffBootstrapText ? wrapProviderContext({ tag: "handoff_context", @@ -1448,12 +1463,25 @@ const make = Effect.gen(function* () { const retryBootstrapText = priorTranscriptBootstrapAvailableChars > 0 - ? buildPriorTranscriptBootstrapText( - thread, - input.messageId, - priorTranscriptBootstrapAvailableChars, - ) + ? isPendingMessageBoundaryForkBootstrap + ? buildMessageForkBootstrapText(thread, priorTranscriptBootstrapAvailableChars) + : buildPriorTranscriptBootstrapText( + thread, + input.messageId, + priorTranscriptBootstrapAvailableChars, + ) : null; + if ( + requiredForkAttachmentManifest && + !retryBootstrapText?.includes(requiredForkAttachmentManifest) + ) { + return yield* new ProviderAdapterRequestError({ + provider: selectedProvider as ProviderKind, + method: "thread.turn.start", + detail: + "The imported attachment manifest is too long to preserve in the fork retry context. Remove attachments from the source boundary or shorten the latest message, then retry.", + }); + } const retryProviderInput = retryBootstrapText ? wrapProviderContext({ tag: "thread_context", diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 3ffa863a8..5471ed9ee 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -9,6 +9,7 @@ import { MAX_PINNED_PROJECTS, PINNED_MESSAGES_MAX_COUNT, THREAD_MARKERS_MAX_COUNT, + ThreadId, TurnId, } from "@synara/contracts"; import { @@ -670,6 +671,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" const resolvedForkTitle = command.sidechatSourceThreadId ? { title: command.title, + forkTitleFamilyRootId: null, forkTitleBase: null, forkTitleOrdinal: null, } @@ -717,6 +719,10 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" subagentRole: null, forkSourceThreadId: command.sourceThreadId, forkSourceMessageId: command.sourceMessageId ?? null, + forkTitleFamilyRootId: + resolvedForkTitle.forkTitleFamilyRootId === null + ? null + : ThreadId.makeUnsafe(resolvedForkTitle.forkTitleFamilyRootId), forkTitleBase: resolvedForkTitle.forkTitleBase, forkTitleOrdinal: resolvedForkTitle.forkTitleOrdinal, sidechatSourceThreadId: command.sidechatSourceThreadId, diff --git a/apps/server/src/orchestration/forkTitle.test.ts b/apps/server/src/orchestration/forkTitle.test.ts index bde6c4399..6b63cc876 100644 --- a/apps/server/src/orchestration/forkTitle.test.ts +++ b/apps/server/src/orchestration/forkTitle.test.ts @@ -14,6 +14,7 @@ const thread = ( title, forkSourceThreadId: null, sidechatSourceThreadId: null, + forkTitleFamilyRootId: null, forkTitleBase: null, forkTitleOrdinal: null, archivedAt: null, @@ -26,17 +27,20 @@ describe("resolveNextForkTitle", () => { const root = thread("root", "Greeting"); expect(resolveNextForkTitle({ sourceThread: root, threads: [root] })).toEqual({ title: "Greeting (2)", + forkTitleFamilyRootId: "root", forkTitleBase: "Greeting", forkTitleOrdinal: 2, }); const fork2 = thread("fork-2", "Greeting (2)", { forkSourceThreadId: root.id, + forkTitleFamilyRootId: root.id, forkTitleBase: "Greeting", forkTitleOrdinal: 2, }); expect(resolveNextForkTitle({ sourceThread: root, threads: [root, fork2] })).toEqual({ title: "Greeting (3)", + forkTitleFamilyRootId: "root", forkTitleBase: "Greeting", forkTitleOrdinal: 3, }); @@ -46,17 +50,20 @@ describe("resolveNextForkTitle", () => { const root = thread("root", "Greeting"); const fork2 = thread("fork-2", "Greeting (2)", { forkSourceThreadId: root.id, + forkTitleFamilyRootId: root.id, forkTitleBase: "Greeting", forkTitleOrdinal: 2, }); const fork3 = thread("fork-3", "Greeting (3)", { forkSourceThreadId: root.id, + forkTitleFamilyRootId: root.id, forkTitleBase: "Greeting", forkTitleOrdinal: 3, }); expect(resolveNextForkTitle({ sourceThread: fork2, threads: [root, fork2, fork3] })).toEqual({ title: "Greeting (4)", + forkTitleFamilyRootId: "root", forkTitleBase: "Greeting", forkTitleOrdinal: 4, }); @@ -79,6 +86,7 @@ describe("resolveNextForkTitle", () => { resolveNextForkTitle({ sourceThread: renamedFork, threads: [root, renamedFork] }), ).toEqual({ title: "Experiment (2)", + forkTitleFamilyRootId: "fork-2", forkTitleBase: "Experiment", forkTitleOrdinal: 2, }); @@ -89,6 +97,7 @@ describe("resolveNextForkTitle", () => { }), ).toEqual({ title: "Experiment (3)", + forkTitleFamilyRootId: "fork-2", forkTitleBase: "Experiment", forkTitleOrdinal: 3, }); @@ -141,6 +150,51 @@ describe("resolveNextForkTitle", () => { ).toBe("Greeting (2)"); }); + it("keeps existing descendants in their immutable family after an ancestor rename", () => { + const root = thread("root", "Greeting"); + const renamedFork2 = thread("fork-2", "Experiment", { + forkSourceThreadId: root.id, + forkTitleFamilyRootId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + }); + const fork3 = thread("fork-3", "Greeting (3)", { + forkSourceThreadId: renamedFork2.id, + forkTitleFamilyRootId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 3, + }); + const fork4 = thread("fork-4", "Greeting (4)", { + forkSourceThreadId: root.id, + forkTitleFamilyRootId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 4, + }); + + expect( + resolveNextForkTitle({ + sourceThread: fork3, + threads: [root, renamedFork2, fork3, fork4], + }), + ).toEqual({ + title: "Greeting (5)", + forkTitleFamilyRootId: root.id, + forkTitleBase: "Greeting", + forkTitleOrdinal: 5, + }); + expect( + resolveNextForkTitle({ + sourceThread: renamedFork2, + threads: [root, renamedFork2, fork3, fork4], + }), + ).toEqual({ + title: "Experiment (2)", + forkTitleFamilyRootId: renamedFork2.id, + forkTitleBase: "Experiment", + forkTitleOrdinal: 2, + }); + }); + it("does not rejoin the old family after renaming back to the generated title", () => { const root = thread("root", "Greeting"); const renamedBack = thread("fork-2", "Greeting (2)", { diff --git a/apps/server/src/orchestration/forkTitle.ts b/apps/server/src/orchestration/forkTitle.ts index bf6fc8760..d62a1339a 100644 --- a/apps/server/src/orchestration/forkTitle.ts +++ b/apps/server/src/orchestration/forkTitle.ts @@ -8,6 +8,7 @@ export interface ForkTitleThread { readonly title: string; readonly forkSourceThreadId?: string | null | undefined; readonly sidechatSourceThreadId?: string | null | undefined; + readonly forkTitleFamilyRootId?: string | null | undefined; readonly forkTitleBase?: string | null | undefined; readonly forkTitleOrdinal?: number | null | undefined; readonly archivedAt?: string | null | undefined; @@ -16,6 +17,7 @@ export interface ForkTitleThread { export interface ResolvedForkTitle { readonly title: string; + readonly forkTitleFamilyRootId: string; readonly forkTitleBase: string; readonly forkTitleOrdinal: number; } @@ -39,7 +41,7 @@ function usesAutomaticForkTitle(thread: ForkTitleThread): thread is ForkTitleThr ); } -function resolveForkFamilyRootId( +function resolveLegacyForkFamilyRootId( thread: ForkTitleThread, threadsById: ReadonlyMap, ): string { @@ -77,7 +79,9 @@ export function resolveNextForkTitle(input: { } const forkTitleBase = usesAutomaticForkTitle(source) ? source.forkTitleBase : source.title; - const familyRootId = resolveForkFamilyRootId(source, threadsById); + const forkTitleFamilyRootId = usesAutomaticForkTitle(source) + ? (source.forkTitleFamilyRootId ?? resolveLegacyForkFamilyRootId(source, threadsById)) + : source.id; let highestOrdinal = 1; for (const thread of input.threads) { @@ -88,7 +92,8 @@ export function resolveNextForkTitle(input: { thread.projectId !== source.projectId || thread.sidechatSourceThreadId || thread.forkTitleBase !== forkTitleBase || - resolveForkFamilyRootId(thread, threadsById) !== familyRootId + (thread.forkTitleFamilyRootId ?? resolveLegacyForkFamilyRootId(thread, threadsById)) !== + forkTitleFamilyRootId ) { continue; } @@ -101,6 +106,7 @@ export function resolveNextForkTitle(input: { const forkTitleOrdinal = highestOrdinal + 1; return { title: formatForkTitle(forkTitleBase, forkTitleOrdinal), + forkTitleFamilyRootId, forkTitleBase, forkTitleOrdinal, }; diff --git a/apps/server/src/orchestration/handoff.test.ts b/apps/server/src/orchestration/handoff.test.ts index 705d900b7..9d6b093c2 100644 --- a/apps/server/src/orchestration/handoff.test.ts +++ b/apps/server/src/orchestration/handoff.test.ts @@ -128,4 +128,27 @@ describe("transcript bootstrap budgets", () => { expect(text).toContain("critical selected excerpt"); expect(listImportedForkProviderAttachments(selectedThread)).toEqual([]); }); + + it("reserves a complete attachment manifest ahead of truncated imported message text", () => { + const attachments = Array.from({ length: 10 }, (_, index) => ({ + type: "file" as const, + id: `long-message-attachment-${index}`, + name: `${String(index).padStart(2, "0")}-${"x".repeat(180)}.txt`, + mimeType: "text/plain", + sizeBytes: 1, + })); + const importedMessage: OrchestrationMessage = { + ...message(1, "user", "long text ".repeat(1_000), "native"), + source: "fork-import", + attachments, + }; + + const text = buildMessageForkBootstrapText(thread([importedMessage]), 8_000); + + expect(text).not.toBeNull(); + expect(text).toContain("Imported attachment manifest:"); + for (const attachment of attachments) { + expect(text).toContain(attachment.name); + } + }); }); diff --git a/apps/server/src/orchestration/handoff.ts b/apps/server/src/orchestration/handoff.ts index 7e21ae3bc..d6d67cae7 100644 --- a/apps/server/src/orchestration/handoff.ts +++ b/apps/server/src/orchestration/handoff.ts @@ -74,8 +74,11 @@ export function listImportedForkMessages( export function listImportedForkProviderAttachments( thread: Pick, -): ReadonlyArray { - const attachmentsById = new Map(); +): ReadonlyArray> { + const attachmentsById = new Map< + string, + Exclude + >(); for (const message of listImportedForkMessages(thread)) { for (const attachment of message.attachments ?? []) { if (attachment.type !== "assistant-selection") { @@ -86,6 +89,19 @@ export function listImportedForkProviderAttachments( return [...attachmentsById.values()]; } +export function buildImportedForkAttachmentManifest( + thread: Pick, +): string | null { + const attachments = listImportedForkProviderAttachments(thread); + if (attachments.length === 0) { + return null; + } + return [ + "Imported attachment manifest:", + ...attachments.map((attachment) => `- ${attachment.name} (${attachment.mimeType})`), + ].join("\n"); +} + export function hasNativeHandoffMessages(thread: Pick): boolean { return thread.messages.some( (message) => @@ -259,10 +275,14 @@ export function buildMessageForkBootstrapText( return null; } + const attachmentManifest = buildImportedForkAttachmentManifest(thread); + return buildImportedMessagesBootstrapText({ thread, importedMessages, - intro: "This task was forked from an exact earlier conversation boundary.", + intro: ["This task was forked from an exact earlier conversation boundary.", attachmentManifest] + .filter((section): section is string => section !== null) + .join("\n\n"), maxChars, ceilingChars: AUTOMATIC_BOOTSTRAP_TRANSCRIPT_CHAR_BUDGET, }); diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index e11a0e089..01ec91c0c 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -175,6 +175,7 @@ describe("orchestration projector", () => { subagentRole: null, forkSourceThreadId: null, forkSourceMessageId: null, + forkTitleFamilyRootId: null, forkTitleBase: null, forkTitleOrdinal: null, sidechatSourceThreadId: null, @@ -194,6 +195,113 @@ describe("orchestration projector", () => { ]); }); + it("replays immutable descendant fork families across ancestor rename and rename-back", async () => { + const createdAt = "2026-07-22T10:00:00.000Z"; + const createdEvent = (input: { + readonly sequence: number; + readonly threadId: string; + readonly title: string; + readonly sourceThreadId: string | null; + readonly familyRootId: string | null; + readonly ordinal: number | null; + }) => + makeEvent({ + sequence: input.sequence, + type: "thread.created", + aggregateKind: "thread", + aggregateId: input.threadId, + occurredAt: createdAt, + commandId: `command-create-${input.threadId}`, + payload: { + threadId: input.threadId, + projectId: "project-1", + title: input.title, + modelSelection: { provider: "codex", model: "gpt-5-codex" }, + runtimeMode: "approval-required", + branch: null, + worktreePath: null, + forkSourceThreadId: input.sourceThreadId, + forkTitleFamilyRootId: input.familyRootId, + forkTitleBase: input.ordinal === null ? null : "Greeting", + forkTitleOrdinal: input.ordinal, + createdAt, + updatedAt: createdAt, + }, + }); + + const events = [ + createdEvent({ + sequence: 1, + threadId: "root", + title: "Greeting", + sourceThreadId: null, + familyRootId: null, + ordinal: null, + }), + createdEvent({ + sequence: 2, + threadId: "fork-2", + title: "Greeting (2)", + sourceThreadId: "root", + familyRootId: "root", + ordinal: 2, + }), + createdEvent({ + sequence: 3, + threadId: "fork-3", + title: "Greeting (3)", + sourceThreadId: "fork-2", + familyRootId: "root", + ordinal: 3, + }), + makeEvent({ + sequence: 4, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "fork-2", + occurredAt: createdAt, + commandId: "command-rename-fork-2-away", + payload: { threadId: "fork-2", title: "Experiment", updatedAt: createdAt }, + }), + makeEvent({ + sequence: 5, + type: "thread.meta-updated", + aggregateKind: "thread", + aggregateId: "fork-2", + occurredAt: createdAt, + commandId: "command-rename-fork-2-back", + payload: { threadId: "fork-2", title: "Greeting", updatedAt: createdAt }, + }), + createdEvent({ + sequence: 6, + threadId: "renamed-child-2", + title: "Greeting (2)", + sourceThreadId: "fork-2", + familyRootId: "fork-2", + ordinal: 2, + }), + ]; + + let model = createEmptyReadModel(createdAt); + for (const event of events) { + model = await Effect.runPromise(projectEvent(model, event)); + } + + expect( + model.threads.map((thread) => ({ + id: thread.id, + familyRootId: thread.forkTitleFamilyRootId, + base: thread.forkTitleBase, + ordinal: thread.forkTitleOrdinal, + })), + ).toEqual([ + { id: "root", familyRootId: null, base: null, ordinal: null }, + { id: "fork-2", familyRootId: null, base: null, ordinal: null }, + { id: "fork-3", familyRootId: "root", base: "Greeting", ordinal: 3 }, + { id: "renamed-child-2", familyRootId: "fork-2", base: "Greeting", ordinal: 2 }, + ]); + }); + it("updates thread settings from turn start events", async () => { const createdAt = "2026-02-23T08:00:00.000Z"; const turnRequestedAt = "2026-02-23T08:00:05.000Z"; diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 9fe03d8b1..6b8dacd6c 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -404,6 +404,7 @@ export function projectEvent( subagentRole: payload.subagentRole, forkSourceThreadId: payload.forkSourceThreadId, forkSourceMessageId: payload.forkSourceMessageId, + forkTitleFamilyRootId: payload.forkTitleFamilyRootId, forkTitleBase: payload.forkTitleBase, forkTitleOrdinal: payload.forkTitleOrdinal, sidechatSourceThreadId: payload.sidechatSourceThreadId, @@ -494,7 +495,11 @@ export function projectEvent( threads: updateThread(nextBase.threads, payload.threadId, { ...(payload.title !== undefined ? { title: payload.title } : {}), ...(titleBreaksAutomaticForkLineage - ? { forkTitleBase: null, forkTitleOrdinal: null } + ? { + forkTitleFamilyRootId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + } : {}), ...(payload.modelSelection !== undefined ? { modelSelection: payload.modelSelection } diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index dcdc57176..b2acd6174 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -68,6 +68,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_role, fork_source_thread_id, fork_source_message_id, + fork_title_family_root_id, fork_title_base, fork_title_ordinal, sidechat_source_thread_id, @@ -107,6 +108,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.subagentRole ?? null}, ${row.forkSourceThreadId ?? null}, ${row.forkSourceMessageId ?? null}, + ${row.forkTitleFamilyRootId ?? null}, ${row.forkTitleBase ?? null}, ${row.forkTitleOrdinal ?? null}, ${row.sidechatSourceThreadId ?? null}, @@ -146,6 +148,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_role = excluded.subagent_role, fork_source_thread_id = excluded.fork_source_thread_id, fork_source_message_id = excluded.fork_source_message_id, + fork_title_family_root_id = excluded.fork_title_family_root_id, fork_title_base = excluded.fork_title_base, fork_title_ordinal = excluded.fork_title_ordinal, sidechat_source_thread_id = excluded.sidechat_source_thread_id, @@ -192,6 +195,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", fork_source_message_id AS "forkSourceMessageId", + fork_title_family_root_id AS "forkTitleFamilyRootId", fork_title_base AS "forkTitleBase", fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", @@ -240,6 +244,7 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_role AS "subagentRole", fork_source_thread_id AS "forkSourceThreadId", fork_source_message_id AS "forkSourceMessageId", + fork_title_family_root_id AS "forkTitleFamilyRootId", fork_title_base AS "forkTitleBase", fork_title_ordinal AS "forkTitleOrdinal", sidechat_source_thread_id AS "sidechatSourceThreadId", diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index f75ec4745..7aceac9d4 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -74,6 +74,7 @@ import Migration0055 from "./Migrations/055_ProjectionThreadSessionLastErrorEven import Migration0056 from "./Migrations/056_ProjectionThreadsForkSourceMessage.ts"; import Migration0057 from "./Migrations/057_ProjectionThreadsForkTitleSequence.ts"; import Migration0058 from "./Migrations/058_ClearRenamedForkTitleLineage.ts"; +import Migration0059 from "./Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts"; /** * Migration loader with all migrations defined inline. @@ -144,6 +145,7 @@ export const migrationEntries = [ [56, "ProjectionThreadsForkSourceMessage", Migration0056], [57, "ProjectionThreadsForkTitleSequence", Migration0057], [58, "ClearRenamedForkTitleLineage", Migration0058], + [59, "ProjectionThreadsForkTitleFamilyRoot", Migration0059], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts new file mode 100644 index 000000000..19a69c3b3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts @@ -0,0 +1,245 @@ +import { assert, it } from "@effect/vitest"; +import { Effect } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import { describe } from "vitest"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { + it.effect("freezes descendant families across later and earlier ancestor renames", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 58 }); + const createdAt = "2026-07-22T10:00:00.000Z"; + + const insertThread = (input: { + readonly id: string; + readonly title: string; + readonly sourceId?: string; + readonly base?: string; + readonly ordinal?: number; + }) => + Effect.gen(function* () { + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + env_mode, + create_branch_flow_completed, + is_pinned, + fork_source_thread_id, + fork_title_base, + fork_title_ordinal, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at + ) + VALUES ( + ${input.id}, + 'project-1', + ${input.title}, + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'local', + 0, + 0, + ${input.sourceId ?? null}, + ${input.base ?? null}, + ${input.ordinal ?? null}, + 0, + 0, + 0, + ${createdAt}, + ${createdAt} + ) + `; + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${`event-created-${input.id}`}, + 'thread', + ${input.id}, + 1, + 'thread.created', + ${createdAt}, + ${`command-created-${input.id}`}, + NULL, + ${`command-created-${input.id}`}, + 'client', + ${JSON.stringify({ + threadId: input.id, + projectId: "project-1", + title: input.title, + forkSourceThreadId: input.sourceId ?? null, + forkTitleBase: input.base ?? null, + forkTitleOrdinal: input.ordinal ?? null, + createdAt, + updatedAt: createdAt, + })}, + '{}' + ) + `; + }); + + const rename = (threadId: string, version: number, title: string) => + Effect.gen(function* () { + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${`event-title-${threadId}-${version}`}, + 'thread', + ${threadId}, + ${version}, + 'thread.meta-updated', + ${createdAt}, + ${`command-title-${threadId}-${version}`}, + NULL, + ${`command-title-${threadId}-${version}`}, + 'client', + ${JSON.stringify({ threadId, title, updatedAt: createdAt })}, + '{}' + ) + `; + yield* sql` + UPDATE projection_threads + SET + title = ${title}, + fork_title_base = NULL, + fork_title_ordinal = NULL + WHERE thread_id = ${threadId} + `; + }); + + yield* insertThread({ id: "root", title: "Greeting" }); + yield* insertThread({ + id: "fork-2", + title: "Greeting (2)", + sourceId: "root", + base: "Greeting", + ordinal: 2, + }); + // Created before fork-2 is renamed, so this descendant must retain root. + yield* insertThread({ + id: "fork-3", + title: "Greeting (3)", + sourceId: "fork-2", + base: "Greeting", + ordinal: 3, + }); + // Emulate the earlier buggy migration 058: a no-op title event cleared + // the already-completed projection metadata while event truth retained it. + yield* rename("fork-3", 2, "Greeting (3)"); + yield* insertThread({ + id: "fork-4", + title: "Greeting (4)", + sourceId: "root", + base: "Greeting", + ordinal: 4, + }); + yield* rename("fork-2", 2, "Experiment"); + yield* rename("fork-2", 3, "Greeting"); + // Created after a rename-away and rename-back, so this is fork-2's new family. + yield* insertThread({ + id: "renamed-child-2", + title: "Greeting (2)", + sourceId: "fork-2", + base: "Greeting", + ordinal: 2, + }); + + yield* runMigrations(); + yield* runMigrations(); + + const createdRoots = yield* sql<{ + readonly id: string; + readonly familyRootId: string | null; + readonly base: string | null; + readonly ordinal: number | null; + }>` + SELECT + stream_id AS id, + json_extract(payload_json, '$.forkTitleFamilyRootId') AS "familyRootId", + json_extract(payload_json, '$.forkTitleBase') AS base, + json_extract(payload_json, '$.forkTitleOrdinal') AS ordinal + FROM orchestration_events + WHERE event_type = 'thread.created' + ORDER BY stream_id ASC + `; + assert.deepEqual(createdRoots, [ + { id: "fork-2", familyRootId: "root", base: "Greeting", ordinal: 2 }, + { id: "fork-3", familyRootId: "root", base: "Greeting", ordinal: 3 }, + { id: "fork-4", familyRootId: "root", base: "Greeting", ordinal: 4 }, + { + id: "renamed-child-2", + familyRootId: "fork-2", + base: "Greeting", + ordinal: 2, + }, + { id: "root", familyRootId: null, base: null, ordinal: null }, + ]); + + const projectedRoots = yield* sql<{ + readonly id: string; + readonly familyRootId: string | null; + readonly base: string | null; + readonly ordinal: number | null; + }>` + SELECT + thread_id AS id, + fork_title_family_root_id AS "familyRootId", + fork_title_base AS base, + fork_title_ordinal AS ordinal + FROM projection_threads + ORDER BY thread_id ASC + `; + // Replaying the creation roots and the two title updates produces this + // exact projection: only the renamed ancestor itself loses its old family. + assert.deepEqual(projectedRoots, [ + { id: "fork-2", familyRootId: null, base: null, ordinal: null }, + { id: "fork-3", familyRootId: "root", base: "Greeting", ordinal: 3 }, + { id: "fork-4", familyRootId: "root", base: "Greeting", ordinal: 4 }, + { + id: "renamed-child-2", + familyRootId: "fork-2", + base: "Greeting", + ordinal: 2, + }, + { id: "root", familyRootId: null, base: null, ordinal: null }, + ]); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); +}); diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts new file mode 100644 index 000000000..b12817f2e --- /dev/null +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts @@ -0,0 +1,282 @@ +// FILE: 059_ProjectionThreadsForkTitleFamilyRoot.ts +// Purpose: Freeze fork-title family identity so later ancestor renames cannot renumber descendants. +// Layer: Server persistence migration + +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import reconcileForkTitleSequence from "./057_ProjectionThreadsForkTitleSequence.ts"; +import { columnExists } from "./schemaHelpers.ts"; + +interface ForkTitleEventRow { + readonly sequence: number; + readonly eventType: "thread.created" | "thread.meta-updated"; + readonly threadId: string; + readonly title: string | null; + readonly projectId: string | null; + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly forkTitleFamilyRootId: string | null; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; +} + +interface EventThreadState { + readonly threadId: string; + readonly projectId: string | null; + readonly createdTitle: string; + readonly forkSourceThreadId: string | null; + currentTitle: string; + hasChangedTitle: boolean; + automaticBase: string | null; + automaticOrdinal: number | null; + familyRootId: string | null; +} + +interface ProjectionThreadRow { + readonly threadId: string; + readonly projectId: string; + readonly title: string; + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly forkTitleFamilyRootId: string | null; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; +} + +function isAutomaticFork(input: { + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; +}): boolean { + return ( + input.forkSourceThreadId !== null && + input.sidechatSourceThreadId === null && + input.forkTitleBase !== null && + Number.isSafeInteger(input.forkTitleOrdinal) && + (input.forkTitleOrdinal ?? 0) >= 2 + ); +} + +function isGeneratedTitle(title: string, base: string): boolean { + if (!title.startsWith(`${base} (`) || !title.endsWith(")")) { + return false; + } + const ordinalText = title.slice(base.length + 2, -1); + const ordinal = Number(ordinalText); + return Number.isSafeInteger(ordinal) && ordinal >= 2 && String(ordinal) === ordinalText; +} + +function inferGeneratedForkSeries(input: { + readonly title: string; + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly statesByThreadId: ReadonlyMap; +}): { readonly base: string; readonly ordinal: number } | null { + if (!input.forkSourceThreadId || input.sidechatSourceThreadId) { + return null; + } + const source = input.statesByThreadId.get(input.forkSourceThreadId); + if (!source) { + return null; + } + const base = source.automaticBase ?? source.currentTitle; + if (!input.title.startsWith(`${base} (`) || !input.title.endsWith(")")) { + return null; + } + const ordinalText = input.title.slice(base.length + 2, -1); + const ordinal = Number(ordinalText); + return Number.isSafeInteger(ordinal) && ordinal >= 2 && String(ordinal) === ordinalText + ? { base, ordinal } + : null; +} + +function resolveEventFamilyRoot(input: { + readonly sourceThreadId: string; + readonly forkTitleBase: string; + readonly statesByThreadId: ReadonlyMap; +}): string { + const source = input.statesByThreadId.get(input.sourceThreadId); + if (!source) { + return input.sourceThreadId; + } + if (source.automaticBase === input.forkTitleBase && source.familyRootId) { + return source.familyRootId; + } + if ( + !source.hasChangedTitle && + source.forkSourceThreadId && + isGeneratedTitle(source.createdTitle, input.forkTitleBase) + ) { + return resolveEventFamilyRoot({ + sourceThreadId: source.forkSourceThreadId, + forkTitleBase: input.forkTitleBase, + statesByThreadId: input.statesByThreadId, + }); + } + return source.threadId; +} + +function resolveProjectionOnlyFamilyRoot( + row: ProjectionThreadRow, + rowsByThreadId: ReadonlyMap, +): string { + let current = row; + const visited = new Set(); + while (current.forkSourceThreadId) { + if (visited.has(current.threadId)) { + return [...visited, current.threadId].toSorted()[0] ?? current.threadId; + } + visited.add(current.threadId); + const source = rowsByThreadId.get(current.forkSourceThreadId); + if (!source) { + return current.forkSourceThreadId; + } + current = source; + } + return current.threadId; +} + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + if (!(yield* columnExists(sql, "projection_threads", "fork_title_family_root_id"))) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN fork_title_family_root_id TEXT + `; + } + + // Some development databases completed an earlier migration 058 that + // cleared automatic metadata even for a no-op title update. Re-run the + // corrected, idempotent legacy allocator first; the event-sequential pass + // below then repairs generated suffixes against their source family. + yield* reconcileForkTitleSequence; + + const eventRows = yield* sql` + SELECT + sequence, + event_type AS "eventType", + stream_id AS "threadId", + json_extract(payload_json, '$.title') AS title, + json_extract(payload_json, '$.projectId') AS "projectId", + json_extract(payload_json, '$.forkSourceThreadId') AS "forkSourceThreadId", + json_extract(payload_json, '$.sidechatSourceThreadId') AS "sidechatSourceThreadId", + json_extract(payload_json, '$.forkTitleFamilyRootId') AS "forkTitleFamilyRootId", + json_extract(payload_json, '$.forkTitleBase') AS "forkTitleBase", + json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal" + FROM orchestration_events + WHERE event_type IN ('thread.created', 'thread.meta-updated') + AND json_valid(payload_json) + ORDER BY sequence ASC + `; + const statesByThreadId = new Map(); + const eventBackedThreadIds = new Set(); + + for (const row of eventRows) { + if (row.eventType === "thread.meta-updated") { + const state = statesByThreadId.get(row.threadId); + if (state && row.title !== null && row.title !== state.currentTitle) { + state.currentTitle = row.title; + state.hasChangedTitle = true; + state.automaticBase = null; + state.automaticOrdinal = null; + state.familyRootId = null; + } + continue; + } + + if (row.title === null) { + continue; + } + eventBackedThreadIds.add(row.threadId); + const inferredSeries = inferGeneratedForkSeries({ + title: row.title, + forkSourceThreadId: row.forkSourceThreadId, + sidechatSourceThreadId: row.sidechatSourceThreadId, + statesByThreadId, + }); + const automatic = isAutomaticFork(row) || inferredSeries !== null; + const automaticBase = inferredSeries?.base ?? (automatic ? row.forkTitleBase : null); + const automaticOrdinal = inferredSeries?.ordinal ?? (automatic ? row.forkTitleOrdinal : null); + const familyRootId = automatic + ? (row.forkTitleFamilyRootId ?? + resolveEventFamilyRoot({ + sourceThreadId: row.forkSourceThreadId!, + forkTitleBase: automaticBase!, + statesByThreadId, + })) + : null; + statesByThreadId.set(row.threadId, { + threadId: row.threadId, + projectId: row.projectId, + createdTitle: row.title, + forkSourceThreadId: row.forkSourceThreadId, + currentTitle: row.title, + hasChangedTitle: false, + automaticBase, + automaticOrdinal, + familyRootId, + }); + + if (familyRootId) { + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.forkTitleFamilyRootId', + ${familyRootId}, + '$.forkTitleBase', + ${automaticBase}, + '$.forkTitleOrdinal', + ${automaticOrdinal} + ) + WHERE sequence = ${row.sequence} + `; + } + } + + for (const state of statesByThreadId.values()) { + yield* sql` + UPDATE projection_threads + SET + fork_title_family_root_id = ${state.familyRootId}, + fork_title_base = ${state.automaticBase}, + fork_title_ordinal = ${state.automaticOrdinal} + WHERE thread_id = ${state.threadId} + `; + } + + // Projection-only legacy rows have no event timeline from which to distinguish + // rename timing. Freeze them deterministically at their topmost known fork + // ancestor. This avoids runtime regrouping and ordinal reuse if titles change. + const projectionRows = yield* sql` + SELECT + thread_id AS "threadId", + project_id AS "projectId", + title, + fork_source_thread_id AS "forkSourceThreadId", + sidechat_source_thread_id AS "sidechatSourceThreadId", + fork_title_family_root_id AS "forkTitleFamilyRootId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + ORDER BY created_at ASC, thread_id ASC + `; + const projectionRowsByThreadId = new Map( + projectionRows.map((row) => [row.threadId, row] as const), + ); + for (const row of projectionRows) { + if (eventBackedThreadIds.has(row.threadId) || !isAutomaticFork(row)) { + continue; + } + const familyRootId = + row.forkTitleFamilyRootId ?? resolveProjectionOnlyFamilyRoot(row, projectionRowsByThreadId); + yield* sql` + UPDATE projection_threads + SET fork_title_family_root_id = ${familyRootId} + WHERE thread_id = ${row.threadId} + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 017940f23..a8e3d62ee 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -50,6 +50,7 @@ export const ProjectionThread = Schema.Struct({ subagentRole: Schema.optional(Schema.NullOr(Schema.String)), forkSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)), forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)), + forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)), forkTitleBase: Schema.optional(Schema.NullOr(Schema.String)), forkTitleOrdinal: Schema.optional(Schema.NullOr(PositiveInt)), sidechatSourceThreadId: Schema.optional(Schema.NullOr(ThreadId)), diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index aee861c7c..e0ef283d1 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -2030,6 +2030,45 @@ describe("ChatView timeline estimator parity (full app)", () => { } }); + it("intercepts Claude /fork in the app instead of sending it to the provider", async () => { + const sourceSnapshot = createSnapshotForTargetUser({ + targetMessageId: MessageId.makeUnsafe("msg-user-claude-fork-slash-source"), + targetText: "Claude fork slash source", + }); + const claudeSnapshot: OrchestrationReadModel = { + ...sourceSnapshot, + threads: sourceSnapshot.threads.map((thread) => ({ + ...thread, + modelSelection: { provider: "claudeAgent", model: "claude-opus-4-8" }, + session: thread.session + ? { + ...thread.session, + providerName: "claudeAgent", + } + : null, + })), + }; + + useComposerDraftStore.getState().setPrompt(THREAD_ID, "/fork"); + const forkMounted = await mountChatView({ + viewport: DEFAULT_VIEWPORT, + snapshot: claudeSnapshot, + }); + + try { + const composerEditor = await waitForComposerEditor(); + await vi.waitFor(() => expect(composerEditor.textContent ?? "").toContain("/fork")); + wsRequests.length = 0; + await userEvent.click(await waitForSendButton()); + + await expect.element(page.getByText("Fork Into New Worktree", { exact: true })).toBeVisible(); + await expect.element(page.getByText("Fork Into Local", { exact: true })).toBeVisible(); + expect(hasDispatchedCommandType("thread.turn.start")).toBe(false); + } finally { + await forkMounted.cleanup(); + } + }); + it("forks from an assistant row that first rendered while streaming", async () => { const targetUserMessageId = MessageId.makeUnsafe("msg-user-message-fork-settling-source"); const sourceSnapshot = createSnapshotForTargetUser({ diff --git a/apps/web/src/composerSlashCommands.test.ts b/apps/web/src/composerSlashCommands.test.ts index ccde0f2e6..964eef94b 100644 --- a/apps/web/src/composerSlashCommands.test.ts +++ b/apps/web/src/composerSlashCommands.test.ts @@ -288,7 +288,7 @@ describe("composerSlashCommands", () => { expect(shouldHideProviderNativeCommandFromComposerMenu("claudeAgent", "feedback")).toBe(true); }); - it("only exposes Synara-owned app commands for claude", () => { + it("offers Scient-owned fork handling for Claude", () => { expect( getAvailableComposerSlashCommands({ provider: "claudeAgent", @@ -299,7 +299,27 @@ describe("composerSlashCommands", () => { canOfferSideCommand: true, canOfferExportCommand: true, }), - ).toEqual(["side", "export", "feedback", "automation"]); + ).toEqual(["fork", "side", "export", "feedback", "automation"]); + }); + + it("keeps app-level /fork for Claude when native /branch advertises the fork alias", () => { + const commands = getAvailableComposerSlashCommands({ + provider: "claudeAgent", + supportsFastSlashCommand: true, + canOfferCompactCommand: true, + canOfferReviewCommand: true, + canOfferForkCommand: true, + canOfferSideCommand: true, + canOfferExportCommand: true, + providerNativeCommandNames: ["branch"], + }); + + expect(commands).toContain("fork"); + expect(parseComposerSlashInvocationForCommands("/fork", commands)).toEqual({ + command: "fork", + args: "", + }); + expect(parseComposerSlashInvocationForCommands("/branch", commands)).toBeNull(); }); it("offers the app-level /export command on every provider", () => { diff --git a/apps/web/src/composerSlashCommands.ts b/apps/web/src/composerSlashCommands.ts index 21b5c05dc..cbac5999c 100644 --- a/apps/web/src/composerSlashCommands.ts +++ b/apps/web/src/composerSlashCommands.ts @@ -85,6 +85,7 @@ function shouldKeepBuiltInSlashCommandDespiteNativeCollision( command === "automation" || command === "export" || command === "feedback" || + (provider === "claudeAgent" && command === "fork") || (providerUsesAppOwnedReviewSlashCommand(provider) && command === "review") ); } @@ -421,8 +422,9 @@ export function getAvailableComposerSlashCommands(input: { "automation", ] : [ - // Claude owns most slash-command UX natively; sidechat remains app-level because it - // creates a Synara split/context clone before the provider sees the first turn. + // Claude owns most slash-command UX natively. Fork and sidechat remain app-level + // because they create Scient-owned tasks before the provider sees the first turn. + ...(input.canOfferForkCommand ? (["fork"] as const) : []), // /export is app-level too — Synara owns the thread transcript, so the download // happens in the app rather than being forwarded to Claude's native /export. ...(input.canOfferSideCommand ? (["side"] as const) : []), diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index ba3b767ee..524255393 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -388,6 +388,7 @@ function threadShellsEqual(left: ThreadShell | undefined, right: ThreadShell): b (left.subagentRole ?? null) === (right.subagentRole ?? null) && (left.forkSourceThreadId ?? null) === (right.forkSourceThreadId ?? null) && (left.forkSourceMessageId ?? null) === (right.forkSourceMessageId ?? null) && + (left.forkTitleFamilyRootId ?? null) === (right.forkTitleFamilyRootId ?? null) && (left.forkTitleBase ?? null) === (right.forkTitleBase ?? null) && (left.forkTitleOrdinal ?? null) === (right.forkTitleOrdinal ?? null) && (left.sidechatSourceThreadId ?? null) === (right.sidechatSourceThreadId ?? null) && @@ -439,6 +440,7 @@ function toThreadShell(thread: Thread): ThreadShell { subagentRole: thread.subagentRole ?? null, forkSourceThreadId: thread.forkSourceThreadId ?? null, forkSourceMessageId: thread.forkSourceMessageId ?? null, + forkTitleFamilyRootId: thread.forkTitleFamilyRootId ?? null, forkTitleBase: thread.forkTitleBase ?? null, forkTitleOrdinal: thread.forkTitleOrdinal ?? null, sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null, @@ -1755,6 +1757,7 @@ function normalizeThreadFromReadModel( previous.hasActionableProposedPlan === resolvedHasActionableProposedPlan && (previous.forkSourceThreadId ?? null) === (incoming.forkSourceThreadId ?? null) && (previous.forkSourceMessageId ?? null) === (incoming.forkSourceMessageId ?? null) && + (previous.forkTitleFamilyRootId ?? null) === (incoming.forkTitleFamilyRootId ?? null) && (previous.forkTitleBase ?? null) === (incoming.forkTitleBase ?? null) && (previous.forkTitleOrdinal ?? null) === (incoming.forkTitleOrdinal ?? null) && (previous.sidechatSourceThreadId ?? null) === (incoming.sidechatSourceThreadId ?? null) && @@ -1801,6 +1804,7 @@ function normalizeThreadFromReadModel( createBranchFlowCompleted: resolvedCreateBranchFlowCompleted, forkSourceThreadId: incoming.forkSourceThreadId ?? null, forkSourceMessageId: incoming.forkSourceMessageId ?? null, + forkTitleFamilyRootId: incoming.forkTitleFamilyRootId ?? null, forkTitleBase: incoming.forkTitleBase ?? null, forkTitleOrdinal: incoming.forkTitleOrdinal ?? null, sidechatSourceThreadId: incoming.sidechatSourceThreadId ?? null, @@ -1897,6 +1901,7 @@ function normalizeThreadShellSnapshot( subagentRole: incoming.subagentRole ?? null, forkSourceThreadId: incoming.forkSourceThreadId ?? null, forkSourceMessageId: incoming.forkSourceMessageId ?? null, + forkTitleFamilyRootId: incoming.forkTitleFamilyRootId ?? null, forkTitleBase: incoming.forkTitleBase ?? null, forkTitleOrdinal: incoming.forkTitleOrdinal ?? null, sidechatSourceThreadId: incoming.sidechatSourceThreadId ?? null, diff --git a/apps/web/src/test/effectRpcWebSocketMock.ts b/apps/web/src/test/effectRpcWebSocketMock.ts index 41f827b60..931499cf9 100644 --- a/apps/web/src/test/effectRpcWebSocketMock.ts +++ b/apps/web/src/test/effectRpcWebSocketMock.ts @@ -148,6 +148,7 @@ export function createShellSnapshotFromReadModel( subagentRole: thread.subagentRole ?? null, forkSourceThreadId: thread.forkSourceThreadId ?? null, forkSourceMessageId: thread.forkSourceMessageId ?? null, + forkTitleFamilyRootId: thread.forkTitleFamilyRootId ?? null, forkTitleBase: thread.forkTitleBase ?? null, forkTitleOrdinal: thread.forkTitleOrdinal ?? null, sidechatSourceThreadId: thread.sidechatSourceThreadId ?? null, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 13b77a4b8..70aa9a643 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -227,6 +227,7 @@ export interface Thread extends ThreadWorkspaceState { subagentRole?: string | null; forkSourceThreadId?: ThreadId | null; forkSourceMessageId?: MessageId | null; + forkTitleFamilyRootId?: ThreadId | null; forkTitleBase?: string | null; forkTitleOrdinal?: number | null; sidechatSourceThreadId?: ThreadId | null; @@ -266,6 +267,7 @@ export interface ThreadShell extends ThreadWorkspaceState { subagentRole?: string | null; forkSourceThreadId?: ThreadId | null; forkSourceMessageId?: MessageId | null; + forkTitleFamilyRootId?: ThreadId | null; forkTitleBase?: string | null; forkTitleOrdinal?: number | null; sidechatSourceThreadId?: ThreadId | null; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 6f916284b..b2326be2c 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -673,6 +673,9 @@ export const OrchestrationThread = Schema.Struct({ forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe( Schema.withDecodingDefault(() => null), ), + forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).pipe( + Schema.withDecodingDefault(() => null), + ), forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe( Schema.withDecodingDefault(() => null), ), @@ -749,6 +752,9 @@ export const OrchestrationThreadShell = Schema.Struct({ forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe( Schema.withDecodingDefault(() => null), ), + forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).pipe( + Schema.withDecodingDefault(() => null), + ), forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe( Schema.withDecodingDefault(() => null), ), @@ -1512,6 +1518,9 @@ export const ThreadCreatedPayload = Schema.Struct({ forkSourceMessageId: Schema.optional(Schema.NullOr(MessageId)).pipe( Schema.withDecodingDefault(() => null), ), + forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).pipe( + Schema.withDecodingDefault(() => null), + ), forkTitleBase: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)).pipe( Schema.withDecodingDefault(() => null), ), From 88cb48eab8dbb4f2fe7218ece70a354127bad658 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 00:25:21 +0300 Subject: [PATCH 08/11] Stabilize cold browser geometry startup --- apps/web/src/components/ChatView.browser.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index e0ef283d1..09d046f3b 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -1727,6 +1727,7 @@ async function measureUserRow(options: { const scrollContainer = await waitForElement( () => host.querySelector("[data-chat-scroll-container='true']"), "Unable to find ChatView message scroll container.", + 20_000, ); let row: HTMLElement | null = null; From d98550400230e6484c20d01d4f9b2aecc6897a82 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 00:42:54 +0300 Subject: [PATCH 09/11] Repair legacy fork title families --- ...ojectionThreadsForkTitleFamilyRoot.test.ts | 275 ++++++++++++++++++ ...59_ProjectionThreadsForkTitleFamilyRoot.ts | 60 +++- 2 files changed, 330 insertions(+), 5 deletions(-) diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts index 19a69c3b3..755c364b8 100644 --- a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts @@ -5,6 +5,7 @@ import { describe } from "vitest"; import { runMigrations } from "../Migrations.ts"; import * as NodeSqliteClient from "../NodeSqliteClient.ts"; +import { resolveNextForkTitle, type ForkTitleThread } from "../../orchestration/forkTitle.ts"; describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { it.effect("freezes descendant families across later and earlier ancestor renames", () => @@ -242,4 +243,278 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { ]); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); + + it.effect("reconstructs legacy fork families in creation order across a later rename", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 58 }); + const createdAt = "2026-07-22T11:00:00.000Z"; + + const insertLegacyThread = (input: { + readonly id: string; + readonly title: string; + readonly sourceId?: string; + }) => + Effect.gen(function* () { + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + env_mode, + create_branch_flow_completed, + is_pinned, + fork_source_thread_id, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + created_at, + updated_at + ) + VALUES ( + ${input.id}, + 'project-legacy', + ${input.title}, + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'local', + 0, + 0, + ${input.sourceId ?? null}, + 0, + 0, + 0, + ${createdAt}, + ${createdAt} + ) + `; + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + ${`event-created-${input.id}`}, + 'thread', + ${input.id}, + 1, + 'thread.created', + ${createdAt}, + ${`command-created-${input.id}`}, + NULL, + ${`command-created-${input.id}`}, + 'client', + ${JSON.stringify({ + threadId: input.id, + projectId: "project-legacy", + title: input.title, + forkSourceThreadId: input.sourceId ?? null, + createdAt, + updatedAt: createdAt, + })}, + '{}' + ) + `; + }); + + yield* insertLegacyThread({ id: "legacy-root", title: "Greeting" }); + yield* insertLegacyThread({ + id: "legacy-child", + title: "Greeting", + sourceId: "legacy-root", + }); + yield* insertLegacyThread({ + id: "legacy-grandchild", + title: "Greeting", + sourceId: "legacy-child", + }); + yield* insertLegacyThread({ + id: "legacy-sibling", + title: "Greeting", + sourceId: "legacy-root", + }); + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + 'event-title-legacy-child-2', + 'thread', + 'legacy-child', + 2, + 'thread.meta-updated', + ${createdAt}, + 'command-title-legacy-child-2', + NULL, + 'command-title-legacy-child-2', + 'client', + ${JSON.stringify({ + threadId: "legacy-child", + title: "Experiment", + updatedAt: createdAt, + })}, + '{}' + ) + `; + yield* sql` + UPDATE projection_threads + SET title = 'Experiment' + WHERE thread_id = 'legacy-child' + `; + yield* insertLegacyThread({ + id: "legacy-renamed-child", + title: "Experiment", + sourceId: "legacy-child", + }); + + yield* runMigrations(); + yield* runMigrations(); + + const createdRoots = yield* sql<{ + readonly id: string; + readonly familyRootId: string | null; + readonly base: string | null; + readonly ordinal: number | null; + }>` + SELECT + stream_id AS id, + json_extract(payload_json, '$.forkTitleFamilyRootId') AS "familyRootId", + json_extract(payload_json, '$.forkTitleBase') AS base, + json_extract(payload_json, '$.forkTitleOrdinal') AS ordinal + FROM orchestration_events + WHERE event_type = 'thread.created' + ORDER BY stream_id ASC + `; + assert.deepEqual(createdRoots, [ + { + id: "legacy-child", + familyRootId: "legacy-root", + base: "Greeting", + ordinal: 2, + }, + { + id: "legacy-grandchild", + familyRootId: "legacy-root", + base: "Greeting", + ordinal: 3, + }, + { + id: "legacy-renamed-child", + familyRootId: "legacy-child", + base: "Experiment", + ordinal: 2, + }, + { id: "legacy-root", familyRootId: null, base: null, ordinal: null }, + { + id: "legacy-sibling", + familyRootId: "legacy-root", + base: "Greeting", + ordinal: 4, + }, + ]); + + const projectedRoots = yield* sql<{ + readonly id: string; + readonly familyRootId: string | null; + readonly base: string | null; + readonly ordinal: number | null; + }>` + SELECT + thread_id AS id, + fork_title_family_root_id AS "familyRootId", + fork_title_base AS base, + fork_title_ordinal AS ordinal + FROM projection_threads + ORDER BY thread_id ASC + `; + assert.deepEqual(projectedRoots, [ + { id: "legacy-child", familyRootId: null, base: null, ordinal: null }, + { + id: "legacy-grandchild", + familyRootId: "legacy-root", + base: "Greeting", + ordinal: 3, + }, + { + id: "legacy-renamed-child", + familyRootId: "legacy-child", + base: "Experiment", + ordinal: 2, + }, + { id: "legacy-root", familyRootId: null, base: null, ordinal: null }, + { + id: "legacy-sibling", + familyRootId: "legacy-root", + base: "Greeting", + ordinal: 4, + }, + ]); + + const allocatorRows = yield* sql<{ + readonly id: string; + readonly projectId: string; + readonly title: string; + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly forkTitleFamilyRootId: string | null; + readonly forkTitleBase: string | null; + readonly forkTitleOrdinal: number | null; + }>` + SELECT + thread_id AS id, + project_id AS "projectId", + title, + fork_source_thread_id AS "forkSourceThreadId", + sidechat_source_thread_id AS "sidechatSourceThreadId", + fork_title_family_root_id AS "forkTitleFamilyRootId", + fork_title_base AS "forkTitleBase", + fork_title_ordinal AS "forkTitleOrdinal" + FROM projection_threads + `; + const allocatorThreads = allocatorRows satisfies ReadonlyArray; + for (const sourceId of ["legacy-grandchild", "legacy-sibling"]) { + const sourceThread = allocatorThreads.find((thread) => thread.id === sourceId); + if (!sourceThread) { + throw new Error(`Expected migrated source thread ${sourceId}`); + } + assert.strictEqual( + resolveNextForkTitle({ sourceThread, threads: allocatorThreads }).title, + "Greeting (5)", + ); + } + const renamedSource = allocatorThreads.find((thread) => thread.id === "legacy-renamed-child"); + if (!renamedSource) { + throw new Error("Expected migrated renamed-family source thread"); + } + assert.strictEqual( + resolveNextForkTitle({ sourceThread: renamedSource, threads: allocatorThreads }).title, + "Experiment (3)", + ); + }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), + ); }); diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts index b12817f2e..51188f83e 100644 --- a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts @@ -19,6 +19,7 @@ interface ForkTitleEventRow { readonly forkTitleFamilyRootId: string | null; readonly forkTitleBase: string | null; readonly forkTitleOrdinal: number | null; + readonly currentProjectionTitle: string | null; } interface EventThreadState { @@ -92,6 +93,22 @@ function inferGeneratedForkSeries(input: { : null; } +function inferLegacyForkSeries(input: { + readonly title: string; + readonly forkSourceThreadId: string | null; + readonly sidechatSourceThreadId: string | null; + readonly statesByThreadId: ReadonlyMap; +}): { readonly base: string } | null { + if (!input.forkSourceThreadId || input.sidechatSourceThreadId) { + return null; + } + const source = input.statesByThreadId.get(input.forkSourceThreadId); + if (!source || input.title !== source.currentTitle) { + return null; + } + return { base: source.automaticBase ?? source.currentTitle }; +} + function resolveEventFamilyRoot(input: { readonly sourceThreadId: string; readonly forkTitleBase: string; @@ -165,7 +182,12 @@ export default Effect.gen(function* () { json_extract(payload_json, '$.sidechatSourceThreadId') AS "sidechatSourceThreadId", json_extract(payload_json, '$.forkTitleFamilyRootId') AS "forkTitleFamilyRootId", json_extract(payload_json, '$.forkTitleBase') AS "forkTitleBase", - json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal" + json_extract(payload_json, '$.forkTitleOrdinal') AS "forkTitleOrdinal", + ( + SELECT projection_threads.title + FROM projection_threads + WHERE projection_threads.thread_id = orchestration_events.stream_id + ) AS "currentProjectionTitle" FROM orchestration_events WHERE event_type IN ('thread.created', 'thread.meta-updated') AND json_valid(payload_json) @@ -173,6 +195,12 @@ export default Effect.gen(function* () { `; const statesByThreadId = new Map(); const eventBackedThreadIds = new Set(); + const highestOrdinalBySeries = new Map(); + const threadIdsWithTitleEvents = new Set( + eventRows + .filter((row) => row.eventType === "thread.meta-updated" && row.title !== null) + .map((row) => row.threadId), + ); for (const row of eventRows) { if (row.eventType === "thread.meta-updated") { @@ -197,17 +225,39 @@ export default Effect.gen(function* () { sidechatSourceThreadId: row.sidechatSourceThreadId, statesByThreadId, }); - const automatic = isAutomaticFork(row) || inferredSeries !== null; - const automaticBase = inferredSeries?.base ?? (automatic ? row.forkTitleBase : null); - const automaticOrdinal = inferredSeries?.ordinal ?? (automatic ? row.forkTitleOrdinal : null); + const hasUnrecordedProjectionRename = + row.currentProjectionTitle !== null && + row.currentProjectionTitle !== row.title && + !threadIdsWithTitleEvents.has(row.threadId); + const legacySeries = hasUnrecordedProjectionRename + ? null + : inferLegacyForkSeries({ + title: row.title, + forkSourceThreadId: row.forkSourceThreadId, + sidechatSourceThreadId: row.sidechatSourceThreadId, + statesByThreadId, + }); + const automatic = isAutomaticFork(row) || inferredSeries !== null || legacySeries !== null; + const automaticBase = + legacySeries?.base ?? inferredSeries?.base ?? (automatic ? row.forkTitleBase : null); const familyRootId = automatic - ? (row.forkTitleFamilyRootId ?? + ? ((legacySeries === null ? row.forkTitleFamilyRootId : null) ?? resolveEventFamilyRoot({ sourceThreadId: row.forkSourceThreadId!, forkTitleBase: automaticBase!, statesByThreadId, })) : null; + const seriesKey = automatic + ? JSON.stringify([row.projectId, familyRootId, automaticBase]) + : null; + const highestOrdinal = seriesKey ? (highestOrdinalBySeries.get(seriesKey) ?? 1) : 1; + const automaticOrdinal = legacySeries + ? highestOrdinal + 1 + : (inferredSeries?.ordinal ?? (automatic ? row.forkTitleOrdinal : null)); + if (seriesKey && automaticOrdinal !== null) { + highestOrdinalBySeries.set(seriesKey, Math.max(highestOrdinal, automaticOrdinal)); + } statesByThreadId.set(row.threadId, { threadId: row.threadId, projectId: row.projectId, From 8baefcc7933b9ad18c1ba0d062c4ac3b0d95a908 Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 00:54:18 +0300 Subject: [PATCH 10/11] Preserve unrecorded fork rename boundaries --- ...ojectionThreadsForkTitleFamilyRoot.test.ts | 60 +++++++++++++++++++ ...59_ProjectionThreadsForkTitleFamilyRoot.ts | 14 ++--- 2 files changed, 67 insertions(+), 7 deletions(-) diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts index 755c364b8..7b9d96056 100644 --- a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts @@ -391,6 +391,51 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { title: "Experiment", sourceId: "legacy-child", }); + yield* insertLegacyThread({ id: "noop-root", title: "Greeting" }); + yield* insertLegacyThread({ + id: "noop-child", + title: "Greeting", + sourceId: "noop-root", + }); + yield* sql` + INSERT INTO orchestration_events ( + event_id, + aggregate_kind, + stream_id, + stream_version, + event_type, + occurred_at, + command_id, + causation_event_id, + correlation_id, + actor_kind, + payload_json, + metadata_json + ) + VALUES ( + 'event-title-noop-child-2', + 'thread', + 'noop-child', + 2, + 'thread.meta-updated', + ${createdAt}, + 'command-title-noop-child-2', + NULL, + 'command-title-noop-child-2', + 'client', + ${JSON.stringify({ + threadId: "noop-child", + title: "Greeting", + updatedAt: createdAt, + })}, + '{}' + ) + `; + yield* sql` + UPDATE projection_threads + SET title = 'Experiment' + WHERE thread_id = 'noop-child' + `; yield* runMigrations(); yield* runMigrations(); @@ -436,6 +481,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { base: "Greeting", ordinal: 4, }, + { id: "noop-child", familyRootId: null, base: null, ordinal: null }, + { id: "noop-root", familyRootId: null, base: null, ordinal: null }, ]); const projectedRoots = yield* sql<{ @@ -473,6 +520,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { base: "Greeting", ordinal: 4, }, + { id: "noop-child", familyRootId: null, base: null, ordinal: null }, + { id: "noop-root", familyRootId: null, base: null, ordinal: null }, ]); const allocatorRows = yield* sql<{ @@ -515,6 +564,17 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { resolveNextForkTitle({ sourceThread: renamedSource, threads: allocatorThreads }).title, "Experiment (3)", ); + const projectionRenamedSource = allocatorThreads.find((thread) => thread.id === "noop-child"); + if (!projectionRenamedSource) { + throw new Error("Expected migrated projection-renamed source thread"); + } + assert.strictEqual( + resolveNextForkTitle({ + sourceThread: projectionRenamedSource, + threads: allocatorThreads, + }).title, + "Experiment (2)", + ); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); }); diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts index 51188f83e..a4ab718f1 100644 --- a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts @@ -196,11 +196,12 @@ export default Effect.gen(function* () { const statesByThreadId = new Map(); const eventBackedThreadIds = new Set(); const highestOrdinalBySeries = new Map(); - const threadIdsWithTitleEvents = new Set( - eventRows - .filter((row) => row.eventType === "thread.meta-updated" && row.title !== null) - .map((row) => row.threadId), - ); + const finalEventTitleByThreadId = new Map(); + for (const row of eventRows) { + if (row.title !== null) { + finalEventTitleByThreadId.set(row.threadId, row.title); + } + } for (const row of eventRows) { if (row.eventType === "thread.meta-updated") { @@ -227,8 +228,7 @@ export default Effect.gen(function* () { }); const hasUnrecordedProjectionRename = row.currentProjectionTitle !== null && - row.currentProjectionTitle !== row.title && - !threadIdsWithTitleEvents.has(row.threadId); + row.currentProjectionTitle !== finalEventTitleByThreadId.get(row.threadId); const legacySeries = hasUnrecordedProjectionRename ? null : inferLegacyForkSeries({ From 7cdd34e8bc838b6458b57d92a2c6a0a4c4cb89fe Mon Sep 17 00:00:00 2001 From: Yaacov Date: Thu, 23 Jul 2026 01:02:12 +0300 Subject: [PATCH 11/11] Clear drifted fork title lineage --- ...ojectionThreadsForkTitleFamilyRoot.test.ts | 38 +++++++++++++++++++ ...59_ProjectionThreadsForkTitleFamilyRoot.ts | 33 ++++++++++++---- 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts index 7b9d96056..ab77920b8 100644 --- a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts @@ -254,6 +254,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { readonly id: string; readonly title: string; readonly sourceId?: string; + readonly base?: string; + readonly ordinal?: number; }) => Effect.gen(function* () { yield* sql` @@ -268,6 +270,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { create_branch_flow_completed, is_pinned, fork_source_thread_id, + fork_title_base, + fork_title_ordinal, pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, @@ -285,6 +289,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { 0, 0, ${input.sourceId ?? null}, + ${input.base ?? null}, + ${input.ordinal ?? null}, 0, 0, 0, @@ -323,6 +329,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { projectId: "project-legacy", title: input.title, forkSourceThreadId: input.sourceId ?? null, + forkTitleBase: input.base ?? null, + forkTitleOrdinal: input.ordinal ?? null, createdAt, updatedAt: createdAt, })}, @@ -436,6 +444,19 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { SET title = 'Experiment' WHERE thread_id = 'noop-child' `; + yield* insertLegacyThread({ id: "numbered-root", title: "Greeting" }); + yield* insertLegacyThread({ + id: "numbered-child", + title: "Greeting (7)", + sourceId: "numbered-root", + base: "Greeting", + ordinal: 7, + }); + yield* sql` + UPDATE projection_threads + SET title = 'Experiment' + WHERE thread_id = 'numbered-child' + `; yield* runMigrations(); yield* runMigrations(); @@ -483,6 +504,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { }, { id: "noop-child", familyRootId: null, base: null, ordinal: null }, { id: "noop-root", familyRootId: null, base: null, ordinal: null }, + { id: "numbered-child", familyRootId: null, base: null, ordinal: null }, + { id: "numbered-root", familyRootId: null, base: null, ordinal: null }, ]); const projectedRoots = yield* sql<{ @@ -522,6 +545,8 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { }, { id: "noop-child", familyRootId: null, base: null, ordinal: null }, { id: "noop-root", familyRootId: null, base: null, ordinal: null }, + { id: "numbered-child", familyRootId: null, base: null, ordinal: null }, + { id: "numbered-root", familyRootId: null, base: null, ordinal: null }, ]); const allocatorRows = yield* sql<{ @@ -575,6 +600,19 @@ describe("059_ProjectionThreadsForkTitleFamilyRoot", () => { }).title, "Experiment (2)", ); + const projectionRenamedNumberedSource = allocatorThreads.find( + (thread) => thread.id === "numbered-child", + ); + if (!projectionRenamedNumberedSource) { + throw new Error("Expected migrated pre-numbered projection-renamed source thread"); + } + assert.strictEqual( + resolveNextForkTitle({ + sourceThread: projectionRenamedNumberedSource, + threads: allocatorThreads, + }).title, + "Experiment (2)", + ); }).pipe(Effect.provide(NodeSqliteClient.layerMemory())), ); }); diff --git a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts index a4ab718f1..19439359b 100644 --- a/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts @@ -237,9 +237,12 @@ export default Effect.gen(function* () { sidechatSourceThreadId: row.sidechatSourceThreadId, statesByThreadId, }); - const automatic = isAutomaticFork(row) || inferredSeries !== null || legacySeries !== null; - const automaticBase = - legacySeries?.base ?? inferredSeries?.base ?? (automatic ? row.forkTitleBase : null); + const automatic = + !hasUnrecordedProjectionRename && + (isAutomaticFork(row) || inferredSeries !== null || legacySeries !== null); + const automaticBase = automatic + ? (legacySeries?.base ?? inferredSeries?.base ?? row.forkTitleBase) + : null; const familyRootId = automatic ? ((legacySeries === null ? row.forkTitleFamilyRootId : null) ?? resolveEventFamilyRoot({ @@ -252,9 +255,11 @@ export default Effect.gen(function* () { ? JSON.stringify([row.projectId, familyRootId, automaticBase]) : null; const highestOrdinal = seriesKey ? (highestOrdinalBySeries.get(seriesKey) ?? 1) : 1; - const automaticOrdinal = legacySeries - ? highestOrdinal + 1 - : (inferredSeries?.ordinal ?? (automatic ? row.forkTitleOrdinal : null)); + const automaticOrdinal = automatic + ? legacySeries + ? highestOrdinal + 1 + : (inferredSeries?.ordinal ?? row.forkTitleOrdinal) + : null; if (seriesKey && automaticOrdinal !== null) { highestOrdinalBySeries.set(seriesKey, Math.max(highestOrdinal, automaticOrdinal)); } @@ -270,7 +275,21 @@ export default Effect.gen(function* () { familyRootId, }); - if (familyRootId) { + if (hasUnrecordedProjectionRename) { + yield* sql` + UPDATE orchestration_events + SET payload_json = json_set( + payload_json, + '$.forkTitleFamilyRootId', + NULL, + '$.forkTitleBase', + NULL, + '$.forkTitleOrdinal', + NULL + ) + WHERE sequence = ${row.sequence} + `; + } else if (familyRootId) { yield* sql` UPDATE orchestration_events SET payload_json = json_set(