diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index dfafa44b..4ade972b 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(); } @@ -122,6 +154,317 @@ 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("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(); + 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 66506361..23108d00 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> => @@ -223,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, @@ -381,6 +421,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 81381d59..e1375a01 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; @@ -1463,6 +1566,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 +1680,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 +1831,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 +1979,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 6dfe46bf..14596ce3 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,10 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { subagentNickname: event.payload.subagentNickname ?? null, 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, lastKnownPr: event.payload.lastKnownPr ?? null, latestTurnId: null, @@ -694,9 +689,21 @@ 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 + ? { + forkTitleFamilyRootId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + } + : {}), ...(event.payload.modelSelection !== undefined ? { modelSelection: event.payload.modelSelection } : {}), @@ -957,6 +964,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 +1188,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 +1227,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 +1952,7 @@ const makeOrchestrationProjectionPipeline = Effect.gen(function* () { const attachmentSideEffects: AttachmentSideEffects = { deletedThreadIds: new Set(), prunedThreadRelativePaths: new Map>(), + orphanCandidateRelativePaths: new Set(), }; yield* Effect.forEach( @@ -1969,7 +1990,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 d5bd8784..d196ca91 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,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentAgentId: null, subagentNickname: null, subagentRole: null, - forkSourceThreadId: null, + forkSourceThreadId: ThreadId.makeUnsafe("thread-source"), + forkSourceMessageId: MessageId.makeUnsafe("message-source-boundary"), + forkTitleFamilyRootId: null, + forkTitleBase: "Thread 1", + forkTitleOrdinal: 2, sidechatSourceThreadId: null, lastKnownPr: null, latestUserMessageAt: "2026-02-24T00:00:03.500Z", @@ -1375,6 +1387,10 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + forkSourceMessageId: null, + forkTitleFamilyRootId: 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 c45d6c39..b998dd8a 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -592,6 +592,10 @@ function toProjectedThreadShell(input: { subagentNickname: threadRow.subagentNickname ?? null, 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, lastKnownPr: threadRow.lastKnownPr, latestTurn: input.latestTurn, @@ -633,6 +637,10 @@ function toProjectedThreadShellFromStoredSummary(input: { subagentNickname: threadRow.subagentNickname ?? null, 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, lastKnownPr: threadRow.lastKnownPr, latestTurn: input.latestTurn, @@ -679,6 +687,10 @@ function toProjectedThread(input: { subagentNickname: threadRow.subagentNickname ?? null, 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, lastKnownPr: threadRow.lastKnownPr, latestTurn: input.latestTurn, @@ -785,6 +797,10 @@ 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_family_root_id AS "forkTitleFamilyRootId", + 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 +843,10 @@ 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_family_root_id AS "forkTitleFamilyRootId", + 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 +1195,10 @@ 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_family_root_id AS "forkTitleFamilyRootId", + 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 +1246,10 @@ 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_family_root_id AS "forkTitleFamilyRootId", + 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 3975700a..ac04f7b3 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,316 @@ 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(); + 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({ + 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", + attachments: importedAttachments.slice(0, 8), + createdAt: now, + updatedAt: now, + }, + { + messageId: sourceBoundaryId, + role: "assistant", + text: "Answer at the branch boundary", + attachments: importedAttachments.slice(8), + createdAt: now, + updatedAt: now, + }, + { + 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, + }, + ], + 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-00000000-prefix-user"), + role: "user", + text: "Question before the branch", + attachments: importedAttachments.slice(0, 8), + createdAt: now, + updatedAt: now, + }, + { + messageId: asMessageId("fork-00000001-boundary-assistant"), + role: "assistant", + text: "Answer at the branch boundary", + attachments: importedAttachments.slice(8), + 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: [currentAttachment], + }, + 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; 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?.input).toContain("before-9.txt"); + expect(input?.attachments?.map((attachment) => attachment.id)).toEqual([ + 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()); + 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", + attachments: [ + { + type: "file", + id: "attachment-before-restart", + name: "restart.txt", + mimeType: "text/plain", + sizeBytes: 15, + }, + ], + 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-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-00000001-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; 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 () => { const threadId = ThreadId.makeUnsafe("thread-native-droid-sidechat"); const harness = await createHarness({ @@ -1272,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: { @@ -1299,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, }), @@ -1324,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", @@ -1360,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", @@ -1397,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"); }); @@ -1669,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(); @@ -2299,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 a8383ce7..2ec509bf 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, @@ -61,11 +62,14 @@ import { ServerConfig } from "../../config.ts"; import { buildScientBuiltInSkillTriggerInstructions } from "../../scientBuiltInSkills.ts"; import { clearWorkspaceIndexCache } from "../../workspaceEntries.ts"; import { + buildImportedForkAttachmentManifest, + buildMessageForkBootstrapText, buildPriorTranscriptBootstrapText, buildForkBootstrapText, buildHandoffBootstrapText, hasNativeAssistantMessagesBefore, listImportedForkMessages, + listImportedForkProviderAttachments, listPriorTranscriptMessages, } from "../handoff.ts"; import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts"; @@ -143,6 +147,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"] { @@ -372,6 +394,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(); @@ -388,6 +414,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); }; @@ -408,6 +435,7 @@ const make = Effect.gen(function* () { } if (attempt.clearPriorTranscript) { freshSessionContextBootstrapThreadIds.delete(threadId); + initialMessageForkContextBootstrapThreadIds.delete(threadId); rollbackContextBootstrapThreadIds.delete(threadId); sidechatContextBootstrapThreadIds.delete(threadId); } @@ -794,6 +822,7 @@ const make = Effect.gen(function* () { readonly modelSelection?: ModelSelection; readonly providerOptions?: ProviderStartOptions; readonly runtimeMode?: RuntimeMode; + readonly initialMessageForkContextBootstrap?: boolean; }, ) { const thread = yield* resolveThread(threadId); @@ -805,6 +834,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)( @@ -948,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, @@ -961,7 +1001,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 +1048,14 @@ 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); + registerInitialMessageForkContextBootstrap(); + } + if ( shouldRegisterContextBootstrap && thread.sidechatSourceThreadId && @@ -1051,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); @@ -1092,6 +1147,9 @@ const make = Effect.gen(function* () { const hasPendingPriorTranscriptBootstrap = freshSessionContextBootstrapThreadIds.has(input.threadId) || rollbackContextBootstrapThreadIds.has(input.threadId); + const isPendingMessageBoundaryForkBootstrap = + hasPendingPriorTranscriptBootstrap && + initialMessageForkContextBootstrapThreadIds.has(input.threadId); const shouldBootstrapSidechatContext = thread.sidechatSourceThreadId !== null && sidechatContextBootstrapThreadIds.has(input.threadId) && @@ -1130,7 +1188,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, @@ -1153,12 +1213,28 @@ 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 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", @@ -1228,7 +1304,24 @@ const make = Effect.gen(function* () { ...(input.skills !== undefined ? { skills: input.skills } : {}), }), ); - const normalizedAttachments = input.attachments ?? []; + 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( @@ -1362,16 +1455,33 @@ const make = Effect.gen(function* () { ? { providerOptions: input.providerOptions } : {}), ...(input.runtimeMode !== undefined ? { runtimeMode: input.runtimeMode } : {}), + initialMessageForkContextBootstrap: isInitialMessageForkContextBootstrap( + thread, + input.messageId, + ), }); 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", @@ -1460,6 +1570,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/assistantMessageLifecycle.ts b/apps/server/src/orchestration/assistantMessageLifecycle.ts index 4599b970..d0742fc1 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 00000000..984d3b4b --- /dev/null +++ b/apps/server/src/orchestration/decider.forkTitle.test.ts @@ -0,0 +1,244 @@ +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 renamedThread = renamedModel.threads.find((thread) => thread.id === "fork-2"); + expect(renamedThread?.forkTitleBase).toBeNull(); + expect(renamedThread?.forkTitleOrdinal).toBeNull(); + + 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, + }); + + 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 () => { + 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 c99c53f0..5471ed9e 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 { @@ -28,6 +29,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 +52,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 +476,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 +513,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 +570,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 +637,49 @@ 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, + forkTitleFamilyRootId: null, + forkTitleBase: null, + forkTitleOrdinal: null, + } + : resolveNextForkTitle({ + sourceThread, + threads: readModel.threads, + }); + const createdEvent: Omit = { ...withEventBase({ aggregateKind: "thread", @@ -625,7 +691,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 +718,13 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" subagentNickname: null, 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, 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 00000000..6b63cc87 --- /dev/null +++ b/apps/server/src/orchestration/forkTitle.test.ts @@ -0,0 +1,282 @@ +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, + forkTitleFamilyRootId: 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)", + 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, + }); + }); + + 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, + 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, + }); + }); + + 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: null, + forkTitleOrdinal: null, + }); + 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)", + forkTitleFamilyRootId: "fork-2", + forkTitleBase: "Experiment", + forkTitleOrdinal: 2, + }); + expect( + resolveNextForkTitle({ + sourceThread: renamedFork, + threads: [root, renamedFork, experiment2], + }), + ).toEqual({ + title: "Experiment (3)", + forkTitleFamilyRootId: "fork-2", + forkTitleBase: "Experiment", + forkTitleOrdinal: 3, + }); + }); + + it("keeps independently renamed siblings in separate families", () => { + const root = thread("root", "Greeting"); + const renamedA = thread("fork-a", "Experiment", { + forkSourceThreadId: root.id, + forkTitleBase: null, + forkTitleOrdinal: null, + }); + const renamedB = thread("fork-b", "Experiment", { + forkSourceThreadId: root.id, + forkTitleBase: null, + forkTitleOrdinal: null, + }); + 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: null, + forkTitleOrdinal: null, + }); + 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("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)", { + 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: null, + forkTitleOrdinal: null, + }); + + 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 00000000..d62a1339 --- /dev/null +++ b/apps/server/src/orchestration/forkTitle.ts @@ -0,0 +1,113 @@ +// 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 forkTitleFamilyRootId?: 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 forkTitleFamilyRootId: 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 usesAutomaticForkTitle(thread: ForkTitleThread): thread is ForkTitleThread & { + readonly forkTitleBase: string; + readonly forkTitleOrdinal: number; +} { + return ( + thread.forkTitleBase !== null && + thread.forkTitleBase !== undefined && + isStoredForkOrdinal(thread.forkTitleOrdinal) + ); +} + +function resolveLegacyForkFamilyRootId( + thread: ForkTitleThread, + threadsById: ReadonlyMap, +): string { + let current = thread; + const visited = new Set(); + + // 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; + } + 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 forkTitleBase = usesAutomaticForkTitle(source) ? source.forkTitleBase : source.title; + const forkTitleFamilyRootId = usesAutomaticForkTitle(source) + ? (source.forkTitleFamilyRootId ?? resolveLegacyForkFamilyRootId(source, threadsById)) + : source.id; + + let highestOrdinal = 1; + for (const thread of input.threads) { + if (!usesAutomaticForkTitle(thread)) { + continue; + } + if ( + thread.projectId !== source.projectId || + thread.sidechatSourceThreadId || + thread.forkTitleBase !== forkTitleBase || + (thread.forkTitleFamilyRootId ?? resolveLegacyForkFamilyRootId(thread, threadsById)) !== + forkTitleFamilyRootId + ) { + 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), + forkTitleFamilyRootId, + forkTitleBase, + forkTitleOrdinal, + }; +} diff --git a/apps/server/src/orchestration/handoff.test.ts b/apps/server/src/orchestration/handoff.test.ts index b76c3c41..9d6b093c 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,49 @@ 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([]); + }); + + 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 80babc43..d6d67cae 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" + ? [`[Selected assistant text: ${normalizeMessageText(attachment.text)}]`] + : [`[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,36 @@ export function listImportedForkMessages( ); } +export function listImportedForkProviderAttachments( + thread: Pick, +): ReadonlyArray> { + const attachmentsById = new Map< + string, + Exclude + >(); + 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 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) => @@ -131,7 +172,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 +190,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 +265,25 @@ 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; + } + + const attachmentManifest = buildImportedForkAttachmentManifest(thread); + + return buildImportedMessagesBootstrapText({ + thread, + importedMessages, + 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/messageFork.test.ts b/apps/server/src/orchestration/messageFork.test.ts new file mode 100644 index 00000000..cd9b440f --- /dev/null +++ b/apps/server/src/orchestration/messageFork.test.ts @@ -0,0 +1,400 @@ +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 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 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 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: [ + { ...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({ + 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({ + 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 00000000..903fb064 --- /dev/null +++ b/apps/server/src/orchestration/messageFork.ts @@ -0,0 +1,248 @@ +// 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 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, + ); + const requestedUserIndex = thread.messages.findIndex( + (message) => message.role === "user" && message.createdAt === thread.latestTurn?.requestedAt, + ); + + 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 + // 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 && + thread.latestTurn?.turnId === message.turnId && + thread.latestTurn.state === "running" + ) { + 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 runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(input.sourceThread); + 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, + sourceMessageIndex, + runningTurnBoundaryIndex, + ) + ) { + return { + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }; + } + + const conversationPrefix = input.sourceThread.messages + .slice(0, sourceMessageIndex + 1) + .filter( + (message): message is OrchestrationMessage & { readonly role: "user" | "assistant" } => + message.role === "user" || message.role === "assistant", + ); + if ( + conversationPrefix.some((message) => { + const messageIndex = input.sourceThread.messages.indexOf(message); + return !isCompletedConversationMessage( + input.sourceThread, + message, + messageIndex, + runningTurnBoundaryIndex, + ); + }) + ) { + return { + ok: false, + reason: "invalid-source", + expectedImportedMessageCount: 0, + }; + } + + 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 && + 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 23dbc79c..01ec91c0 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -174,6 +174,10 @@ describe("orchestration projector", () => { subagentNickname: null, subagentRole: null, forkSourceThreadId: null, + forkSourceMessageId: null, + forkTitleFamilyRootId: null, + forkTitleBase: null, + forkTitleOrdinal: null, sidechatSourceThreadId: null, lastKnownPr: null, latestTurn: null, @@ -191,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 dd35edea..6b8dacd6 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -403,6 +403,10 @@ export function projectEvent( subagentNickname: payload.subagentNickname, subagentRole: payload.subagentRole, forkSourceThreadId: payload.forkSourceThreadId, + forkSourceMessageId: payload.forkSourceMessageId, + forkTitleFamilyRootId: payload.forkTitleFamilyRootId, + forkTitleBase: payload.forkTitleBase, + forkTitleOrdinal: payload.forkTitleOrdinal, sidechatSourceThreadId: payload.sidechatSourceThreadId, lastKnownPr: payload.lastKnownPr ?? null, latestTurn: null, @@ -480,10 +484,23 @@ 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 + ? { + 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 9055aa78..b2acd617 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -67,6 +67,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { subagent_nickname, 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, last_known_pr_json, latest_turn_id, @@ -103,6 +107,10 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.subagentNickname ?? null}, ${row.subagentRole ?? null}, ${row.forkSourceThreadId ?? null}, + ${row.forkSourceMessageId ?? null}, + ${row.forkTitleFamilyRootId ?? null}, + ${row.forkTitleBase ?? null}, + ${row.forkTitleOrdinal ?? null}, ${row.sidechatSourceThreadId ?? null}, ${row.lastKnownPr === null ? null : JSON.stringify(row.lastKnownPr)}, ${row.latestTurnId}, @@ -139,6 +147,10 @@ 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_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, last_known_pr_json = excluded.last_known_pr_json, latest_turn_id = excluded.latest_turn_id, @@ -182,6 +194,10 @@ 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_family_root_id AS "forkTitleFamilyRootId", + 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 +243,10 @@ 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_family_root_id AS "forkTitleFamilyRootId", + 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 8ef19dd5..7aceac9d 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -71,6 +71,10 @@ 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"; +import Migration0058 from "./Migrations/058_ClearRenamedForkTitleLineage.ts"; +import Migration0059 from "./Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts"; /** * Migration loader with all migrations defined inline. @@ -138,6 +142,10 @@ export const migrationEntries = [ [53, "BackfillThreadActivitySequence", Migration0053], [54, "ProjectPullRequestPins", Migration0054], [55, "ProjectionThreadSessionLastErrorEvent", Migration0055], + [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/056_ProjectionThreadsForkSourceMessage.test.ts b/apps/server/src/persistence/Migrations/056_ProjectionThreadsForkSourceMessage.test.ts new file mode 100644 index 00000000..fffd0b99 --- /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 00000000..0fdd46b7 --- /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 00000000..91bb84b8 --- /dev/null +++ b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.test.ts @@ -0,0 +1,317 @@ +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"; +import { resolveNextForkTitle, type ForkTitleThread } from "../../orchestration/forkTitle.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: null, + forkTitleOrdinal: null, + }, + { + id: "fork-3", + title: "Greeting", + forkTitleBase: "Greeting", + forkTitleOrdinal: 2, + }, + { + 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 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; + 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: 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 }, + ]); + }).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 00000000..8ec9eb9a --- /dev/null +++ b/apps/server/src/persistence/Migrations/057_ProjectionThreadsForkTitleSequence.ts @@ -0,0 +1,208 @@ +// 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; + readonly currentProjectionTitle?: string | null; + readonly hasHistoricalTitleChange?: number; +} + +const familyRootId = ( + row: ThreadCreatedRow, + rowsByThreadId: ReadonlyMap, +): string => { + let current = row; + const visited = new Set(); + + while (current.forkSourceThreadId && current.hasHistoricalTitleChange !== 1) { + 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 + 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 = 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(); + + for (const row of createdRows) { + if ( + !row.projectId || + !row.title || + !row.forkSourceThreadId || + row.sidechatSourceThreadId || + row.hasHistoricalTitleChange === 1 || + (row.currentProjectionTitle !== null && + row.currentProjectionTitle !== undefined && + row.currentProjectionTitle !== row.title) + ) { + 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 eventBackedThreadIds = new Set(createdRows.map((row) => row.threadId)); + 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 || + eventBackedThreadIds.has(row.threadId) || + (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/Migrations/058_ClearRenamedForkTitleLineage.test.ts b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts new file mode 100644 index 00000000..bdc1190c --- /dev/null +++ b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.test.ts @@ -0,0 +1,236 @@ +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"; +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("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"; + + 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, + })}, + '{}' + ) + `; + }); + + const insertTitleEvent = (input: { + readonly threadId: string; + readonly version: number; + readonly title: string; + }) => + 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-${input.threadId}-${input.version}`}, + 'thread', + ${input.threadId}, + ${input.version}, + 'thread.meta-updated', + ${createdAt}, + ${`command-title-${input.threadId}-${input.version}`}, + NULL, + ${`command-title-${input.threadId}-${input.version}`}, + 'client', + ${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(); + 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 + `; + 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 new file mode 100644 index 00000000..8be4af17 --- /dev/null +++ b/apps/server/src/persistence/Migrations/058_ClearRenamedForkTitleLineage.ts @@ -0,0 +1,81 @@ +// 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"; + +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 + // 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 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/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts new file mode 100644 index 00000000..ab77920b --- /dev/null +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.test.ts @@ -0,0 +1,618 @@ +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"; +import { resolveNextForkTitle, type ForkTitleThread } from "../../orchestration/forkTitle.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())), + ); + + 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; + 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-legacy', + ${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-legacy", + title: input.title, + forkSourceThreadId: input.sourceId ?? null, + forkTitleBase: input.base ?? null, + forkTitleOrdinal: input.ordinal ?? 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* 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* 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(); + + 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, + }, + { 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<{ + 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, + }, + { 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<{ + 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)", + ); + 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)", + ); + 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 new file mode 100644 index 00000000..19439359 --- /dev/null +++ b/apps/server/src/persistence/Migrations/059_ProjectionThreadsForkTitleFamilyRoot.ts @@ -0,0 +1,351 @@ +// 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; + readonly currentProjectionTitle: string | 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 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; + 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", + ( + 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) + ORDER BY sequence ASC + `; + const statesByThreadId = new Map(); + const eventBackedThreadIds = new Set(); + const highestOrdinalBySeries = new Map(); + 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") { + 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 hasUnrecordedProjectionRename = + row.currentProjectionTitle !== null && + row.currentProjectionTitle !== finalEventTitleByThreadId.get(row.threadId); + const legacySeries = hasUnrecordedProjectionRename + ? null + : inferLegacyForkSeries({ + title: row.title, + forkSourceThreadId: row.forkSourceThreadId, + sidechatSourceThreadId: row.sidechatSourceThreadId, + statesByThreadId, + }); + 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({ + 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 = automatic + ? legacySeries + ? highestOrdinal + 1 + : (inferredSeries?.ordinal ?? row.forkTitleOrdinal) + : null; + if (seriesKey && automaticOrdinal !== null) { + highestOrdinalBySeries.set(seriesKey, Math.max(highestOrdinal, automaticOrdinal)); + } + 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 (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( + 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 d274e958..a8e3d62e 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,10 @@ 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)), + 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)), 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 10f598ac..09d046f3 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: { @@ -1325,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( @@ -1333,7 +1482,7 @@ async function waitForElement( expect(element, errorMessage).toBeTruthy(); }, { - timeout: 8_000, + timeout, interval: 16, }, ); @@ -1578,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; @@ -1788,6 +1938,267 @@ 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.", + 20_000, + ); + 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("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({ + 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 e554873f..e8676639 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -544,6 +544,7 @@ import { collapseCursorModelVariants } from "../cursorModelVariants"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { canCreateThreadHandoff, + resolveThreadForkableMessageIds, resolveAvailableHandoffTargetProviders, resolveThreadHandoffBadgeLabel, } from "../lib/threadHandoff"; @@ -3110,6 +3111,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; @@ -9531,6 +9536,7 @@ export default function ChatView({ ); const { + handleForkFromMessage, handleForkTargetSelection, handleReviewTargetSelection, isSlashStatusDialogOpen, @@ -11217,6 +11223,8 @@ export default function ChatView({ onRevertUserMessage={onRevertUserMessage} 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 c2a87331..c1069a10 100644 --- a/apps/web/src/components/chat/ChatTranscriptPane.tsx +++ b/apps/web/src/components/chat/ChatTranscriptPane.tsx @@ -77,6 +77,8 @@ interface ChatTranscriptPaneProps { onRevertUserMessage: (messageId: MessageId) => void; 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"; @@ -134,6 +136,8 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onRevertUserMessage, onUndoTurnFiles, onEditUserMessage, + onForkFromMessage, + forkableMessageIds, onScrollToBottom, onToggleWorkGroup, resolvedTheme, @@ -218,6 +222,8 @@ export const ChatTranscriptPane = memo(function ChatTranscriptPane({ onRevertUserMessage={onRevertUserMessage} {...(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 new file mode 100644 index 00000000..5f168985 --- /dev/null +++ b/apps/web/src/components/chat/MessagesTimeline.messageFork.browser.tsx @@ -0,0 +1,191 @@ +// 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} + /> +
+ ); +} + +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 = ""; + }); + + 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(); + } + }); + + 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 7f814070..82060082 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -360,6 +360,135 @@ 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("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 a1de2390..b0f008ab 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, @@ -416,6 +417,10 @@ 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; + /** Server-backed boundaries whose complete prefix is safe to import. */ + forkableMessageIds?: ReadonlySet; activeTurnId?: TurnId | null; isRevertingCheckpoint: boolean; onImageExpand: (preview: ExpandedImagePreview) => void; @@ -473,6 +478,8 @@ export const MessagesTimeline = memo(function MessagesTimeline({ onRevertUserMessage, onUndoTurnFiles, onEditUserMessage, + onForkFromMessage, + forkableMessageIds, activeTurnId, isRevertingCheckpoint, onImageExpand, @@ -1195,6 +1202,18 @@ export const MessagesTimeline = memo(function MessagesTimeline({ className={MESSAGE_HOVER_REVEAL_CLASS_NAME} /> )} + {onForkFromMessage && + (forkableMessageIds === undefined || + forkableMessageIds.has(row.message.id)) ? ( + onForkFromMessage(row.message.id)} + > + + + ) : null} {showEditUserMessage && ( ) : null} + {assistantCopyState.visible && + onForkFromMessage && + (forkableMessageIds === undefined || forkableMessageIds.has(row.message.id)) ? ( + onForkFromMessage(row.message.id)} + > + + + ) : null} {assistantMeta.length > 0 ? (

{assistantMeta} diff --git a/apps/web/src/composerSlashCommands.test.ts b/apps/web/src/composerSlashCommands.test.ts index ccde0f2e..964eef94 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 21b5c05d..cbac5999 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/hooks/useComposerSlashCommands.ts b/apps/web/src/hooks/useComposerSlashCommands.ts index a4c1734d..aeeb5216 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,9 +259,23 @@ export function useComposerSlashCommands(input: { return true; } - const importedMessages = buildThreadHandoffImportedMessages(activeThread); - const nextThreadId = newThreadId(); + const importedMessages = inputOptions?.sourceMessageId + ? buildThreadForkImportedMessagesThrough( + activeThread, + inputOptions.sourceMessageId, + nextThreadId, + ) + : 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 createdAt = new Date().toISOString(); // Fork first, then let the normal first-send worktree bootstrap create the cwd if needed. const resolvedTarget = resolveForkThreadEnvironment({ @@ -270,6 +289,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 +321,6 @@ export function useComposerSlashCommands(input: { syncServerShellSnapshot, ], ); - const createSidechatFromSlashCommand = useCallback( async (inputOptions?: { initialPrompt?: string }) => { const api = readNativeApi(); @@ -511,6 +530,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 +543,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 +1051,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 f78d1326..acf51e93 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 8746e43a..e26bcb58 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 0475b9dc..2ac5e7b1 100644 --- a/apps/web/src/lib/threadHandoff.test.ts +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -1,12 +1,412 @@ -import { type ModelSelection } from "@synara/contracts"; +import { MessageId, type ModelSelection, ThreadId, TurnId } from "@synara/contracts"; import { describe, expect, it } from "vitest"; import { + buildThreadForkImportedMessagesThrough, + resolveThreadForkableMessageIds, 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, + ThreadId.makeUnsafe("thread-fork-destination"), + ); + + 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, + ThreadId.makeUnsafe("thread-fork-streaming"), + ), + ).toEqual([]); + expect( + buildThreadForkImportedMessagesThrough( + thread, + MessageId.makeUnsafe("message-missing"), + ThreadId.makeUnsafe("thread-fork-missing"), + ), + ).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, + 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("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( + { + 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"); + 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, + ThreadId.makeUnsafe("thread-fork-active"), + ), + ).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"); + + 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, + ThreadId.makeUnsafe("thread-fork-settled"), + ); + + 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, + ThreadId.makeUnsafe("thread-fork-settled-multiple"), + ); + + 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 1235d489..91b9433c 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, @@ -13,6 +14,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 +45,64 @@ function isImportableThreadMessage( return (message.role === "user" || message.role === "assistant") && message.streaming === false; } +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, + ); + const requestedUserIndex = thread.messages.findIndex( + (message) => message.role === "user" && message.createdAt === thread.latestTurn?.requestedAt, + ); + + const knownUnsafeIndexes = [ + ...(activeAssistantIndex >= 0 ? [activeAssistantIndex] : []), + ...(requestedUserIndex >= 0 ? [requestedUserIndex + 1] : []), + ]; + if (knownUnsafeIndexes.length > 0) { + return Math.min(...knownUnsafeIndexes); + } + + return 0; +} + +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 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 { @@ -71,37 +131,116 @@ 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, + 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, index) => { + const importedText = + message.role === "user" ? stripEmbeddedAssistantSelections(message.text) : message.text; + const importedMessage: ThreadHandoffImportedMessage = { + messageId: makeMessageId(index), + 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, + destinationThreadId: ThreadId, +): ReadonlyArray { + const sourceMessageIndex = thread.messages.findIndex((message) => message.id === sourceMessageId); + const sourceMessage = thread.messages[sourceMessageIndex]; + const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread); + if ( + sourceMessageIndex < 0 || + !sourceMessage || + (runningTurnBoundaryIndex !== null && sourceMessageIndex >= runningTurnBoundaryIndex) + ) { + 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)) || + 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), + (index) => + MessageId.makeUnsafe( + `fork:${destinationThreadId}:${String(index).padStart(8, "0")}:${randomUUID()}`, + ), + ); +} + +/** + * 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; + const runningTurnBoundaryIndex = resolveRunningTurnBoundaryIndex(thread); + + for (const [messageIndex, message] of thread.messages.entries()) { + if (message.role !== "user" && message.role !== "assistant") { + continue; + } + prefixIsImportable = + prefixIsImportable && + (runningTurnBoundaryIndex === null || messageIndex < runningTurnBoundaryIndex) && + isForkImportableThreadMessage(thread, message); + if (prefixIsImportable) { + forkableMessageIds.add(message.id); + } + } + + return forkableMessageIds; } export function buildThreadHandoffImportedActivities( diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index 556ca927..52425539 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -387,6 +387,10 @@ 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.forkTitleFamilyRootId ?? null) === (right.forkTitleFamilyRootId ?? 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 +439,10 @@ function toThreadShell(thread: Thread): ThreadShell { subagentNickname: thread.subagentNickname ?? null, 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, lastKnownPr: thread.lastKnownPr ?? null, handoff: thread.handoff ?? null, @@ -1748,6 +1756,10 @@ function normalizeThreadFromReadModel( previous.hasPendingUserInput === resolvedHasPendingUserInput && 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) && deepEqualJson(previous.lastKnownPr ?? null, lastKnownPr) && (previous.handoff ?? null) === handoff && @@ -1791,6 +1803,10 @@ function normalizeThreadFromReadModel( associatedWorktreeRef: nextAssociatedWorktreeRef, 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, lastKnownPr, handoff, @@ -1884,6 +1900,10 @@ function normalizeThreadShellSnapshot( subagentNickname: incoming.subagentNickname ?? null, 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, lastKnownPr, handoff, @@ -2188,6 +2208,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 +2251,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 360a2215..931499cf 100644 --- a/apps/web/src/test/effectRpcWebSocketMock.ts +++ b/apps/web/src/test/effectRpcWebSocketMock.ts @@ -147,6 +147,10 @@ export function createShellSnapshotFromReadModel( subagentNickname: thread.subagentNickname ?? null, 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, latestTurn: thread.latestTurn, latestUserMessageAt: thread.latestUserMessageAt ?? null, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index bd77646e..70aa9a64 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -226,6 +226,10 @@ export interface Thread extends ThreadWorkspaceState { subagentNickname?: string | null; subagentRole?: string | null; forkSourceThreadId?: ThreadId | null; + forkSourceMessageId?: MessageId | null; + forkTitleFamilyRootId?: ThreadId | null; + forkTitleBase?: string | null; + forkTitleOrdinal?: number | null; sidechatSourceThreadId?: ThreadId | null; handoff?: ThreadHandoff | null; lastKnownPr?: OrchestrationThreadPullRequest | null; @@ -262,6 +266,10 @@ export interface ThreadShell extends ThreadWorkspaceState { subagentNickname?: string | null; subagentRole?: string | null; forkSourceThreadId?: ThreadId | null; + forkSourceMessageId?: MessageId | null; + forkTitleFamilyRootId?: ThreadId | null; + forkTitleBase?: string | null; + forkTitleOrdinal?: number | null; sidechatSourceThreadId?: ThreadId | null; handoff?: ThreadHandoff | null; lastKnownPr?: OrchestrationThreadPullRequest | null; @@ -306,6 +314,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 169ae6f6..b2326be2 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -670,6 +670,18 @@ 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), + ), + forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).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 +749,18 @@ 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), + ), + forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).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 +944,7 @@ const ThreadForkCreateCommand = Schema.Struct({ commandId: CommandId, threadId: ThreadId, sourceThreadId: ThreadId, + sourceMessageId: Schema.optional(MessageId), projectId: ProjectId, title: TrimmedNonEmptyString, modelSelection: ModelSelection, @@ -1490,6 +1515,18 @@ 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), + ), + forkTitleFamilyRootId: Schema.optional(Schema.NullOr(ThreadId)).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 03d29e24..57805fbb 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 00000000..038b439f --- /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, ""); +}