From 93ec19165f98969867c6f15d83a19ef8c0bc0ba7 Mon Sep 17 00:00:00 2001 From: tyulyukov Date: Fri, 8 May 2026 14:39:36 +0300 Subject: [PATCH] feat(handoff): add local/worktree thread transfers - add server, persistence, and UI plumbing for handing threads off between local and worktree - preserve and restore uncommitted changes across failed transfers - cover the new handoff flow with regression tests --- apps/server/src/git/Layers/GitManager.test.ts | 138 +++ apps/server/src/git/Layers/GitManager.ts | 920 +++++++++++++++++- .../git/Layers/GitStatusBroadcaster.test.ts | 2 + apps/server/src/git/Services/GitManager.ts | 9 + .../Layers/OrchestrationEngine.test.ts | 5 + .../Layers/ProjectionPipeline.ts | 8 + .../Layers/ProjectionSnapshotQuery.test.ts | 7 + .../Layers/ProjectionSnapshotQuery.ts | 31 + .../Layers/ProviderCommandReactor.ts | 78 +- .../orchestration/commandInvariants.test.ts | 10 + .../src/orchestration/decider.delete.test.ts | 10 + .../decider.projectScripts.test.ts | 15 + apps/server/src/orchestration/decider.ts | 111 +++ apps/server/src/orchestration/handoff.test.ts | 245 +++++ apps/server/src/orchestration/handoff.ts | 124 +++ .../src/orchestration/projector.test.ts | 5 + apps/server/src/orchestration/projector.ts | 21 + .../src/orchestration/serverCommandId.ts | 10 + .../Layers/ProjectionRepositories.test.ts | 5 + .../Layers/ProjectionThreadMessages.test.ts | 4 + .../Layers/ProjectionThreadMessages.ts | 7 + .../persistence/Layers/ProjectionThreads.ts | 40 +- apps/server/src/persistence/Migrations.ts | 2 + ...2_ProjectionThreadsHandoffMetadata.test.ts | 215 ++++ .../032_ProjectionThreadsHandoffMetadata.ts | 99 ++ .../Services/ProjectionThreadMessages.ts | 6 +- .../persistence/Services/ProjectionThreads.ts | 18 +- .../Layers/ProviderSessionReaper.test.ts | 5 + .../Layers/StartupSessionRecovery.test.ts | 5 + apps/server/src/server.test.ts | 12 + apps/server/src/ws.ts | 6 + apps/web/src/components/ChatView.browser.tsx | 23 + .../web/src/components/ChatView.logic.test.ts | 25 + apps/web/src/components/ChatView.logic.ts | 5 + apps/web/src/components/ChatView.tsx | 51 + .../components/CommandPalette.logic.test.ts | 5 + .../components/KeybindingsToast.browser.tsx | 6 + apps/web/src/components/Sidebar.logic.test.ts | 7 + apps/web/src/components/Sidebar.tsx | 30 + apps/web/src/components/chat/ChatHeader.tsx | 127 ++- .../chat/MessagesTimeline.browser.tsx | 2 + .../chat/MessagesTimeline.logic.test.ts | 9 + .../components/chat/MessagesTimeline.test.tsx | 1 + apps/web/src/environmentApi.ts | 1 + apps/web/src/environmentGrouping.test.ts | 1 + apps/web/src/historyBootstrap.test.ts | 7 + apps/web/src/hooks/useThreadHandoff.ts | 100 ++ apps/web/src/lib/threadHandoff.test.ts | 210 ++++ apps/web/src/lib/threadHandoff.ts | 140 +++ apps/web/src/lib/threadSort.test.ts | 8 + .../web/src/orchestrationEventEffects.test.ts | 10 + apps/web/src/rpc/wsRpcClient.ts | 3 + apps/web/src/session-logic.test.ts | 3 + apps/web/src/store.test.ts | 29 + apps/web/src/store.ts | 31 +- apps/web/src/threadDerivation.ts | 4 + apps/web/src/types.ts | 10 + apps/web/src/worktreeCleanup.test.ts | 5 + apps/web/src/wsNativeApi.ts | 1 + packages/contracts/src/git.ts | 28 + packages/contracts/src/ipc.ts | 3 + packages/contracts/src/orchestration.ts | 83 ++ packages/contracts/src/rpc.ts | 10 + packages/shared/package.json | 4 + packages/shared/src/worktreeHandoff.test.ts | 75 ++ packages/shared/src/worktreeHandoff.ts | 55 ++ 66 files changed, 3259 insertions(+), 26 deletions(-) create mode 100644 apps/server/src/orchestration/handoff.test.ts create mode 100644 apps/server/src/orchestration/handoff.ts create mode 100644 apps/server/src/orchestration/serverCommandId.ts create mode 100644 apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.test.ts create mode 100644 apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.ts create mode 100644 apps/web/src/hooks/useThreadHandoff.ts create mode 100644 apps/web/src/lib/threadHandoff.test.ts create mode 100644 apps/web/src/lib/threadHandoff.ts create mode 100644 packages/shared/src/worktreeHandoff.test.ts create mode 100644 packages/shared/src/worktreeHandoff.ts diff --git a/apps/server/src/git/Layers/GitManager.test.ts b/apps/server/src/git/Layers/GitManager.test.ts index e6f10f4bd16..b3e416a05f1 100644 --- a/apps/server/src/git/Layers/GitManager.test.ts +++ b/apps/server/src/git/Layers/GitManager.test.ts @@ -3167,4 +3167,142 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { ]); }), ); + + it.effect("handoffThread moves a thread from local to a new named worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("marcode-git-manager-handoff-"); + yield* initRepo(repoDir); + const { manager } = yield* makeManager(); + + const result = yield* manager.handoffThread({ + cwd: repoDir, + targetMode: "worktree", + currentBranch: "main", + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + preferredLocalBranch: null, + preferredWorktreeBaseBranch: "main", + preferredNewWorktreeName: "feature/handoff-new", + }); + + expect(result.targetMode).toBe("worktree"); + expect(result.worktreePath).not.toBeNull(); + expect(result.branch).toBe("feature/handoff-new"); + expect(result.changesTransferred).toBe(false); + expect(result.conflictsDetected).toBe(false); + expect(fs.existsSync(result.worktreePath as string)).toBe(true); + const worktreeBranch = (yield* runGit(result.worktreePath as string, [ + "branch", + "--show-current", + ])).stdout.trim(); + expect(worktreeBranch).toBe("feature/handoff-new"); + }), + ); + + it.effect("handoffThread carries uncommitted changes from local into the new worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("marcode-git-manager-handoff-changes-"); + yield* initRepo(repoDir); + fs.writeFileSync(path.join(repoDir, "carry.txt"), "carry-me\n"); + const { manager } = yield* makeManager(); + + const result = yield* manager.handoffThread({ + cwd: repoDir, + targetMode: "worktree", + currentBranch: "main", + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + preferredLocalBranch: null, + preferredWorktreeBaseBranch: "main", + preferredNewWorktreeName: "feature/handoff-changes", + }); + + expect(result.changesTransferred).toBe(true); + expect(result.conflictsDetected).toBe(false); + const carriedPath = path.join(result.worktreePath as string, "carry.txt"); + expect(fs.existsSync(carriedPath)).toBe(true); + expect(fs.readFileSync(carriedPath, "utf-8")).toBe("carry-me\n"); + expect(fs.existsSync(path.join(repoDir, "carry.txt"))).toBe(false); + }), + ); + + it.effect("handoffThread errors when handing off to local without a materialized worktree", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("marcode-git-manager-handoff-no-worktree-"); + yield* initRepo(repoDir); + const { manager } = yield* makeManager(); + + const errorMessage = yield* manager + .handoffThread({ + cwd: repoDir, + targetMode: "local", + currentBranch: "main", + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + preferredLocalBranch: null, + preferredWorktreeBaseBranch: null, + preferredNewWorktreeName: null, + }) + .pipe( + Effect.flip, + Effect.map((error) => error.message), + ); + + expect(errorMessage).toContain("does not have a materialized worktree"); + }), + ); + + it.effect("handoffThread moves a worktree thread back to local with carried changes", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("marcode-git-manager-handoff-local-"); + yield* initRepo(repoDir); + const { manager } = yield* makeManager(); + + const created = yield* manager.handoffThread({ + cwd: repoDir, + targetMode: "worktree", + currentBranch: "main", + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + preferredLocalBranch: null, + preferredWorktreeBaseBranch: "main", + preferredNewWorktreeName: "feature/handoff-local", + }); + + const worktreePath = created.worktreePath as string; + fs.writeFileSync(path.join(worktreePath, "carry-back.txt"), "carry-back\n"); + + const result = yield* manager.handoffThread({ + cwd: repoDir, + targetMode: "local", + currentBranch: "feature/handoff-local", + worktreePath, + associatedWorktreePath: worktreePath, + associatedWorktreeBranch: "feature/handoff-local", + associatedWorktreeRef: null, + preferredLocalBranch: null, + preferredWorktreeBaseBranch: null, + preferredNewWorktreeName: null, + }); + + expect(result.targetMode).toBe("local"); + expect(result.worktreePath).toBeNull(); + expect(result.changesTransferred).toBe(true); + expect(result.conflictsDetected).toBe(false); + expect(fs.existsSync(worktreePath)).toBe(false); + const carriedPath = path.join(repoDir, "carry-back.txt"); + expect(fs.existsSync(carriedPath)).toBe(true); + expect(fs.readFileSync(carriedPath, "utf-8")).toBe("carry-back\n"); + const branch = (yield* runGit(repoDir, ["branch", "--show-current"])).stdout.trim(); + expect(branch).toBe("feature/handoff-local"); + }), + ); }); diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index eb8ab691e3a..f2789c10ebe 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -1,7 +1,7 @@ import { randomUUID } from "node:crypto"; import { realpathSync } from "node:fs"; -import { Cache, Duration, Effect, Exit, Layer, Option, Ref, Schedule, Schema } from "effect"; +import { Cache, Duration, Effect, Exit, Layer, Option, Path, Ref, Schedule, Schema } from "effect"; import { GitActionProgressEvent, GitActionProgressPhase, @@ -21,6 +21,7 @@ import { sanitizeBranchFragment, sanitizeFeatureBranchName, } from "@marcode/shared/git"; +import { resolveWorktreeHandoffIntent } from "@marcode/shared/worktreeHandoff"; import { GitManagerError } from "@marcode/contracts"; import { @@ -34,6 +35,7 @@ import { GitHostCli } from "../Services/GitHostCli.ts"; import { TextGeneration } from "../Services/TextGeneration.ts"; import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts"; import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts"; +import { ServerConfig } from "../../config.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; import { JiraContextCollector } from "../../jira/Services/JiraContextCollector.ts"; import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; @@ -112,6 +114,27 @@ interface BranchHeadContext { isCrossRepository: boolean; } +interface FailedLocalHandoffRecovery { + worktreeRecreated: boolean; + worktreeChangesRestored: boolean; + localChangesRestored: boolean; + recoveryNotes: ReadonlyArray; +} + +interface FailedLocalTransferRecovery extends FailedLocalHandoffRecovery { + localCheckoutRestored: boolean; +} + +interface FailedWorktreeHandoffRecovery { + checkoutRestored: boolean; + stashRestored: boolean; + recoveryNotes: ReadonlyArray; +} + +interface FailedWorktreeTransferRecovery extends FailedWorktreeHandoffRecovery { + worktreeRemoved: boolean; +} + function parseRepositoryNameFromPullRequestUrl(url: string): string | null { const trimmed = url.trim(); const ghMatch = /^https:\/\/[^/]+\/[^/]+\/([^/]+)\/pull\/\d+(?:\/.*)?$/i.exec(trimmed); @@ -276,6 +299,86 @@ function gitManagerError(operation: string, detail: string, cause?: unknown): Gi }); } +function combineGitMessages(stdout: string, stderr: string): string | null { + const parts = [stdout.trim(), stderr.trim()].filter((part) => part.length > 0); + if (parts.length === 0) { + return null; + } + return parts.join("\n").trim(); +} + +function buildFailedLocalHandoffRecoveryDetail( + baseMessage: string, + recovery: FailedLocalHandoffRecovery, +): string { + return `${baseMessage} ${[ + recovery.worktreeRecreated + ? "The original worktree was recreated." + : "The original worktree could not be recreated automatically.", + recovery.worktreeChangesRestored + ? "Recovered worktree changes were reapplied." + : "Recovered worktree changes remain in the Git stash.", + recovery.localChangesRestored + ? "Previous local changes were restored." + : "Previous local changes remain in the Git stash.", + ...recovery.recoveryNotes, + ].join(" ")}`.trim(); +} + +function buildFailedLocalTransferDetail( + baseMessage: string, + recovery: FailedLocalTransferRecovery, +): string { + return `${baseMessage} ${[ + recovery.worktreeRecreated + ? "The original worktree was recreated." + : "The original worktree could not be recreated automatically.", + recovery.worktreeChangesRestored + ? "The thread changes were restored to that worktree." + : "The thread changes remain in the Git stash.", + recovery.localCheckoutRestored + ? "Local checkout was restored." + : "Local checkout could not be fully restored automatically.", + recovery.localChangesRestored + ? "Previous local changes were restored." + : "Previous local changes remain in the Git stash.", + ...recovery.recoveryNotes, + ].join(" ")}`.trim(); +} + +function buildFailedWorktreeHandoffRecoveryDetail( + baseMessage: string, + recovery: FailedWorktreeHandoffRecovery, +): string { + return `${baseMessage} ${[ + recovery.checkoutRestored + ? "Local checkout was restored." + : "Local checkout could not be fully restored automatically.", + recovery.stashRestored + ? "Previous local changes were restored." + : "Previous local changes remain in the Git stash.", + ...recovery.recoveryNotes, + ].join(" ")}`.trim(); +} + +function buildFailedWorktreeTransferDetail( + baseMessage: string, + recovery: FailedWorktreeTransferRecovery, +): string { + return `${baseMessage} ${[ + recovery.worktreeRemoved + ? "The new worktree was removed." + : "The new worktree could not be removed automatically.", + recovery.checkoutRestored + ? "Local checkout was restored." + : "Local checkout could not be fully restored automatically.", + recovery.stashRestored + ? "Previous local changes were restored." + : "Previous local changes remain in the Git stash. Run `git stash list` in Local to recover them.", + ...recovery.recoveryNotes, + ].join(" ")}`.trim(); +} + function limitContext(value: string, maxChars: number): string { if (value.length <= maxChars) return value; return `${value.slice(0, maxChars)}\n\n[truncated]`; @@ -497,6 +600,8 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const serverSettingsService = yield* ServerSettingsService; const jiraContextCollector = yield* JiraContextCollector; + const path = yield* Path.Path; + const { worktreesDir } = yield* ServerConfig; const analytics = Option.getOrElse(yield* Effect.serviceOption(AnalyticsService), () => ({ record: () => Effect.void, flush: Effect.void, @@ -1592,6 +1697,818 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { }).pipe(Effect.ensuring(invalidateStatus(input.cwd))); }); + const readStashRef = (cwd: string) => + gitCore + .execute({ + operation: "GitManager.handoffThread.readStashRef", + cwd, + args: ["rev-parse", "--verify", "--quiet", "refs/stash"], + allowNonZeroExit: true, + timeoutMs: 5_000, + }) + .pipe( + Effect.map((result) => { + if (result.code !== 0) return null; + const trimmed = result.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ); + + const readHeadRef = (cwd: string) => + gitCore + .execute({ + operation: "GitManager.handoffThread.readHeadRef", + cwd, + args: ["rev-parse", "HEAD"], + timeoutMs: 5_000, + }) + .pipe( + Effect.map((result) => { + const trimmed = result.stdout.trim(); + return trimmed.length > 0 ? trimmed : null; + }), + ); + + const checkoutDetached = (cwd: string, ref: string) => + gitCore + .execute({ + operation: "GitManager.handoffThread.checkoutDetached", + cwd, + args: ["checkout", "--detach", ref], + timeoutMs: 30_000, + }) + .pipe(Effect.asVoid); + + const buildNamedWorktreePath = (cwd: string, name: string) => { + const repoName = path.basename(cwd); + const sanitizedName = name.trim().replaceAll("/", "-"); + return path.join(worktreesDir, repoName, sanitizedName); + }; + + const buildDetachedWorktreePath = (cwd: string) => { + const repoName = path.basename(cwd); + const shortId = randomUUID().replace(/-/g, "").slice(0, 8); + return path.join(worktreesDir, repoName, `marcode-detached-${shortId}`); + }; + + const createDetachedWorktreeAt = (input: { cwd: string; ref: string; path: string }) => + gitCore + .execute({ + operation: "GitManager.handoffThread.createDetachedWorktree", + cwd: input.cwd, + args: ["worktree", "add", "--detach", input.path, input.ref], + timeoutMs: 60_000, + }) + .pipe( + Effect.map(() => ({ + worktree: { + path: input.path, + ref: input.ref, + branch: null as string | null, + }, + })), + ); + + const createDetachedWorktree = (input: { + cwd: string; + ref: string; + path: string | null; + name?: string | null; + }) => + Effect.gen(function* () { + const resolvedPath = + input.path ?? + (input.name + ? buildNamedWorktreePath(input.cwd, input.name) + : buildDetachedWorktreePath(input.cwd)); + return yield* createDetachedWorktreeAt({ + cwd: input.cwd, + ref: input.ref, + path: resolvedPath, + }); + }); + + const createNamedWorktree = (input: { + cwd: string; + baseBranch: string; + name: string; + path: string | null; + }) => + Effect.gen(function* () { + const resolvedPath = input.path ?? buildNamedWorktreePath(input.cwd, input.name); + return yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: input.baseBranch, + newBranch: input.name, + path: resolvedPath, + }); + }); + + const stashWorkingTree = (cwd: string, label: string) => + Effect.gen(function* () { + if (!(yield* gitCore.statusDetails(cwd)).hasWorkingTreeChanges) { + return { + hadChanges: false, + stashRef: null, + }; + } + const beforeRef = yield* readStashRef(cwd); + yield* gitCore.execute({ + operation: "GitManager.handoffThread.stashPush", + cwd, + args: ["stash", "push", "--include-untracked", "-m", label], + timeoutMs: 30_000, + }); + const afterRef = yield* readStashRef(cwd); + if (afterRef === beforeRef) { + return yield* gitManagerError( + "handoffThread", + "Git did not create a stash entry while preparing the thread handoff.", + ); + } + return { + hadChanges: true, + stashRef: afterRef, + }; + }); + + const dropStashBySha = (cwd: string, stashSha: string) => + Effect.gen(function* () { + const listResult = yield* gitCore.execute({ + operation: "GitManager.handoffThread.listStashShas", + cwd, + args: ["stash", "list", "--format=%H"], + allowNonZeroExit: true, + timeoutMs: 5_000, + }); + if (listResult.code !== 0) return; + const index = listResult.stdout + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .indexOf(stashSha); + if (index < 0) return; + yield* gitCore.execute({ + operation: "GitManager.handoffThread.stashDrop", + cwd, + args: ["stash", "drop", `stash@{${index}}`], + allowNonZeroExit: true, + timeoutMs: 10_000, + }); + }); + + const popStash = (cwd: string, stashRef: string | null) => + Effect.gen(function* () { + if (!stashRef) { + return { + conflictsDetected: false, + message: null as string | null, + }; + } + // `git stash pop` requires a `stash@{N}` reference, but `stashRef` here is the + // commit SHA captured via `git rev-parse refs/stash` in `readStashRef`. Apply + // the stash by SHA (which `git stash apply` accepts for any stash-shaped + // commit) and then drop the matching list entry on success so callers still + // observe pop-style semantics. + const result = yield* gitCore + .execute({ + operation: "GitManager.handoffThread.stashApply", + cwd, + args: ["stash", "apply", "--index", stashRef], + allowNonZeroExit: true, + timeoutMs: 30_000, + }) + .pipe( + Effect.catch((error) => + Effect.succeed({ + code: 1, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + stdoutTruncated: false, + stderrTruncated: false, + }), + ), + ); + if (result.code === 0) { + yield* dropStashBySha(cwd, stashRef).pipe(Effect.catch(() => Effect.void)); + return { + conflictsDetected: false, + message: null as string | null, + }; + } + return { + conflictsDetected: true, + message: (combineGitMessages(result.stdout, result.stderr) ?? + "Git reported conflicts while applying the handed off changes.") as string | null, + }; + }); + + const restoreSourceStash = (cwd: string, stashRef: string | null) => + popStash(cwd, stashRef).pipe(Effect.asVoid); + + const restoreStashes = (restores: ReadonlyArray<{ cwd: string; stashRef: string | null }>) => + Effect.forEach(restores, (entry) => restoreSourceStash(entry.cwd, entry.stashRef), { + concurrency: 1, + discard: true, + }); + + const resolveForegroundFallbackBranch = (cwd: string, excludedBranch: string) => + gitCore.listBranches({ cwd }).pipe( + Effect.map((result) => { + const localBranches = result.branches.filter( + (branch) => + !branch.isRemote && branch.name !== excludedBranch && branch.worktreePath === null, + ); + const defaultBranch = localBranches.find((branch) => branch.isDefault)?.name ?? null; + if (defaultBranch) return defaultBranch; + return localBranches[0]?.name ?? null; + }), + ); + + const restoreLocalHandoffSource = (input: { + cwd: string; + originalBranch: string | null; + originalHeadRef: string | null; + currentBranch: string | null; + stashRef: string | null; + }) => + Effect.gen(function* () { + let checkoutRestored = input.originalBranch === input.currentBranch; + const recoveryNotes: string[] = []; + + if ( + input.originalBranch && + input.currentBranch && + input.originalBranch !== input.currentBranch + ) { + checkoutRestored = yield* Effect.scoped( + gitCore.checkoutBranch({ + cwd: input.cwd, + branch: input.originalBranch, + }), + ).pipe( + Effect.as(true), + Effect.catch((error) => { + recoveryNotes.push( + `Local could not be returned to '${input.originalBranch}': ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return Effect.succeed(false); + }), + ); + } else if (!input.originalBranch && input.originalHeadRef) { + checkoutRestored = yield* checkoutDetached(input.cwd, input.originalHeadRef).pipe( + Effect.as(true), + Effect.catch((error) => { + recoveryNotes.push( + `Local could not be returned to its previous detached HEAD: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return Effect.succeed(false); + }), + ); + } + + const stashRestore = yield* popStash(input.cwd, input.stashRef); + const stashRestored = !stashRestore.conflictsDetected; + if (stashRestore.conflictsDetected) { + recoveryNotes.push( + `${stashRestore.message ?? "Git reported conflicts while restoring the original Local changes."}\nThe local stash entry was kept for recovery.`, + ); + } + + return { + checkoutRestored, + stashRestored, + recoveryNotes, + }; + }); + + const restoreRemovedWorktreeAfterFailedLocalCheckout = (input: { + cwd: string; + worktreePath: string | null; + branch: string | null; + ref: string | null; + worktreeStashRef: string | null; + localStashRef: string | null; + }) => + Effect.gen(function* () { + const recoveryNotes: string[] = []; + let worktreeRecreated = false; + let worktreeChangesRestored = input.worktreeStashRef === null; + let localChangesRestored = input.localStashRef === null; + + if (input.worktreePath) { + const recreated = + input.branch !== null + ? yield* gitCore + .createWorktree({ + cwd: input.cwd, + branch: input.branch, + path: input.worktreePath, + }) + .pipe(Effect.catch(() => Effect.succeed(null))) + : input.ref + ? yield* createDetachedWorktree({ + cwd: input.cwd, + ref: input.ref, + path: input.worktreePath, + }).pipe(Effect.catch(() => Effect.succeed(null))) + : null; + + if (recreated?.worktree.path) { + worktreeRecreated = true; + const worktreeRestore = yield* popStash(recreated.worktree.path, input.worktreeStashRef); + worktreeChangesRestored = !worktreeRestore.conflictsDetected; + if (worktreeRestore.conflictsDetected) { + recoveryNotes.push( + `${worktreeRestore.message ?? "Git reported conflicts while restoring the recovered worktree changes."}\nThe worktree stash entry was kept for recovery.`, + ); + } + } else if (input.worktreeStashRef) { + recoveryNotes.push( + "The thread worktree could not be recreated automatically. Its uncommitted changes were kept in the Git stash for manual recovery.", + ); + } + } + + const localRestore = yield* popStash(input.cwd, input.localStashRef); + localChangesRestored = !localRestore.conflictsDetected; + if (localRestore.conflictsDetected) { + recoveryNotes.push( + `${localRestore.message ?? "Git reported conflicts while restoring your previous local changes."}\nThe local stash entry was kept for recovery.`, + ); + } + + return { + worktreeRecreated, + worktreeChangesRestored, + localChangesRestored, + recoveryNotes, + }; + }); + + const rollbackFailedLocalTransfer = (input: { + cwd: string; + originalBranch: string | null; + originalHeadRef: string | null; + currentBranch: string | null; + worktreePath: string | null; + worktreeBranch: string | null; + worktreeRef: string | null; + worktreeStashRef: string | null; + localStashRef: string | null; + }) => + Effect.gen(function* () { + const worktreeRecovery = yield* restoreRemovedWorktreeAfterFailedLocalCheckout({ + cwd: input.cwd, + worktreePath: input.worktreePath, + branch: input.worktreeBranch, + ref: input.worktreeRef, + worktreeStashRef: input.worktreeStashRef, + localStashRef: null, + }); + + const localRecovery = yield* restoreLocalHandoffSource({ + cwd: input.cwd, + originalBranch: input.originalBranch, + originalHeadRef: input.originalHeadRef, + currentBranch: input.currentBranch, + stashRef: input.localStashRef, + }); + + return { + worktreeRecreated: worktreeRecovery.worktreeRecreated, + worktreeChangesRestored: worktreeRecovery.worktreeChangesRestored, + localCheckoutRestored: localRecovery.checkoutRestored, + localChangesRestored: localRecovery.stashRestored, + recoveryNotes: [...worktreeRecovery.recoveryNotes, ...localRecovery.recoveryNotes], + }; + }); + + const rollbackFailedWorktreeTransfer = (input: { + cwd: string; + worktreePath: string; + originalBranch: string | null; + originalHeadRef: string | null; + currentBranch: string | null; + stashRef: string | null; + }) => + Effect.gen(function* () { + const recoveryNotes: string[] = []; + const worktreeRemoved = yield* gitCore + .removeWorktree({ + cwd: input.cwd, + path: input.worktreePath, + force: true, + }) + .pipe( + Effect.as(true), + Effect.catch((error) => { + recoveryNotes.push( + `The newly created worktree could not be removed automatically: ${ + error instanceof Error ? error.message : String(error) + }`, + ); + return Effect.succeed(false); + }), + ); + + const localRecovery = yield* restoreLocalHandoffSource({ + cwd: input.cwd, + originalBranch: input.originalBranch, + originalHeadRef: input.originalHeadRef, + currentBranch: input.currentBranch, + stashRef: input.stashRef, + }); + + return { + worktreeRemoved, + checkoutRestored: localRecovery.checkoutRestored, + stashRestored: localRecovery.stashRestored, + recoveryNotes: [...recoveryNotes, ...localRecovery.recoveryNotes], + }; + }); + + const handoffThread: GitManagerShape["handoffThread"] = Effect.fnUntraced(function* (input) { + const currentLocalStatus = yield* gitCore.statusDetails(input.cwd); + + if (input.targetMode === "local") { + if (!input.worktreePath) { + return yield* gitManagerError( + "handoffThread", + "Cannot hand off to Local because this thread does not have a materialized worktree.", + ); + } + + const worktreeHeadRef = yield* readHeadRef(input.worktreePath); + const targetLocalBranch = + input.currentBranch ?? input.associatedWorktreeBranch ?? input.preferredLocalBranch ?? null; + if (!(targetLocalBranch ?? worktreeHeadRef)) { + return yield* gitManagerError( + "handoffThread", + "Cannot hand off to Local because the worktree thread does not have a recoverable HEAD reference.", + ); + } + + const associatedWorktreePath = input.associatedWorktreePath ?? input.worktreePath; + const associatedWorktreeBranch = + input.associatedWorktreeBranch ?? input.currentBranch ?? null; + const associatedWorktreeRef = + input.associatedWorktreeRef ?? worktreeHeadRef ?? associatedWorktreeBranch; + const originalLocalBranch = currentLocalStatus.branch ?? null; + const originalLocalHeadRef = yield* readHeadRef(input.cwd); + let currentLocalBranchAfterPreparation = originalLocalBranch; + + const preservedLocalStash = yield* stashWorkingTree( + input.cwd, + `marcode preserve local handoff ${randomUUID()}`, + ); + const sourceStash = yield* stashWorkingTree( + input.worktreePath, + `marcode handoff to local ${randomUUID()}`, + ); + + yield* gitCore + .removeWorktree({ + cwd: input.cwd, + path: input.worktreePath, + }) + .pipe( + Effect.catch((error) => + restoreStashes([ + { cwd: input.worktreePath!, stashRef: sourceStash.stashRef }, + { cwd: input.cwd, stashRef: preservedLocalStash.stashRef }, + ]).pipe(Effect.flatMap(() => Effect.fail(error))), + ), + ); + + if (targetLocalBranch && currentLocalStatus.branch !== targetLocalBranch) { + yield* Effect.scoped( + gitCore.checkoutBranch({ + cwd: input.cwd, + branch: targetLocalBranch, + }), + ).pipe( + Effect.catch((error) => + restoreRemovedWorktreeAfterFailedLocalCheckout({ + cwd: input.cwd, + worktreePath: associatedWorktreePath, + branch: associatedWorktreeBranch, + ref: associatedWorktreeRef, + worktreeStashRef: sourceStash.stashRef, + localStashRef: preservedLocalStash.stashRef, + }).pipe( + Effect.flatMap((recovery) => + Effect.fail( + new GitManagerError({ + operation: "GitManager.handoffThread", + detail: buildFailedLocalHandoffRecoveryDetail(error.message, recovery), + cause: error, + }), + ), + ), + ), + ), + ); + currentLocalBranchAfterPreparation = targetLocalBranch; + } else if (!targetLocalBranch && worktreeHeadRef) { + yield* checkoutDetached(input.cwd, worktreeHeadRef).pipe( + Effect.catch((error) => + restoreRemovedWorktreeAfterFailedLocalCheckout({ + cwd: input.cwd, + worktreePath: associatedWorktreePath, + branch: associatedWorktreeBranch, + ref: associatedWorktreeRef, + worktreeStashRef: sourceStash.stashRef, + localStashRef: preservedLocalStash.stashRef, + }).pipe( + Effect.flatMap((recovery) => + Effect.fail( + new GitManagerError({ + operation: "GitManager.handoffThread", + detail: buildFailedLocalHandoffRecoveryDetail(error.message, recovery), + cause: error, + }), + ), + ), + ), + ), + ); + currentLocalBranchAfterPreparation = null; + } + + const threadTransfer = yield* popStash(input.cwd, sourceStash.stashRef); + if (threadTransfer.conflictsDetected) { + const recovery = yield* rollbackFailedLocalTransfer({ + cwd: input.cwd, + originalBranch: originalLocalBranch, + originalHeadRef: originalLocalHeadRef, + currentBranch: currentLocalBranchAfterPreparation, + worktreePath: associatedWorktreePath, + worktreeBranch: associatedWorktreeBranch, + worktreeRef: associatedWorktreeRef, + worktreeStashRef: sourceStash.stashRef, + localStashRef: preservedLocalStash.stashRef, + }); + return yield* new GitManagerError({ + operation: "GitManager.handoffThread", + detail: buildFailedLocalTransferDetail( + `${ + threadTransfer.message ?? + "Git reported conflicts while applying the handed off changes." + } The handoff was rolled back so the thread stays in its worktree.`, + recovery, + ), + }); + } + + const localTransfer = yield* popStash(input.cwd, preservedLocalStash.stashRef); + const changesTransferred = sourceStash.hadChanges || preservedLocalStash.hadChanges; + const movedThreadChanges = sourceStash.hadChanges; + const restoredLocalChanges = preservedLocalStash.hadChanges; + const localTargetLabel = targetLocalBranch + ? `main local checkout on '${targetLocalBranch}'` + : "local checkout in detached HEAD"; + const message = localTransfer.conflictsDetected + ? `${ + localTransfer.message ?? + "Git reported conflicts while restoring your previous local changes." + }\nYour previous local stash entry was kept for recovery.` + : movedThreadChanges && restoredLocalChanges + ? `Moved the thread back to the ${localTargetLabel}, carried its uncommitted work over, and restored your previous local changes.` + : movedThreadChanges + ? `Moved the thread back to the ${localTargetLabel} and carried its uncommitted work over.` + : restoredLocalChanges + ? `Moved the thread back to the ${localTargetLabel} and restored your previous local changes.` + : `Moved the thread back to the ${localTargetLabel}.`; + + return { + targetMode: "local", + branch: targetLocalBranch, + worktreePath: null, + associatedWorktreePath, + associatedWorktreeBranch, + associatedWorktreeRef, + changesTransferred, + conflictsDetected: localTransfer.conflictsDetected, + message, + }; + } + + const worktreeIntent = resolveWorktreeHandoffIntent({ + preferredNewWorktreeName: input.preferredNewWorktreeName, + associatedWorktreePath: input.associatedWorktreePath, + associatedWorktreeBranch: input.associatedWorktreeBranch, + associatedWorktreeRef: input.associatedWorktreeRef, + preferredWorktreeBaseBranch: + input.preferredWorktreeBaseBranch ?? currentLocalStatus.branch ?? null, + currentBranch: input.currentBranch, + }); + if (!worktreeIntent) { + return yield* gitManagerError( + "handoffThread", + "Cannot hand off to a worktree because no worktree target is available.", + ); + } + const targetWorktreeName = + worktreeIntent.kind === "create-new" ? worktreeIntent.worktreeName : null; + const targetAssociatedWorktreePath = + worktreeIntent.kind === "reuse-associated" ? worktreeIntent.associatedWorktreePath : null; + const targetAssociatedWorktreeBranch = + worktreeIntent.kind === "reuse-associated" ? worktreeIntent.associatedWorktreeBranch : null; + const targetAssociatedWorktreeRef = + worktreeIntent.kind === "reuse-associated" ? worktreeIntent.associatedWorktreeRef : null; + const targetBaseBranch = worktreeIntent.baseBranch; + if (!targetBaseBranch && !targetAssociatedWorktreeBranch && !targetAssociatedWorktreeRef) { + return yield* gitManagerError( + "handoffThread", + "Select a base branch before handing off this thread to a worktree.", + ); + } + + const sourceStash = yield* stashWorkingTree( + input.cwd, + `marcode handoff to worktree ${randomUUID()}`, + ); + const sourceBranch = currentLocalStatus.branch ?? input.currentBranch ?? null; + const sourceHeadRef = yield* readHeadRef(input.cwd); + let foregroundBranchAfterHandoff = currentLocalStatus.branch; + + if (sourceBranch && sourceBranch === targetAssociatedWorktreeBranch) { + const fallbackLocalBranch = yield* resolveForegroundFallbackBranch( + input.cwd, + targetAssociatedWorktreeBranch, + ); + if (!fallbackLocalBranch) { + if (!sourceHeadRef) { + yield* restoreSourceStash(input.cwd, sourceStash.stashRef); + return yield* gitManagerError( + "handoffThread", + `Cannot hand off '${targetAssociatedWorktreeBranch}' to a worktree because there is no recoverable local HEAD reference available.`, + ); + } + yield* checkoutDetached(input.cwd, sourceHeadRef).pipe( + Effect.catch((error) => + restoreSourceStash(input.cwd, sourceStash.stashRef).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + foregroundBranchAfterHandoff = null; + } else { + yield* Effect.scoped( + gitCore.checkoutBranch({ + cwd: input.cwd, + branch: fallbackLocalBranch, + }), + ).pipe( + Effect.catch((error) => + restoreSourceStash(input.cwd, sourceStash.stashRef).pipe( + Effect.flatMap(() => Effect.fail(error)), + ), + ), + ); + foregroundBranchAfterHandoff = fallbackLocalBranch; + } + } + + const worktree = yield* Effect.gen(function* () { + if (targetAssociatedWorktreeRef && !targetAssociatedWorktreeBranch) { + return yield* createDetachedWorktree({ + cwd: input.cwd, + ref: targetAssociatedWorktreeRef, + path: targetAssociatedWorktreePath, + }); + } + if (targetWorktreeName) { + if (!targetBaseBranch) { + return yield* gitManagerError( + "handoffThread", + "Select a base branch before creating a new worktree.", + ); + } + return yield* createNamedWorktree({ + cwd: input.cwd, + baseBranch: targetBaseBranch, + name: targetWorktreeName, + path: null, + }); + } + if (targetAssociatedWorktreeBranch) { + if ( + (yield* gitCore.listLocalBranchNames(input.cwd)).includes(targetAssociatedWorktreeBranch) + ) { + return yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: targetAssociatedWorktreeBranch, + path: targetAssociatedWorktreePath, + }); + } + if (!targetBaseBranch) { + return yield* createDetachedWorktree({ + cwd: input.cwd, + ref: targetAssociatedWorktreeBranch, + path: targetAssociatedWorktreePath, + }); + } + return yield* gitCore.createWorktree({ + cwd: input.cwd, + branch: targetBaseBranch ?? targetAssociatedWorktreeBranch, + newBranch: targetAssociatedWorktreeBranch, + path: targetAssociatedWorktreePath, + }); + } + if (!targetBaseBranch) { + return yield* createDetachedWorktree({ + cwd: input.cwd, + ref: targetAssociatedWorktreeRef!, + path: targetAssociatedWorktreePath, + }); + } + return yield* createDetachedWorktree({ + cwd: input.cwd, + ref: targetBaseBranch, + path: targetAssociatedWorktreePath, + ...(targetWorktreeName ? { name: targetWorktreeName } : {}), + }); + }).pipe( + Effect.catch((error) => + restoreLocalHandoffSource({ + cwd: input.cwd, + originalBranch: sourceBranch, + originalHeadRef: sourceHeadRef, + currentBranch: foregroundBranchAfterHandoff, + stashRef: sourceStash.stashRef, + }).pipe( + Effect.flatMap((recovery) => + Effect.fail( + new GitManagerError({ + operation: "GitManager.handoffThread", + detail: buildFailedWorktreeHandoffRecoveryDetail(error.message, recovery), + cause: error, + }), + ), + ), + ), + ), + ); + + const transfer = yield* popStash(worktree.worktree.path, sourceStash.stashRef); + if (transfer.conflictsDetected) { + const recovery = yield* rollbackFailedWorktreeTransfer({ + cwd: input.cwd, + worktreePath: worktree.worktree.path, + originalBranch: sourceBranch, + originalHeadRef: sourceHeadRef, + currentBranch: foregroundBranchAfterHandoff, + stashRef: sourceStash.stashRef, + }); + return yield* new GitManagerError({ + operation: "GitManager.handoffThread", + detail: buildFailedWorktreeTransferDetail( + `${ + transfer.message ?? "Git reported conflicts while applying the handed off changes." + } The stash entry was kept for recovery.`, + recovery, + ), + }); + } + + const materializedWorktreeStatus = yield* gitCore.statusDetails(worktree.worktree.path); + const materializedWorktreeRef = + (yield* readHeadRef(worktree.worktree.path)) ?? + ("ref" in worktree.worktree ? worktree.worktree.ref : worktree.worktree.branch); + const materializedWorktreeBranch = materializedWorktreeStatus.branch ?? null; + // MarCode does not expose `gitCore.publishBranch`. Worktree branch publishing is + // skipped here; remote-set behavior remains a best-effort responsibility of any + // explicit push action initiated after the handoff. + const changesTransferred = sourceStash.hadChanges; + const handoffSummary = + foregroundBranchAfterHandoff && foregroundBranchAfterHandoff !== sourceBranch + ? `The thread moved into its worktree and Local returned to '${foregroundBranchAfterHandoff}'.` + : foregroundBranchAfterHandoff === null && sourceBranch === targetAssociatedWorktreeBranch + ? "The thread moved into its worktree and Local returned to a detached HEAD." + : "The thread moved into its worktree."; + const message = changesTransferred + ? `${handoffSummary} Uncommitted local changes were carried over.` + : handoffSummary; + + return { + targetMode: "worktree", + branch: materializedWorktreeBranch, + worktreePath: worktree.worktree.path, + associatedWorktreePath: worktree.worktree.path, + associatedWorktreeBranch: materializedWorktreeBranch, + associatedWorktreeRef: materializedWorktreeRef, + changesTransferred, + conflictsDetected: false, + message, + }; + }); + const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* ( modelSelection: ModelSelection, cwd: string, @@ -1829,6 +2746,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { invalidateStatus, resolvePullRequest, preparePullRequestThread, + handoffThread, runStackedAction, } satisfies GitManagerShape; }); diff --git a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts index fbbaa0a9c05..484f148f74c 100644 --- a/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +++ b/apps/server/src/git/Layers/GitStatusBroadcaster.test.ts @@ -70,6 +70,7 @@ function makeTestLayer(state: { resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"), preparePullRequestThread: () => Effect.die("preparePullRequestThread should not be called in this test"), + handoffThread: () => Effect.die("handoffThread should not be called in this test"), runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), }; @@ -268,6 +269,7 @@ describe("GitStatusBroadcasterLive", () => { Effect.die("resolvePullRequest should not be called in this test"), preparePullRequestThread: () => Effect.die("preparePullRequestThread should not be called in this test"), + handoffThread: () => Effect.die("handoffThread should not be called in this test"), runStackedAction: () => Effect.die("runStackedAction should not be called in this test"), } satisfies GitManagerShape), ), diff --git a/apps/server/src/git/Services/GitManager.ts b/apps/server/src/git/Services/GitManager.ts index 5c1d59d72c7..b1fd0747016 100644 --- a/apps/server/src/git/Services/GitManager.ts +++ b/apps/server/src/git/Services/GitManager.ts @@ -8,6 +8,8 @@ */ import { GitActionProgressEvent, + GitHandoffThreadInput, + GitHandoffThreadResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, @@ -86,6 +88,13 @@ export interface GitManagerShape { input: GitPreparePullRequestThreadInput, ) => Effect.Effect; + /** + * Move a thread between Local and Worktree while preserving recoverable Git state. + */ + readonly handoffThread: ( + input: GitHandoffThreadInput, + ) => Effect.Effect; + /** * Run a Git action (`commit`, `push`, `create_pr`, `commit_push`, `commit_push_pr`). * When `featureBranch` is set, creates and checks out a feature branch first. diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 75812597c43..02432848f39 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -146,6 +146,11 @@ describe("OrchestrationEngine", () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index 0c69fd22915..e80942f6984 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -593,6 +593,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + associatedWorktreePath: event.payload.associatedWorktreePath, + associatedWorktreeBranch: event.payload.associatedWorktreeBranch, + associatedWorktreeRef: event.payload.associatedWorktreeRef, + createBranchFlowCompleted: event.payload.createBranchFlowCompleted, + handoff: event.payload.handoff, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurnId: null, @@ -818,6 +823,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, + // Preserve original source if a row already exists; otherwise use + // the source from the event (defaulting to "native" via schema). + source: previousMessage?.source ?? event.payload.source, createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts index ba381570f86..34791cdcde6 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts @@ -283,6 +283,11 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: { @@ -308,6 +313,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { text: "hello from projection", turnId: asTurnId("turn-1"), streaming: false, + source: "native", createdAt: "2026-02-24T00:00:04.000Z", updatedAt: "2026-02-24T00:00:05.000Z", }, @@ -397,6 +403,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { runtimeMode: "full-access", branch: null, worktreePath: null, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: { diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index 9abebc36c07..1b1c363cbcf 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -10,6 +10,7 @@ import { OrchestrationShellSnapshot, OrchestrationThread, ProjectScript, + ThreadHandoff, TurnId, type OrchestrationCheckpointSummary, type OrchestrationLatestTurn, @@ -75,6 +76,10 @@ const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( modelSelection: Schema.fromJsonString(ModelSelection), additionalDirectories: Schema.fromJsonString(Schema.Array(Schema.String)), implementingJiraTicketKeys: Schema.fromJsonString(Schema.Array(Schema.String)), + handoff: Schema.NullOr(Schema.fromJsonString(ThreadHandoff)), + // Stored as INTEGER (0 or 1); kept raw at the row level and converted to + // boolean by callers that map this row to the domain shape. + createBranchFlowCompleted: Schema.Number, }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -272,6 +277,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + associated_worktree_path AS "associatedWorktreePath", + associated_worktree_branch AS "associatedWorktreeBranch", + associated_worktree_ref AS "associatedWorktreeRef", + create_branch_flow_completed AS "createBranchFlowCompleted", + handoff_json AS "handoff", additional_directories_json AS "additionalDirectories", implementing_jira_ticket_keys_json AS "implementingJiraTicketKeys", latest_turn_id AS "latestTurnId", @@ -301,6 +311,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source, created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -602,6 +613,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + associated_worktree_path AS "associatedWorktreePath", + associated_worktree_branch AS "associatedWorktreeBranch", + associated_worktree_ref AS "associatedWorktreeRef", + create_branch_flow_completed AS "createBranchFlowCompleted", + handoff_json AS "handoff", additional_directories_json AS "additionalDirectories", implementing_jira_ticket_keys_json AS "implementingJiraTicketKeys", latest_turn_id AS "latestTurnId", @@ -633,6 +649,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source, created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -807,6 +824,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ...(row.attachments !== null ? { attachments: row.attachments } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, + source: row.source, createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -942,6 +960,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + associatedWorktreePath: row.associatedWorktreePath, + associatedWorktreeBranch: row.associatedWorktreeBranch, + associatedWorktreeRef: row.associatedWorktreeRef, + createBranchFlowCompleted: row.createBranchFlowCompleted === 1, + handoff: row.handoff, additionalDirectories: row.additionalDirectories, implementingJiraTicketKeys: row.implementingJiraTicketKeys as ReadonlyArray< OrchestrationThread["implementingJiraTicketKeys"][number] @@ -1195,6 +1218,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: row.interactionMode, branch: row.branch, worktreePath: row.worktreePath, + handoff: row.handoff, additionalDirectories: row.additionalDirectories, implementingJiraTicketKeys: row.implementingJiraTicketKeys as ReadonlyArray< OrchestrationThreadShell["implementingJiraTicketKeys"][number] @@ -1295,6 +1319,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + handoff: threadRow.value.handoff, additionalDirectories: threadRow.value.additionalDirectories, implementingJiraTicketKeys: threadRow.value.implementingJiraTicketKeys as ReadonlyArray< OrchestrationThreadShell["implementingJiraTicketKeys"][number] @@ -1393,6 +1418,11 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { interactionMode: threadRow.value.interactionMode, branch: threadRow.value.branch, worktreePath: threadRow.value.worktreePath, + associatedWorktreePath: threadRow.value.associatedWorktreePath, + associatedWorktreeBranch: threadRow.value.associatedWorktreeBranch, + associatedWorktreeRef: threadRow.value.associatedWorktreeRef, + createBranchFlowCompleted: threadRow.value.createBranchFlowCompleted === 1, + handoff: threadRow.value.handoff, additionalDirectories: threadRow.value.additionalDirectories, implementingJiraTicketKeys: threadRow.value.implementingJiraTicketKeys as ReadonlyArray< OrchestrationThread["implementingJiraTicketKeys"][number] @@ -1409,6 +1439,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text: row.text, turnId: row.turnId, streaming: row.isStreaming === 1, + source: row.source, createdAt: row.createdAt, updatedAt: row.updatedAt, }; diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 31fa134128a..83182ecf53a 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -2,8 +2,10 @@ import { type ChatAttachment, CommandId, EventId, + MessageId, type ModelSelection, type OrchestrationEvent, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, ProviderKind, type OrchestrationSession, ThreadId, @@ -36,6 +38,11 @@ import { type ProviderCommandReactorShape, } from "../Services/ProviderCommandReactor.ts"; import { ServerSettingsService } from "../../serverSettings.ts"; +import { + buildHandoffBootstrapText, + HANDOFF_CONTEXT_WRAPPER_OVERHEAD, + hasNativeAssistantMessagesBefore, +} from "../handoff.ts"; type ProviderIntentEvent = Extract< OrchestrationEvent, @@ -440,6 +447,7 @@ const make = Effect.gen(function* () { const buildSendTurnRequestForThread = Effect.fnUntraced(function* (input: { readonly threadId: ThreadId; + readonly messageId?: MessageId; readonly messageText: string; readonly attachments?: ReadonlyArray; readonly modelSelection?: ModelSelection; @@ -461,7 +469,37 @@ const make = Effect.gen(function* () { if (input.modelSelection !== undefined) { threadModelSelections.set(input.threadId, input.modelSelection); } - const normalizedInput = toNonEmptyProviderInput(input.messageText); + + // Handoff bootstrap injection: on the first user turn after `thread.handoff.create`, + // wrap the user's input in a `` block so the receiving provider + // sees the imported conversation. We only inject once per thread (gated by both + // `bootstrapStatus === "pending"` and the absence of any prior native assistant + // turn). + let bootstrapInjected = false; + let effectiveMessageText = input.messageText; + if ( + thread.handoff !== null && + thread.handoff.bootstrapStatus === "pending" && + input.messageId !== undefined && + !hasNativeAssistantMessagesBefore(thread, input.messageId) + ) { + const availableBootstrapChars = Math.max( + 0, + PROVIDER_SEND_TURN_MAX_INPUT_CHARS - + input.messageText.length - + HANDOFF_CONTEXT_WRAPPER_OVERHEAD, + ); + const bootstrapText = + availableBootstrapChars > 0 + ? buildHandoffBootstrapText(thread, availableBootstrapChars) + : null; + if (bootstrapText !== null) { + effectiveMessageText = `\n${bootstrapText}\n\n\n\n${input.messageText}\n`; + bootstrapInjected = true; + } + } + + const normalizedInput = toNonEmptyProviderInput(effectiveMessageText); const normalizedAttachments = input.attachments ?? []; if (!normalizedInput && normalizedAttachments.length === 0) { @@ -495,6 +533,10 @@ const make = Effect.gen(function* () { ...(normalizedAttachments.length > 0 ? { attachments: normalizedAttachments } : {}), ...(modelForTurn !== undefined ? { modelSelection: modelForTurn } : {}), ...(input.interactionMode !== undefined ? { interactionMode: input.interactionMode } : {}), + // Internal marker, stripped before reaching the provider; the reactor + // reads it after a successful sendTurn to flip `bootstrapStatus` to + // `"completed"`. + handoffBootstrapInjected: bootstrapInjected, }; }); @@ -781,6 +823,7 @@ const make = Effect.gen(function* () { const sendTurnRequest = yield* buildSendTurnRequestForThread({ threadId: event.payload.threadId, + messageId: event.payload.messageId, messageText: message.text, ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), ...(event.payload.modelSelection !== undefined @@ -797,9 +840,36 @@ const make = Effect.gen(function* () { return; } - yield* providerService - .sendTurn(sendTurnRequest.value) - .pipe(Effect.catchCause(recoverTurnStartFailure), Effect.forkScoped); + const { handoffBootstrapInjected, ...providerSendTurnRequest } = sendTurnRequest.value; + + yield* providerService.sendTurn(providerSendTurnRequest).pipe( + Effect.tap(() => { + if (!handoffBootstrapInjected || thread.handoff === null) { + return Effect.void; + } + return orchestrationEngine + .dispatch({ + type: "thread.meta.update", + commandId: serverCommandId("handoff-bootstrap-complete"), + threadId: event.payload.threadId, + handoff: { ...thread.handoff, bootstrapStatus: "completed" }, + }) + .pipe( + Effect.catchCause((cause) => + Effect.logWarning( + "provider command reactor failed to mark handoff bootstrap complete", + { + threadId: event.payload.threadId, + cause: Cause.pretty(cause), + }, + ), + ), + Effect.asVoid, + ); + }), + Effect.catchCause(recoverTurnStartFailure), + Effect.forkScoped, + ); }); const processTurnInterruptRequested = Effect.fn("processTurnInterruptRequested")(function* ( diff --git a/apps/server/src/orchestration/commandInvariants.test.ts b/apps/server/src/orchestration/commandInvariants.test.ts index b8016673e6a..f5d6a397b3a 100644 --- a/apps/server/src/orchestration/commandInvariants.test.ts +++ b/apps/server/src/orchestration/commandInvariants.test.ts @@ -66,6 +66,11 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], createdAt: now, @@ -91,6 +96,11 @@ const readModel: OrchestrationReadModel = { runtimeMode: "full-access", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], createdAt: now, diff --git a/apps/server/src/orchestration/decider.delete.test.ts b/apps/server/src/orchestration/decider.delete.test.ts index 5bbe94788b3..39f5da356ca 100644 --- a/apps/server/src/orchestration/decider.delete.test.ts +++ b/apps/server/src/orchestration/decider.delete.test.ts @@ -71,6 +71,11 @@ async function seedReadModel(): Promise { runtimeMode: "approval-required", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: now, updatedAt: now, }, @@ -101,6 +106,11 @@ async function seedReadModel(): Promise { runtimeMode: "approval-required", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/orchestration/decider.projectScripts.test.ts b/apps/server/src/orchestration/decider.projectScripts.test.ts index 51b55d76743..a966d3754de 100644 --- a/apps/server/src/orchestration/decider.projectScripts.test.ts +++ b/apps/server/src/orchestration/decider.projectScripts.test.ts @@ -146,6 +146,11 @@ describe("decider project scripts", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: now, updatedAt: now, }, @@ -256,6 +261,11 @@ describe("decider project scripts", () => { runtimeMode: "full-access", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: now, updatedAt: now, }, @@ -339,6 +349,11 @@ describe("decider project scripts", () => { runtimeMode: "approval-required", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: now, updatedAt: now, }, diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index dd8ecf18d89..e2fbffff532 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -15,6 +15,7 @@ import { requireThreadAbsent, requireThreadNotArchived, } from "./commandInvariants.ts"; +import { hasNativeHandoffMessages } from "./handoff.ts"; import { projectEvent } from "./projector.ts"; const nowIso = () => new Date().toISOString(); @@ -228,12 +229,109 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" interactionMode: command.interactionMode, branch: command.branch, worktreePath: command.worktreePath, + associatedWorktreePath: command.worktreePath, + associatedWorktreeBranch: command.branch, + associatedWorktreeRef: command.branch, + createBranchFlowCompleted: false, + handoff: null, createdAt: command.createdAt, updatedAt: command.createdAt, }, }; } + case "thread.handoff.create": { + yield* requireProject({ + readModel, + command, + projectId: command.projectId, + }); + yield* requireThreadAbsent({ + readModel, + command, + threadId: command.threadId, + }); + const sourceThread = yield* requireThread({ + readModel, + command, + threadId: command.sourceThreadId, + }); + if (sourceThread.projectId !== command.projectId) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Source thread '${command.sourceThreadId}' belongs to a different project.`, + }); + } + if (sourceThread.handoff !== null && !hasNativeHandoffMessages(sourceThread)) { + return yield* new OrchestrationCommandInvariantError({ + commandType: command.type, + detail: `Source thread '${command.sourceThreadId}' must contain at least one native message after handoff before it can be handed off again.`, + }); + } + + const associatedWorktreePath = command.associatedWorktreePath ?? command.worktreePath ?? null; + const associatedWorktreeBranch = command.associatedWorktreeBranch ?? command.branch ?? null; + const associatedWorktreeRef = + command.associatedWorktreeRef ?? command.associatedWorktreeBranch ?? command.branch ?? null; + + const createdEvent: PlannedOrchestrationEvent = { + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.created", + payload: { + threadId: command.threadId, + projectId: command.projectId, + title: command.title, + modelSelection: command.modelSelection, + runtimeMode: command.runtimeMode, + interactionMode: command.interactionMode, + branch: command.branch, + worktreePath: command.worktreePath, + associatedWorktreePath, + associatedWorktreeBranch, + associatedWorktreeRef, + createBranchFlowCompleted: command.createBranchFlowCompleted ?? false, + handoff: { + sourceThreadId: command.sourceThreadId, + sourceProvider: sourceThread.modelSelection.provider, + importedAt: command.createdAt, + bootstrapStatus: "pending", + }, + createdAt: command.createdAt, + updatedAt: command.createdAt, + }, + }; + + const importedMessageEvents: ReadonlyArray = + command.importedMessages.map((message) => ({ + ...withEventBase({ + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: command.createdAt, + commandId: command.commandId, + }), + type: "thread.message-sent", + payload: { + threadId: command.threadId, + messageId: message.messageId, + role: message.role, + text: message.text, + ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + turnId: null, + streaming: false, + source: "handoff-import" as const, + createdAt: message.createdAt, + updatedAt: message.updatedAt, + }, + })); + + return [createdEvent, ...importedMessageEvents]; + } + case "thread.delete": { yield* requireThread({ readModel, @@ -330,6 +428,19 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ...(command.implementingJiraTicketKeys !== undefined ? { implementingJiraTicketKeys: command.implementingJiraTicketKeys } : {}), + ...(command.associatedWorktreePath !== undefined + ? { associatedWorktreePath: command.associatedWorktreePath } + : {}), + ...(command.associatedWorktreeBranch !== undefined + ? { associatedWorktreeBranch: command.associatedWorktreeBranch } + : {}), + ...(command.associatedWorktreeRef !== undefined + ? { associatedWorktreeRef: command.associatedWorktreeRef } + : {}), + ...(command.createBranchFlowCompleted !== undefined + ? { createBranchFlowCompleted: command.createBranchFlowCompleted } + : {}), + ...(command.handoff !== undefined ? { handoff: command.handoff } : {}), updatedAt: occurredAt, }, }; diff --git a/apps/server/src/orchestration/handoff.test.ts b/apps/server/src/orchestration/handoff.test.ts new file mode 100644 index 00000000000..4c1f1804667 --- /dev/null +++ b/apps/server/src/orchestration/handoff.test.ts @@ -0,0 +1,245 @@ +import { describe, expect, it } from "vitest"; +import type { OrchestrationMessage, OrchestrationThread } from "@marcode/contracts"; + +import { + buildHandoffBootstrapText, + hasNativeAssistantMessagesBefore, + hasNativeHandoffMessages, + listImportedHandoffMessages, +} from "./handoff.ts"; + +const isoNow = "2026-05-08T00:00:00.000Z"; + +const message = ( + overrides: Partial & Pick, +): OrchestrationMessage => + ({ + turnId: null, + streaming: false, + source: "native", + createdAt: isoNow, + updatedAt: isoNow, + ...overrides, + }) as OrchestrationMessage; + +const baseThread: Pick< + OrchestrationThread, + "title" | "branch" | "worktreePath" | "handoff" | "messages" +> = { + title: "Original" as OrchestrationThread["title"], + branch: null, + worktreePath: null, + handoff: { + sourceThreadId: "thread-A" as OrchestrationThread["handoff"] extends infer T + ? T extends null + ? never + : T extends { sourceThreadId: infer S } + ? S + : never + : never, + sourceProvider: "codex", + importedAt: isoNow as OrchestrationThread["createdAt"], + bootstrapStatus: "pending", + } as OrchestrationThread["handoff"], + messages: [], +}; + +describe("buildHandoffBootstrapText", () => { + it("returns null when there are no imported messages", () => { + expect(buildHandoffBootstrapText({ ...baseThread, messages: [] })).toBeNull(); + }); + + it("returns null when handoff is null", () => { + const imported = message({ + id: "msg-1" as OrchestrationMessage["id"], + role: "user", + text: "hi", + source: "handoff-import", + }); + expect( + buildHandoffBootstrapText({ ...baseThread, handoff: null, messages: [imported] }), + ).toBeNull(); + }); + + it("includes intro, title, and most recent imported messages", () => { + const imported = message({ + id: "msg-1" as OrchestrationMessage["id"], + role: "user", + text: "hello world", + source: "handoff-import", + }); + const text = buildHandoffBootstrapText({ ...baseThread, messages: [imported] }); + expect(text).not.toBeNull(); + expect(text).toContain("This conversation was handed off from codex."); + expect(text).toContain("Original conversation title: Original"); + expect(text).toContain("Most recent imported messages:"); + expect(text).toContain("hello world"); + }); + + it("includes branch + worktree path when set", () => { + const imported = message({ + id: "msg-1" as OrchestrationMessage["id"], + role: "user", + text: "hi", + source: "handoff-import", + }); + const text = buildHandoffBootstrapText({ + ...baseThread, + branch: "feature/x" as OrchestrationThread["branch"], + worktreePath: "/tmp/wt" as OrchestrationThread["worktreePath"], + messages: [imported], + }); + expect(text).toContain("Git branch: feature/x"); + expect(text).toContain("Worktree path: /tmp/wt"); + }); + + it("emits an Earlier conversation summary section when more than RECENT_MESSAGE_COUNT messages", () => { + const imported = Array.from({ length: 10 }, (_, i) => + message({ + id: `msg-${i}` as OrchestrationMessage["id"], + role: i % 2 === 0 ? "user" : "assistant", + text: `body-${i}`, + source: "handoff-import", + }), + ); + const text = buildHandoffBootstrapText({ ...baseThread, messages: imported }); + expect(text).toContain("Earlier conversation summary:"); + expect(text).toContain("body-0"); + expect(text).toContain("body-9"); + }); + + it("respects the maxChars budget by trimming with ellipsis", () => { + const imported = message({ + id: "msg-1" as OrchestrationMessage["id"], + role: "user", + text: "x".repeat(1_000), + source: "handoff-import", + }); + const text = buildHandoffBootstrapText({ ...baseThread, messages: [imported] }, 100); + expect(text).not.toBeNull(); + expect(text!.length).toBeLessThanOrEqual(100); + expect(text!.endsWith("...")).toBe(true); + }); +}); + +describe("listImportedHandoffMessages", () => { + it("filters to user/assistant non-streaming handoff-import messages", () => { + const messages: OrchestrationMessage[] = [ + message({ + id: "msg-native" as OrchestrationMessage["id"], + role: "user", + text: "n", + source: "native", + }), + message({ + id: "msg-imported" as OrchestrationMessage["id"], + role: "user", + text: "i", + source: "handoff-import", + }), + message({ + id: "msg-streaming" as OrchestrationMessage["id"], + role: "assistant", + text: "s", + source: "handoff-import", + streaming: true, + }), + message({ + id: "msg-system" as OrchestrationMessage["id"], + role: "system", + text: "sys", + source: "handoff-import", + }), + ]; + const result = listImportedHandoffMessages({ messages }); + expect(result.map((m) => m.id)).toEqual(["msg-imported"]); + }); +}); + +describe("hasNativeHandoffMessages", () => { + it("returns true if there is at least one native, non-streaming user/assistant message", () => { + const messages: OrchestrationMessage[] = [ + message({ + id: "msg-imported" as OrchestrationMessage["id"], + role: "user", + text: "i", + source: "handoff-import", + }), + message({ + id: "msg-native" as OrchestrationMessage["id"], + role: "user", + text: "n", + source: "native", + }), + ]; + expect(hasNativeHandoffMessages({ messages })).toBe(true); + }); + + it("returns false when only handoff-import messages exist", () => { + const messages: OrchestrationMessage[] = [ + message({ + id: "msg-imported" as OrchestrationMessage["id"], + role: "user", + text: "i", + source: "handoff-import", + }), + ]; + expect(hasNativeHandoffMessages({ messages })).toBe(false); + }); +}); + +describe("hasNativeAssistantMessagesBefore", () => { + it("returns false when the current message is the first one", () => { + const messages: OrchestrationMessage[] = [ + message({ + id: "msg-1" as OrchestrationMessage["id"], + role: "user", + text: "hi", + source: "native", + }), + ]; + expect(hasNativeAssistantMessagesBefore({ messages }, "msg-1")).toBe(false); + }); + + it("returns true when a prior message is a native assistant", () => { + const messages: OrchestrationMessage[] = [ + message({ + id: "msg-a" as OrchestrationMessage["id"], + role: "user", + text: "hi", + source: "native", + }), + message({ + id: "msg-b" as OrchestrationMessage["id"], + role: "assistant", + text: "ok", + source: "native", + }), + message({ + id: "msg-c" as OrchestrationMessage["id"], + role: "user", + text: "more", + source: "native", + }), + ]; + expect(hasNativeAssistantMessagesBefore({ messages }, "msg-c")).toBe(true); + }); + + it("returns false when prior assistants are all imported", () => { + const messages: OrchestrationMessage[] = [ + message({ + id: "msg-a" as OrchestrationMessage["id"], + role: "assistant", + text: "ok", + source: "handoff-import", + }), + message({ + id: "msg-b" as OrchestrationMessage["id"], + role: "user", + text: "next", + source: "native", + }), + ]; + expect(hasNativeAssistantMessagesBefore({ messages }, "msg-b")).toBe(false); + }); +}); diff --git a/apps/server/src/orchestration/handoff.ts b/apps/server/src/orchestration/handoff.ts new file mode 100644 index 00000000000..e3e4ea11a2a --- /dev/null +++ b/apps/server/src/orchestration/handoff.ts @@ -0,0 +1,124 @@ +import { + PROVIDER_SEND_TURN_MAX_INPUT_CHARS, + type OrchestrationMessage, + type OrchestrationThread, +} from "@marcode/contracts"; + +const RECENT_MESSAGE_COUNT = 6; +const EARLIER_MESSAGE_CHAR_LIMIT = 320; +const RECENT_MESSAGE_CHAR_LIMIT = 2_400; + +export const HANDOFF_BOOTSTRAP_CHAR_BUDGET = Math.floor(PROVIDER_SEND_TURN_MAX_INPUT_CHARS * 0.75); + +export const HANDOFF_CONTEXT_WRAPPER_OVERHEAD = + "\n\n\n\n\n\n" + .length; + +function normalizeMessageText(value: string): string { + return value + .replace(/\s+\n/g, "\n") + .replace(/\n{3,}/g, "\n\n") + .trim(); +} + +function truncateText(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + return `${value.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`; +} + +function roleLabel(message: Pick): "User" | "Assistant" { + return message.role === "assistant" ? "Assistant" : "User"; +} + +export function listImportedHandoffMessages( + thread: Pick, +): ReadonlyArray { + return thread.messages.filter( + (message) => + message.source === "handoff-import" && + (message.role === "user" || message.role === "assistant") && + message.streaming === false, + ); +} + +export function hasNativeHandoffMessages(thread: Pick): boolean { + return thread.messages.some( + (message) => + (message.role === "user" || message.role === "assistant") && + message.source === "native" && + message.streaming === false, + ); +} + +export function hasNativeAssistantMessagesBefore( + thread: Pick, + currentMessageId: string, +): boolean { + const currentIndex = thread.messages.findIndex((message) => message.id === currentMessageId); + if (currentIndex <= 0) { + return false; + } + return thread.messages + .slice(0, currentIndex) + .some( + (message) => + message.role === "assistant" && message.source === "native" && message.streaming === false, + ); +} + +export function buildHandoffBootstrapText( + thread: Pick, + maxChars = HANDOFF_BOOTSTRAP_CHAR_BUDGET, +): string | null { + const importedMessages = listImportedHandoffMessages(thread); + if (importedMessages.length === 0 || thread.handoff === null) { + return null; + } + + const earlierMessages = importedMessages.slice(0, -RECENT_MESSAGE_COUNT); + const recentMessages = importedMessages.slice(-RECENT_MESSAGE_COUNT); + const sections: string[] = [ + `This conversation was handed off from ${thread.handoff.sourceProvider}.`, + `Original conversation title: ${thread.title}`, + ]; + + if (thread.branch) { + sections.push(`Git branch: ${thread.branch}`); + } + if (thread.worktreePath) { + sections.push(`Worktree path: ${thread.worktreePath}`); + } + + if (earlierMessages.length > 0) { + sections.push( + "Earlier conversation summary:\n" + + earlierMessages + .map((message) => { + const normalized = truncateText( + normalizeMessageText(message.text), + EARLIER_MESSAGE_CHAR_LIMIT, + ); + return `- ${roleLabel(message)}: ${normalized}`; + }) + .join("\n"), + ); + } + + sections.push( + "Most recent imported messages:\n" + + recentMessages + .map((message) => { + const normalized = truncateText( + normalizeMessageText(message.text), + RECENT_MESSAGE_CHAR_LIMIT, + ); + return `${roleLabel(message)}:\n${normalized}`; + }) + .join("\n\n"), + ); + + const joined = sections.join("\n\n").trim(); + return truncateText(joined, Math.max(0, maxChars)); +} diff --git a/apps/server/src/orchestration/projector.test.ts b/apps/server/src/orchestration/projector.test.ts index 46adc439b7a..6e7d7f816e0 100644 --- a/apps/server/src/orchestration/projector.test.ts +++ b/apps/server/src/orchestration/projector.test.ts @@ -88,6 +88,11 @@ describe("orchestration projector", () => { interactionMode: "default", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 94b57de32ca..e00d2972e4d 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -341,6 +341,11 @@ export function projectEvent( interactionMode: payload.interactionMode, branch: payload.branch, worktreePath: payload.worktreePath, + associatedWorktreePath: payload.associatedWorktreePath, + associatedWorktreeBranch: payload.associatedWorktreeBranch, + associatedWorktreeRef: payload.associatedWorktreeRef, + createBranchFlowCompleted: payload.createBranchFlowCompleted, + handoff: payload.handoff, latestTurn: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, @@ -413,6 +418,19 @@ export function projectEvent( ...(payload.implementingJiraTicketKeys !== undefined ? { implementingJiraTicketKeys: payload.implementingJiraTicketKeys } : {}), + ...(payload.associatedWorktreePath !== undefined + ? { associatedWorktreePath: payload.associatedWorktreePath } + : {}), + ...(payload.associatedWorktreeBranch !== undefined + ? { associatedWorktreeBranch: payload.associatedWorktreeBranch } + : {}), + ...(payload.associatedWorktreeRef !== undefined + ? { associatedWorktreeRef: payload.associatedWorktreeRef } + : {}), + ...(payload.createBranchFlowCompleted !== undefined + ? { createBranchFlowCompleted: payload.createBranchFlowCompleted } + : {}), + ...(payload.handoff !== undefined ? { handoff: payload.handoff } : {}), updatedAt: payload.updatedAt, }), })), @@ -467,6 +485,7 @@ export function projectEvent( ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), turnId: payload.turnId, streaming: payload.streaming, + source: payload.source ?? "native", createdAt: payload.createdAt, updatedAt: payload.updatedAt, }, @@ -488,6 +507,8 @@ export function projectEvent( streaming: message.streaming, updatedAt: message.updatedAt, turnId: message.turnId, + // Preserve the original source - never overwrite (handoff-import + // messages keep their source even when the assistant streams). ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), diff --git a/apps/server/src/orchestration/serverCommandId.ts b/apps/server/src/orchestration/serverCommandId.ts new file mode 100644 index 00000000000..5ef08c1136f --- /dev/null +++ b/apps/server/src/orchestration/serverCommandId.ts @@ -0,0 +1,10 @@ +import { CommandId } from "@marcode/contracts"; + +/** + * Mints a `CommandId` for a server-issued internal orchestration command (one + * that the reactor or an HTTP/WS handler dispatches without being prompted by a + * client). Tagging makes it obvious in the event log which subsystem issued + * which command. + */ +export const serverCommandId = (tag: string): CommandId => + CommandId.make(`server:${tag}:${crypto.randomUUID()}`); diff --git a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts index 26ca858e452..a0e12b19000 100644 --- a/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionRepositories.test.ts @@ -85,6 +85,11 @@ projectionRepositoriesLayer("Projection repositories", (it) => { interactionMode: "default", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurnId: null, diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts index 3a589fbecdf..2faaf5dc903 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts @@ -36,6 +36,7 @@ layer("ProjectionThreadMessageRepository", (it) => { text: "initial", attachments: persistedAttachments, isStreaming: false, + source: "native", createdAt, updatedAt, }); @@ -47,6 +48,7 @@ layer("ProjectionThreadMessageRepository", (it) => { role: "user", text: "updated", isStreaming: false, + source: "native", createdAt, updatedAt: "2026-02-28T19:00:02.000Z", }); @@ -88,6 +90,7 @@ layer("ProjectionThreadMessageRepository", (it) => { }, ], isStreaming: false, + source: "native", createdAt, updatedAt: "2026-02-28T19:10:01.000Z", }); @@ -100,6 +103,7 @@ layer("ProjectionThreadMessageRepository", (it) => { text: "cleared", attachments: [], isStreaming: false, + source: "native", createdAt, updatedAt: "2026-02-28T19:10:02.000Z", }); diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index a0f6fa85cda..34a9f8736cd 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -30,6 +30,7 @@ function toProjectionThreadMessage( role: row.role, text: row.text, isStreaming: row.isStreaming === 1, + source: row.source, createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), @@ -53,6 +54,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json, is_streaming, + source, created_at, updated_at ) @@ -71,6 +73,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ) ), ${row.isStreaming ? 1 : 0}, + ${row.source}, ${row.createdAt}, ${row.updatedAt} ) @@ -85,6 +88,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { projection_thread_messages.attachments_json ), is_streaming = excluded.is_streaming, + -- Preserve the original source on update so handoff-imported messages + -- keep their tag even when the assistant streams completion deltas. created_at = excluded.created_at, updated_at = excluded.updated_at `; @@ -104,6 +109,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source, created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -125,6 +131,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source, created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 3ffec2d82a4..8bdac23dce6 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -1,6 +1,6 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; -import { Effect, Layer, Schema, Struct } from "effect"; +import { Effect, Layer, Option, Schema, Struct } from "effect"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -11,17 +11,26 @@ import { ProjectionThreadRepository, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@marcode/contracts"; +import { ModelSelection, ThreadHandoff } from "@marcode/contracts"; const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), additionalDirectories: Schema.fromJsonString(Schema.Array(Schema.String)), implementingJiraTicketKeys: Schema.fromJsonString(Schema.Array(Schema.String)), + handoff: Schema.NullOr(Schema.fromJsonString(ThreadHandoff)), + createBranchFlowCompleted: Schema.Number, }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; +function toProjectionThread(row: ProjectionThreadDbRow): ProjectionThread { + return { + ...row, + createBranchFlowCompleted: row.createBranchFlowCompleted === 1, + }; +} + const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -38,6 +47,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode, branch, worktree_path, + associated_worktree_path, + associated_worktree_branch, + associated_worktree_ref, + create_branch_flow_completed, + handoff_json, additional_directories_json, implementing_jira_ticket_keys_json, latest_turn_id, @@ -59,6 +73,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.interactionMode}, ${row.branch}, ${row.worktreePath}, + ${row.associatedWorktreePath}, + ${row.associatedWorktreeBranch}, + ${row.associatedWorktreeRef}, + ${row.createBranchFlowCompleted ? 1 : 0}, + ${row.handoff !== null ? JSON.stringify(row.handoff) : null}, ${JSON.stringify(row.additionalDirectories)}, ${JSON.stringify(row.implementingJiraTicketKeys)}, ${row.latestTurnId}, @@ -80,6 +99,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode = excluded.interaction_mode, branch = excluded.branch, worktree_path = excluded.worktree_path, + associated_worktree_path = excluded.associated_worktree_path, + associated_worktree_branch = excluded.associated_worktree_branch, + associated_worktree_ref = excluded.associated_worktree_ref, + create_branch_flow_completed = excluded.create_branch_flow_completed, + handoff_json = excluded.handoff_json, additional_directories_json = excluded.additional_directories_json, implementing_jira_ticket_keys_json = excluded.implementing_jira_ticket_keys_json, latest_turn_id = excluded.latest_turn_id, @@ -108,6 +132,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + associated_worktree_path AS "associatedWorktreePath", + associated_worktree_branch AS "associatedWorktreeBranch", + associated_worktree_ref AS "associatedWorktreeRef", + create_branch_flow_completed AS "createBranchFlowCompleted", + handoff_json AS "handoff", additional_directories_json AS "additionalDirectories", implementing_jira_ticket_keys_json AS "implementingJiraTicketKeys", latest_turn_id AS "latestTurnId", @@ -138,6 +167,11 @@ const makeProjectionThreadRepository = Effect.gen(function* () { interaction_mode AS "interactionMode", branch, worktree_path AS "worktreePath", + associated_worktree_path AS "associatedWorktreePath", + associated_worktree_branch AS "associatedWorktreeBranch", + associated_worktree_ref AS "associatedWorktreeRef", + create_branch_flow_completed AS "createBranchFlowCompleted", + handoff_json AS "handoff", additional_directories_json AS "additionalDirectories", implementing_jira_ticket_keys_json AS "implementingJiraTicketKeys", latest_turn_id AS "latestTurnId", @@ -172,11 +206,13 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + Effect.map(Option.map(toProjectionThread)), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + Effect.map((rows) => rows.map(toProjectionThread)), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index ce1d13862be..2fb0acbaaca 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -43,6 +43,7 @@ import Migration0028 from "./Migrations/028_BackfillProjectionThreadShellSummary import Migration0029 from "./Migrations/029_CleanupInvalidProjectionPendingApprovals.ts"; import Migration0030 from "./Migrations/030_CanonicalizeModelSelectionOptions.ts"; import Migration0031 from "./Migrations/031_ProjectionThreadsImplementingJiraTicketKeys.ts"; +import Migration0032 from "./Migrations/032_ProjectionThreadsHandoffMetadata.ts"; /** * Migration loader with all migrations defined inline. @@ -85,6 +86,7 @@ export const migrationEntries = [ [29, "CleanupInvalidProjectionPendingApprovals", Migration0029], [30, "CanonicalizeModelSelectionOptions", Migration0030], [31, "ProjectionThreadsImplementingJiraTicketKeys", Migration0031], + [32, "ProjectionThreadsHandoffMetadata", Migration0032], ] as const; export const makeMigrationLoader = (throughId?: number) => diff --git a/apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.test.ts b/apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.test.ts new file mode 100644 index 00000000000..1380f7e4213 --- /dev/null +++ b/apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.test.ts @@ -0,0 +1,215 @@ +import { assert, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +import { runMigrations } from "../Migrations.ts"; +import * as NodeSqliteClient from "../NodeSqliteClient.ts"; + +/** + * Regression-guard: a future merge from the upstream `pingdotgg/t3code` repo + * MUST NOT silently drop migration 032. Mirrors the spirit of + * `windowState.integration-guard.test.ts` and + * `service.notification-wiring.test.ts`. + * + * Each test gets its own in-memory database layer because `it.layer` shares + * the underlying connection across tests in a single block, and a re-run of + * `runMigrations({toMigrationInclusive: 32})` is a no-op once the migration + * tracking table records it. + */ + +const freshDb = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +freshDb("032_ProjectionThreadsHandoffMetadata: schema", (it) => { + it.effect("adds handoff + workspace association columns to projection_threads", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 32 }); + + const threadCols = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const threadColNames = new Set(threadCols.map((row) => row.name)); + assert.isTrue(threadColNames.has("handoff_json"), "handoff_json column missing"); + assert.isTrue( + threadColNames.has("associated_worktree_path"), + "associated_worktree_path column missing", + ); + assert.isTrue( + threadColNames.has("associated_worktree_branch"), + "associated_worktree_branch column missing", + ); + assert.isTrue( + threadColNames.has("associated_worktree_ref"), + "associated_worktree_ref column missing", + ); + assert.isTrue( + threadColNames.has("create_branch_flow_completed"), + "create_branch_flow_completed column missing", + ); + + const messageCols = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + const messageColNames = new Set(messageCols.map((row) => row.name)); + assert.isTrue(messageColNames.has("source"), "source column missing on messages"); + }), + ); +}); + +const backfillDb = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +backfillDb("032_ProjectionThreadsHandoffMetadata: backfill", (it) => { + it.effect("backfills associated_worktree_path/branch from existing worktree_path/branch", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 31 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-with-worktree', + 'project-1', + 'WT thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + 'feature/x', + '/tmp/worktree-x', + NULL, + '2026-05-08T00:00:00.000Z', + '2026-05-08T00:00:00.000Z', + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + + yield* runMigrations({ toMigrationInclusive: 32 }); + + const rows = yield* sql<{ + readonly associated_worktree_path: string | null; + readonly associated_worktree_branch: string | null; + readonly associated_worktree_ref: string | null; + readonly create_branch_flow_completed: number; + }>` + SELECT + associated_worktree_path, + associated_worktree_branch, + associated_worktree_ref, + create_branch_flow_completed + FROM projection_threads + WHERE thread_id = 'thread-with-worktree' + `; + + assert.strictEqual(rows.length, 1); + const row = rows[0]!; + assert.strictEqual(row.associated_worktree_path, "/tmp/worktree-x"); + assert.strictEqual(row.associated_worktree_branch, "feature/x"); + assert.strictEqual(row.associated_worktree_ref, "feature/x"); + assert.strictEqual(row.create_branch_flow_completed, 0); + }), + ); +}); + +const messageSourceDb = it.layer(Layer.mergeAll(NodeSqliteClient.layerMemory())); + +messageSourceDb("032_ProjectionThreadsHandoffMetadata: message source", (it) => { + it.effect("defaults message source to 'native' for existing rows", () => + Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* runMigrations({ toMigrationInclusive: 31 }); + + yield* sql` + INSERT INTO projection_threads ( + thread_id, + project_id, + title, + model_selection_json, + runtime_mode, + interaction_mode, + branch, + worktree_path, + latest_turn_id, + created_at, + updated_at, + archived_at, + latest_user_message_at, + pending_approval_count, + pending_user_input_count, + has_actionable_proposed_plan, + deleted_at + ) + VALUES ( + 'thread-msg', + 'project-1', + 'msg thread', + '{"provider":"codex","model":"gpt-5-codex"}', + 'approval-required', + 'default', + NULL, + NULL, + NULL, + '2026-05-08T00:00:00.000Z', + '2026-05-08T00:00:00.000Z', + NULL, + NULL, + 0, + 0, + 0, + NULL + ) + `; + yield* sql` + INSERT INTO projection_thread_messages ( + thread_id, + message_id, + role, + text, + turn_id, + is_streaming, + created_at, + updated_at + ) + VALUES ( + 'thread-msg', + 'message-1', + 'user', + 'hello', + NULL, + 0, + '2026-05-08T00:00:00.000Z', + '2026-05-08T00:00:00.000Z' + ) + `; + + yield* runMigrations({ toMigrationInclusive: 32 }); + + const rows = yield* sql<{ readonly source: string }>` + SELECT source FROM projection_thread_messages WHERE message_id = 'message-1' + `; + assert.strictEqual(rows.length, 1); + assert.strictEqual(rows[0]!.source, "native"); + }), + ); +}); diff --git a/apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.ts b/apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.ts new file mode 100644 index 00000000000..b74d4031141 --- /dev/null +++ b/apps/server/src/persistence/Migrations/032_ProjectionThreadsHandoffMetadata.ts @@ -0,0 +1,99 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Adds handoff- and workspace-association columns to `projection_threads`, + * plus a `source` column to `projection_thread_messages` so the bootstrap + * reactor and timeline can distinguish native turns from handoff-imported + * copies. + * + * Idempotent: each ALTER is gated on a `pragma_table_info` probe so re-runs + * (and partial failures) are safe. + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const projectionThreadsColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + const projectionThreadsHasColumn = (columnName: string) => + projectionThreadsColumns.some((column) => column.name === columnName); + + const projectionThreadMessagesColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + const projectionThreadMessagesHasColumn = (columnName: string) => + projectionThreadMessagesColumns.some((column) => column.name === columnName); + + if (!projectionThreadsHasColumn("handoff_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN handoff_json TEXT + `; + } + + const addedAssociatedWorktreePath = !projectionThreadsHasColumn("associated_worktree_path"); + if (addedAssociatedWorktreePath) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN associated_worktree_path TEXT + `; + } + + const addedAssociatedWorktreeBranch = !projectionThreadsHasColumn("associated_worktree_branch"); + if (addedAssociatedWorktreeBranch) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN associated_worktree_branch TEXT + `; + } + + const addedAssociatedWorktreeRef = !projectionThreadsHasColumn("associated_worktree_ref"); + if (addedAssociatedWorktreeRef) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN associated_worktree_ref TEXT + `; + } + + // Backfill the association columns from the live `branch` / `worktree_path` + // values for any thread that does not yet have an association recorded. We + // run these AFTER all ALTERs so SQLite's transaction-time schema visibility + // does not bite us. + if (addedAssociatedWorktreePath) { + yield* sql` + UPDATE projection_threads + SET associated_worktree_path = worktree_path + WHERE associated_worktree_path IS NULL + `; + } + if (addedAssociatedWorktreeBranch) { + yield* sql` + UPDATE projection_threads + SET associated_worktree_branch = branch + WHERE associated_worktree_branch IS NULL + `; + } + if (addedAssociatedWorktreeRef) { + yield* sql` + UPDATE projection_threads + SET associated_worktree_ref = COALESCE(associated_worktree_branch, branch) + WHERE associated_worktree_ref IS NULL + AND COALESCE(associated_worktree_branch, branch) IS NOT NULL + `; + } + + if (!projectionThreadsHasColumn("create_branch_flow_completed")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN create_branch_flow_completed INTEGER NOT NULL DEFAULT 0 + `; + } + + if (!projectionThreadMessagesHasColumn("source")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN source TEXT NOT NULL DEFAULT 'native' + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index 39b5282ec70..f78027a92a3 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -10,11 +10,12 @@ import { ChatAttachment, MessageId, OrchestrationMessageRole, + OrchestrationMessageSource, ThreadId, TurnId, IsoDateTime, } from "@marcode/contracts"; -import { Schema, Context } from "effect"; +import { Effect as EffectModule, Schema, Context } from "effect"; import type { Option } from "effect"; import type { Effect } from "effect"; @@ -28,6 +29,9 @@ export const ProjectionThreadMessage = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), isStreaming: Schema.Boolean, + source: OrchestrationMessageSource.pipe( + Schema.withDecodingDefault(EffectModule.succeed("native")), + ), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index 41fa7fa9554..5d2bd3e2857 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -13,10 +13,11 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + ThreadHandoff, ThreadId, TurnId, } from "@marcode/contracts"; -import { Option, Schema, Context } from "effect"; +import { Effect as EffectModule, Option, Schema, Context } from "effect"; import type { Effect } from "effect"; import type { ProjectionRepositoryError } from "../Errors.ts"; @@ -30,6 +31,21 @@ export const ProjectionThread = Schema.Struct({ interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), worktreePath: Schema.NullOr(Schema.String), + associatedWorktreePath: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(EffectModule.succeed(null)), + ), + associatedWorktreeBranch: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(EffectModule.succeed(null)), + ), + associatedWorktreeRef: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(EffectModule.succeed(null)), + ), + createBranchFlowCompleted: Schema.Boolean.pipe( + Schema.withDecodingDefault(EffectModule.succeed(false)), + ), + handoff: Schema.NullOr(ThreadHandoff).pipe( + Schema.withDecodingDefault(EffectModule.succeed(null)), + ), additionalDirectories: Schema.Array(Schema.String), implementingJiraTicketKeys: Schema.Array(Schema.String), latestTurnId: Schema.NullOr(TurnId), diff --git a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts index 186053f2e21..9aba4996cf1 100644 --- a/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +++ b/apps/server/src/provider/Layers/ProviderSessionReaper.test.ts @@ -85,6 +85,11 @@ function makeReadModel( runtimeMode: "full-access" as const, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], createdAt: now, diff --git a/apps/server/src/provider/Layers/StartupSessionRecovery.test.ts b/apps/server/src/provider/Layers/StartupSessionRecovery.test.ts index 5f7e08222ce..8b6fba63a04 100644 --- a/apps/server/src/provider/Layers/StartupSessionRecovery.test.ts +++ b/apps/server/src/provider/Layers/StartupSessionRecovery.test.ts @@ -64,6 +64,11 @@ function makeReadModel( runtimeMode: "full-access" as const, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], createdAt: now, diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 8285765d86e..fd266e534aa 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -157,6 +157,11 @@ const makeDefaultOrchestrationReadModel = () => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], createdAt: now, @@ -187,6 +192,7 @@ const makeDefaultOrchestrationThreadShell = ( interactionMode: "default", branch: null, worktreePath: null, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, @@ -3098,6 +3104,11 @@ it.layer(NodeServices.layer)("server router seam", (it) => { runtimeMode: "full-access" as const, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], createdAt: now, @@ -3136,6 +3147,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, + handoff: thread.handoff, additionalDirectories: thread.additionalDirectories, implementingJiraTicketKeys: thread.implementingJiraTicketKeys, latestTurn: thread.latestTurn, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 358b2209b0f..22aa0d002e0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -938,6 +938,12 @@ const makeWsRpcLayer = (currentSessionId: AuthSessionId) => .pipe(Effect.tap(() => refreshGitStatus(input.cwd))), { "rpc.aggregate": "git" }, ), + [WS_METHODS.gitHandoffThread]: (input) => + observeRpcEffect( + WS_METHODS.gitHandoffThread, + gitManager.handoffThread(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitListBranches]: (input) => observeRpcEffect(WS_METHODS.gitListBranches, git.listBranches(input), { "rpc.aggregate": "git", diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index 4722e26c013..d3a3aca7f89 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -272,6 +272,7 @@ function createUserMessage(options: { ...(options.attachments ? { attachments: options.attachments } : {}), turnId: null, streaming: false, + source: "native" as const, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; @@ -284,6 +285,7 @@ function createAssistantMessage(options: { id: MessageId; text: string; offsetSe text: options.text, turnId: null, streaming: false, + source: "native" as const, createdAt: isoAt(options.offsetSeconds), updatedAt: isoAt(options.offsetSeconds + 1), }; @@ -380,6 +382,11 @@ function createSnapshotForTargetUser(options: { runtimeMode: "full-access", branch: "main", worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: { @@ -455,6 +462,11 @@ function addThreadToSnapshot( runtimeMode: "full-access", branch: "main", worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, @@ -491,6 +503,7 @@ function toShellThread(thread: OrchestrationReadModel["threads"][number]) { interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, + handoff: null, additionalDirectories: thread.additionalDirectories, latestTurn: thread.latestTurn, createdAt: thread.createdAt, @@ -796,6 +809,11 @@ function createSnapshotWithSecondaryProject(options?: { runtimeMode: "full-access", branch: "release/docs-portal", worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, @@ -831,6 +849,11 @@ function createSnapshotWithSecondaryProject(options?: { runtimeMode: "full-access", branch: "release/docs-archive", worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 75597d07cb4..c6b502e8abb 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -106,6 +106,7 @@ describe("deriveActiveThreadActivityIso", () => { role: "user" as const, text: "newer hydrated message", streaming: false, + source: "native" as const, createdAt: "2026-03-29T00:02:00.000Z", updatedAt: "2026-03-29T00:02:00.000Z", }, @@ -130,6 +131,7 @@ describe("deriveActiveThreadActivityIso", () => { hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, + handoff: null, }), ).toBe("2026-03-29T00:01:00.000Z"); }); @@ -144,6 +146,7 @@ describe("deriveActiveThreadActivityIso", () => { role: "assistant" as const, text: "assistant", streaming: false, + source: "native" as const, createdAt: "2026-03-29T00:01:00.000Z", updatedAt: "2026-03-29T00:01:00.000Z", }, @@ -153,6 +156,7 @@ describe("deriveActiveThreadActivityIso", () => { role: "user" as const, text: "user", streaming: false, + source: "native" as const, createdAt: "2026-03-29T00:02:00.000Z", updatedAt: "2026-03-29T00:02:00.000Z", }, @@ -307,6 +311,11 @@ const makeThread = (input?: { : null, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], turnDiffSummaries: [], @@ -355,6 +364,7 @@ function setStoreThreads(threads: ReadonlyArray>) updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, + handoff: thread.handoff, additionalDirectories: thread.additionalDirectories, implementingJiraTicketKeys: thread.implementingJiraTicketKeys, }, @@ -554,6 +564,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { latestTurn: previousLatestTurn, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], turnDiffSummaries: [], @@ -592,6 +607,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { latestTurn: previousLatestTurn, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], turnDiffSummaries: [], @@ -636,6 +656,11 @@ describe("hasServerAcknowledgedLocalDispatch", () => { latestTurn: previousLatestTurn, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], turnDiffSummaries: [], diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 4d33482ae5c..60b31a4f677 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -55,6 +55,11 @@ export function buildLocalDraftThread( latestTurn: null, branch: draftThread.branch, worktreePath: draftThread.worktreePath, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [...additionalDirectories], implementingJiraTicketKeys: [], turnDiffSummaries: [], diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2f2e0dcf819..22f822cd489 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -55,6 +55,12 @@ import { useNavigate, useSearch } from "@tanstack/react-router"; import { useShallow } from "zustand/react/shallow"; import { useGitStatus } from "~/lib/gitStatusState"; import { projectSearchEntriesQueryOptions } from "~/lib/projectReactQuery"; +import { + canCreateThreadHandoff, + resolveAvailableHandoffTargetProviders, + resolveThreadHandoffBadgeLabel, +} from "../lib/threadHandoff"; +import { useThreadHandoff } from "../hooks/useThreadHandoff"; import { usePrimaryEnvironmentId } from "../environments/primary"; import { readEnvironmentApi } from "../environmentApi"; import { getServerHttpOrigin, isElectron } from "../env"; @@ -824,6 +830,7 @@ export default function ChatView({ ); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); + const { createThreadHandoff } = useThreadHandoff(); const queryClient = useQueryClient(); const rawSearch = useSearch({ strict: false, @@ -1569,6 +1576,26 @@ export default function ChatView({ hasInFlightTurn || isSessionStarting || hasPendingAssistantResponse; + const handoffActionTargetProviders = useMemo( + () => + activeThread + ? resolveAvailableHandoffTargetProviders(activeThread.modelSelection.provider) + : [], + [activeThread], + ); + const handoffDisabled = !( + activeThread && + canCreateThreadHandoff({ + thread: activeThread, + isBusy: isWorking, + hasPendingApprovals: pendingApprovals.length > 0, + hasPendingUserInput: pendingUserInputs.length > 0, + }) + ); + const handoffBadgeLabel = activeThread ? resolveThreadHandoffBadgeLabel(activeThread) : null; + const handoffBadgeSourceProvider = activeThread?.handoff?.sourceProvider ?? null; + const handoffBadgeTargetProvider = + activeThread?.handoff != null ? activeThread.modelSelection.provider : null; const isCompacting = activeThread?.session?.compacting === true; const isThreadHydrating = false; const activeWorkStartedAt = @@ -3496,6 +3523,7 @@ export default function ChatView({ ...(optimisticAttachments.length > 0 ? { attachments: optimisticAttachments } : {}), createdAt: messageCreatedAt, streaming: false, + source: "native", }, ]); isAtEndRef.current = true; @@ -3830,6 +3858,22 @@ export default function ChatView({ }); }; + const onCreateHandoff = useCallback( + async (target: ProviderKind) => { + if (!activeThread) return; + try { + await createThreadHandoff(activeThread, target); + } catch (error) { + toastManager.add({ + type: "error", + title: "Could not create handoff", + description: error instanceof Error ? error.message : "Unknown error", + }); + } + }, + [activeThread, createThreadHandoff], + ); + const onRespondToApproval = useCallback( async (requestId: ApprovalRequestId, decision: ProviderApprovalDecision) => { const api = readNativeApi(); @@ -4029,6 +4073,7 @@ export default function ChatView({ text: outgoingMessageText, createdAt: messageCreatedAt, streaming: false, + source: "native", }, ]); isAtEndRef.current = true; @@ -4919,6 +4964,12 @@ export default function ChatView({ hasPlan={Boolean(activePlan || sidebarProposedPlan)} planSidebarOpen={effectivePlanSidebarOpen} planSidebarLabel={planSidebarLabel} + handoffActionTargetProviders={handoffActionTargetProviders} + handoffDisabled={handoffDisabled} + onCreateHandoff={onCreateHandoff} + handoffBadgeLabel={handoffBadgeLabel} + handoffBadgeSourceProvider={handoffBadgeSourceProvider} + handoffBadgeTargetProvider={handoffBadgeTargetProvider} onRunProjectScript={runProjectScript} onAddProjectScript={saveProjectScript} onUpdateProjectScript={updateProjectScript} diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index e2cf4fc2219..86e0e5a7988 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -30,6 +30,11 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], turnDiffSummaries: [], diff --git a/apps/web/src/components/KeybindingsToast.browser.tsx b/apps/web/src/components/KeybindingsToast.browser.tsx index ba85571c7ac..e3d084ed32f 100644 --- a/apps/web/src/components/KeybindingsToast.browser.tsx +++ b/apps/web/src/components/KeybindingsToast.browser.tsx @@ -157,6 +157,11 @@ function createMinimalSnapshot(): OrchestrationReadModel { runtimeMode: "full-access", branch: "main", worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, @@ -171,6 +176,7 @@ function createMinimalSnapshot(): OrchestrationReadModel { text: "hello", turnId: null, streaming: false, + source: "native", createdAt: NOW_ISO, updatedAt: NOW_ISO, }, diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index f3afaa7502b..8208813db11 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -735,6 +735,11 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], turnDiffSummaries: [], @@ -826,6 +831,7 @@ describe("sortProjectsForSidebar", () => { text: "older project user message", createdAt: "2026-03-09T10:01:00.000Z", streaming: false, + source: "native", completedAt: "2026-03-09T10:01:00.000Z", }, ], @@ -841,6 +847,7 @@ describe("sortProjectsForSidebar", () => { text: "newer project user message", createdAt: "2026-03-09T10:05:00.000Z", streaming: false, + source: "native", completedAt: "2026-03-09T10:05:00.000Z", }, ], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 0a1e82cdd00..213f0078a4e 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ import { ArchiveIcon, + ArrowRightIcon, ArrowUpDownIcon, ChevronRightIcon, CloudIcon, @@ -11,6 +12,8 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; +import { PROVIDER_DISPLAY_NAMES } from "@marcode/contracts"; +import { PROVIDER_ICON_BY_PROVIDER } from "./chat/providerIconUtils"; import { prStatusIndicator, resolveThreadPr, @@ -284,6 +287,32 @@ function buildThreadJumpLabelMap(input: { return mapping.size > 0 ? mapping : EMPTY_THREAD_JUMP_LABELS; } +function ThreadHandoffBadge({ + handoff, +}: { + handoff: NonNullable; +}) { + const SourceIcon = PROVIDER_ICON_BY_PROVIDER[handoff.sourceProvider]; + return ( + + + + + + } + /> + + Handoff from {PROVIDER_DISPLAY_NAMES[handoff.sourceProvider]} + + + ); +} + interface SidebarThreadRowProps { thread: SidebarThreadSummary; projectCwd: string | null; @@ -593,6 +622,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP )} {threadStatus && } + {thread.handoff && } {renamingThreadKey === threadKey ? ( ; + handoffDisabled?: boolean; + hideHandoffControls?: boolean; + onCreateHandoff?: (target: ProviderKind) => void; + handoffBadgeLabel?: string | null; + handoffBadgeSourceProvider?: ProviderKind | null; + handoffBadgeTargetProvider?: ProviderKind | null; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; @@ -50,6 +70,14 @@ interface ChatHeaderProps { onTogglePlanSidebar: () => void; } +function renderProviderIcon(provider: ProviderKind | null, className: string) { + if (provider === null) { + return ; + } + const Icon = PROVIDER_ICON_BY_PROVIDER[provider]; + return ; +} + export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, @@ -72,6 +100,13 @@ export const ChatHeader = memo(function ChatHeader({ hasPlan, planSidebarOpen, planSidebarLabel, + handoffActionTargetProviders, + handoffDisabled = false, + hideHandoffControls = false, + onCreateHandoff, + handoffBadgeLabel = null, + handoffBadgeSourceProvider = null, + handoffBadgeTargetProvider = null, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -88,26 +123,52 @@ export const ChatHeader = memo(function ChatHeader({ const primaryEnvironmentId = usePrimaryEnvironmentId(); const isRemoteEnvironment = primaryEnvironmentId !== null && activeThreadEnvironmentId !== primaryEnvironmentId; + const showHandoffMenu = + !hideHandoffControls && Boolean(onCreateHandoff) && handoffActionTargetProviders.length > 0; + const showHandoffBadge = !hideHandoffControls && handoffBadgeLabel !== null; return (
- - - {activeThreadTitle} - - } - /> - - {activeThreadTitle} - - +
+ + + {activeThreadTitle} + + } + /> + + {activeThreadTitle} + + + {showHandoffBadge && ( + + + + {renderProviderIcon(handoffBadgeSourceProvider, "size-3")} + + + + {renderProviderIcon(handoffBadgeTargetProvider, "size-3")} + + + } + /> + {handoffBadgeLabel} + + )} +
{showMetaRow && (
{activeProjectName && {activeProjectName}} @@ -138,6 +199,40 @@ export const ChatHeader = memo(function ChatHeader({ )}
+ {showHandoffMenu && onCreateHandoff && ( + + + + } + > + + Hand off + + } + /> + Hand off thread + + + {handoffActionTargetProviders.map((provider) => ( + onCreateHandoff(provider)}> + {renderProviderIcon(provider, "size-3.5 shrink-0")} + Handoff to {PROVIDER_DISPLAY_NAMES[provider]} + + ))} + + + )} {activeProjectScripts && ( { turnId: null, createdAt: "2026-01-01T00:00:00Z", streaming: false, + source: "native", }, }, { @@ -308,6 +309,7 @@ describe("deriveMessagesTimelineRows", () => { createdAt: "2026-01-01T00:00:10Z", completedAt: "2026-01-01T00:00:11Z", streaming: false, + source: "native", }, }, { @@ -322,6 +324,7 @@ describe("deriveMessagesTimelineRows", () => { createdAt: "2026-01-01T00:00:20Z", completedAt: "2026-01-01T00:00:30Z", streaming: false, + source: "native", }, }, ], @@ -361,6 +364,7 @@ describe("selectUserMessageMinimapEntries", () => { createdAt: "2026-01-01T00:00:10Z", completedAt: "2026-01-01T00:00:11Z", streaming: false, + source: "native", }, durationStart: "2026-01-01T00:00:10Z", showCompletionDivider: false, @@ -410,6 +414,7 @@ describe("selectUserMessageMinimapEntries", () => { turnId: null, createdAt: "2026-01-01T00:00:05Z", streaming: false, + source: "native", }, durationStart: "2026-01-01T00:00:05Z", showCompletionDivider: false, @@ -427,6 +432,7 @@ describe("selectUserMessageMinimapEntries", () => { createdAt: "2026-01-01T00:00:10Z", completedAt: "2026-01-01T00:00:11Z", streaming: false, + source: "native", }, durationStart: "2026-01-01T00:00:05Z", showCompletionDivider: false, @@ -443,6 +449,7 @@ describe("selectUserMessageMinimapEntries", () => { turnId: null, createdAt: "2026-01-01T00:00:20Z", streaming: false, + source: "native", }, durationStart: "2026-01-01T00:00:20Z", showCompletionDivider: false, @@ -480,6 +487,7 @@ describe("selectUserMessageMinimapEntries", () => { turnId: null, createdAt: "2026-01-01T00:00:00Z", streaming: false, + source: "native", }, durationStart: "2026-01-01T00:00:00Z", showCompletionDivider: false, @@ -505,6 +513,7 @@ describe("selectUserMessageMinimapEntries", () => { turnId: null, createdAt: "2026-01-01T00:00:00Z", streaming: false, + source: "native", }, durationStart: "2026-01-01T00:00:00Z", showCompletionDivider: false, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 61dcf38304c..d1a311f2be3 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -111,6 +111,7 @@ describe("MessagesTimeline", () => { ].join("\n"), createdAt: "2026-03-17T19:12:28.000Z", streaming: false, + source: "native", }, }, ]} diff --git a/apps/web/src/environmentApi.ts b/apps/web/src/environmentApi.ts index 4a05de32d2d..a80488cb5a9 100644 --- a/apps/web/src/environmentApi.ts +++ b/apps/web/src/environmentApi.ts @@ -36,6 +36,7 @@ export function createEnvironmentApi(rpcClient: WsRpcClient): EnvironmentApi { init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, + handoffThread: rpcClient.git.handoffThread, workingTreeDiff: rpcClient.git.workingTreeDiff, }, orchestration: { diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index d730bb6efd4..423ce9e55fc 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -74,6 +74,7 @@ function makeSidebarThreadSummary( hasPendingApprovals: false, hasPendingUserInput: false, hasActionableProposedPlan: false, + handoff: null, ...overrides, }; } diff --git a/apps/web/src/historyBootstrap.test.ts b/apps/web/src/historyBootstrap.test.ts index d595febe3dd..99ae65779e3 100644 --- a/apps/web/src/historyBootstrap.test.ts +++ b/apps/web/src/historyBootstrap.test.ts @@ -15,6 +15,7 @@ describe("buildBootstrapInput", () => { text: "hello", createdAt: "2026-02-09T00:00:00.000Z", streaming: false, + source: "native", }, { id: messageId("a-1"), @@ -22,6 +23,7 @@ describe("buildBootstrapInput", () => { text: "world", createdAt: "2026-02-09T00:00:01.000Z", streaming: false, + source: "native", }, ], "what's next?", @@ -46,6 +48,7 @@ describe("buildBootstrapInput", () => { text: "first question with details", createdAt: "2026-02-09T00:00:00.000Z", streaming: false, + source: "native", }, { id: messageId("a-1"), @@ -53,6 +56,7 @@ describe("buildBootstrapInput", () => { text: "first answer with details", createdAt: "2026-02-09T00:00:01.000Z", streaming: false, + source: "native", }, { id: messageId("u-2"), @@ -60,6 +64,7 @@ describe("buildBootstrapInput", () => { text: "second question with details", createdAt: "2026-02-09T00:00:02.000Z", streaming: false, + source: "native", }, ], "final request", @@ -83,6 +88,7 @@ describe("buildBootstrapInput", () => { text: "old context", createdAt: "2026-02-09T00:00:00.000Z", streaming: false, + source: "native", }, ], latestPrompt, @@ -113,6 +119,7 @@ describe("buildBootstrapInput", () => { ], createdAt: "2026-02-09T00:00:00.000Z", streaming: false, + source: "native", }, ], "What does this error mean?", diff --git a/apps/web/src/hooks/useThreadHandoff.ts b/apps/web/src/hooks/useThreadHandoff.ts new file mode 100644 index 00000000000..e21ef27548f --- /dev/null +++ b/apps/web/src/hooks/useThreadHandoff.ts @@ -0,0 +1,100 @@ +import { useNavigate } from "@tanstack/react-router"; +import { useCallback } from "react"; +import { type ProviderKind } from "@marcode/contracts"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { ensureEnvironmentApi } from "../environmentApi"; +import { + buildThreadHandoffImportedMessages, + canCreateThreadHandoff, + resolveAvailableHandoffTargetProviders, + resolveThreadHandoffModelSelection, +} from "../lib/threadHandoff"; +import { newCommandId, newThreadId } from "../lib/utils"; +import { isProviderEnabled } from "../providerModels"; +import { useServerProviders } from "../rpc/serverState"; +import { selectProjectByRef, useStore } from "../store"; +import { buildThreadRouteParams } from "../threadRoutes"; +import { type Thread } from "../types"; +import { scopeProjectRef, scopeThreadRef } from "@marcode/client-runtime"; + +export function useThreadHandoff() { + const navigate = useNavigate(); + const providers = useServerProviders(); + + const createThreadHandoff = useCallback( + async (thread: Thread, targetProvider: ProviderKind): Promise => { + const api = ensureEnvironmentApi(thread.environmentId); + + const project = selectProjectByRef( + useStore.getState(), + scopeProjectRef(thread.environmentId, thread.projectId), + ); + if (!project) { + throw new Error("Project not found for handoff thread."); + } + + if (!canCreateThreadHandoff({ thread })) { + throw new Error("This thread cannot be handed off yet."); + } + if ( + !resolveAvailableHandoffTargetProviders(thread.modelSelection.provider).includes( + targetProvider, + ) + ) { + throw new Error("This handoff target is not available for the current thread."); + } + if (!isProviderEnabled(providers, targetProvider)) { + throw new Error("This provider is not available yet."); + } + + const nextThreadId = newThreadId(); + const createdAt = new Date().toISOString(); + const importedMessages = buildThreadHandoffImportedMessages(thread); + const stickyModelSelectionByProvider = + useComposerDraftStore.getState().stickyModelSelectionByProvider ?? {}; + + await api.orchestration.dispatchCommand({ + type: "thread.handoff.create", + commandId: newCommandId(), + threadId: nextThreadId, + sourceThreadId: thread.id, + projectId: thread.projectId, + title: thread.title, + modelSelection: resolveThreadHandoffModelSelection({ + sourceThread: thread, + targetProvider, + projectDefaultModelSelection: project.defaultModelSelection, + stickyModelSelectionByProvider, + }), + runtimeMode: thread.runtimeMode, + interactionMode: thread.interactionMode, + branch: thread.branch, + worktreePath: thread.worktreePath, + associatedWorktreePath: thread.associatedWorktreePath ?? thread.worktreePath ?? null, + associatedWorktreeBranch: thread.associatedWorktreeBranch ?? thread.branch ?? null, + associatedWorktreeRef: + thread.associatedWorktreeRef ?? thread.associatedWorktreeBranch ?? thread.branch ?? null, + createBranchFlowCompleted: thread.createBranchFlowCompleted, + importedMessages: [...importedMessages], + createdAt, + }); + + // TODO: dispatch imported activities once `thread.activity.append` is added + // to `ClientOrchestrationCommand` (currently server-internal only). + // TODO: copy transferable composer state once `copyTransferableComposerState` + // exists on `useComposerDraftStore`. + + await navigate({ + to: "/$environmentId/$threadId", + params: buildThreadRouteParams(scopeThreadRef(thread.environmentId, nextThreadId)), + }); + + return nextThreadId; + }, + [navigate, providers], + ); + + return { + createThreadHandoff, + }; +} diff --git a/apps/web/src/lib/threadHandoff.test.ts b/apps/web/src/lib/threadHandoff.test.ts new file mode 100644 index 00000000000..76e264100b4 --- /dev/null +++ b/apps/web/src/lib/threadHandoff.test.ts @@ -0,0 +1,210 @@ +import { + EnvironmentId, + MessageId, + type ModelSelection, + ProjectId, + ThreadId, +} from "@marcode/contracts"; +import { describe, expect, it } from "vitest"; +import { + DEFAULT_INTERACTION_MODE, + DEFAULT_RUNTIME_MODE, + type ChatMessage, + type Thread, + type ThreadSession, +} from "../types"; +import { + canCreateThreadHandoff, + resolveAvailableHandoffTargetProviders, + resolveThreadHandoffModelSelection, +} from "./threadHandoff"; + +function makeThread(overrides: Partial = {}): Thread { + return { + id: ThreadId.make("thread-1"), + environmentId: EnvironmentId.make("environment-local"), + codexThreadId: null, + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + runtimeMode: DEFAULT_RUNTIME_MODE, + interactionMode: DEFAULT_INTERACTION_MODE, + session: null, + messages: [], + proposedPlans: [], + error: null, + createdAt: "2026-02-13T00:00:00.000Z", + archivedAt: null, + latestTurn: null, + branch: null, + worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, + additionalDirectories: [], + implementingJiraTicketKeys: [], + turnDiffSummaries: [], + activities: [], + ...overrides, + }; +} + +function makeMessage(overrides: Partial = {}): ChatMessage { + return { + id: MessageId.make(`message-${Math.random().toString(36).slice(2, 10)}`), + role: "user", + text: "hello", + source: "native", + createdAt: "2026-02-13T00:01:00.000Z", + streaming: false, + ...overrides, + }; +} + +describe("threadHandoff", () => { + it("lists all supported handoff targets except the active provider", () => { + expect(resolveAvailableHandoffTargetProviders("codex")).toEqual([ + "claudeAgent", + "cursor", + "opencode", + ]); + expect(resolveAvailableHandoffTargetProviders("claudeAgent")).toEqual([ + "codex", + "cursor", + "opencode", + ]); + expect(resolveAvailableHandoffTargetProviders("cursor")).toEqual([ + "codex", + "claudeAgent", + "opencode", + ]); + expect(resolveAvailableHandoffTargetProviders("opencode")).toEqual([ + "codex", + "claudeAgent", + "cursor", + ]); + }); + + it("prefers sticky model selection for the chosen handoff target", () => { + const stickySelection = { + provider: "codex", + model: "gpt-5.4", + } satisfies ModelSelection; + + expect( + resolveThreadHandoffModelSelection({ + sourceThread: { + modelSelection: { provider: "claudeAgent", model: "claude-sonnet-4-6" }, + }, + targetProvider: "codex", + projectDefaultModelSelection: { provider: "codex", model: "gpt-5.3-codex" }, + stickyModelSelectionByProvider: { codex: stickySelection }, + }), + ).toEqual(stickySelection); + }); + + it("falls back to the project default model selection when no sticky exists for the target", () => { + const projectDefault = { + provider: "cursor", + model: "composer-1.5", + } satisfies ModelSelection; + + expect( + resolveThreadHandoffModelSelection({ + sourceThread: { + modelSelection: { provider: "codex", model: "gpt-5.4" }, + }, + targetProvider: "cursor", + projectDefaultModelSelection: projectDefault, + stickyModelSelectionByProvider: {}, + }), + ).toEqual(projectDefault); + }); + + it("falls back to the resolved provider default model when no sticky or project default exists", () => { + expect( + resolveThreadHandoffModelSelection({ + sourceThread: { + modelSelection: { provider: "claudeAgent", model: "claude-opus-4-6" }, + }, + targetProvider: "codex", + projectDefaultModelSelection: null, + stickyModelSelectionByProvider: {}, + }), + ).toEqual({ + provider: "codex", + model: "gpt-5.4", + }); + }); + + describe("canCreateThreadHandoff", () => { + it("returns false when isBusy", () => { + const thread = makeThread({ messages: [makeMessage()] }); + expect(canCreateThreadHandoff({ thread, isBusy: true })).toBe(false); + }); + + it("returns false when hasPendingApprovals", () => { + const thread = makeThread({ messages: [makeMessage()] }); + expect(canCreateThreadHandoff({ thread, hasPendingApprovals: true })).toBe(false); + }); + + it("returns false when hasPendingUserInput", () => { + const thread = makeThread({ messages: [makeMessage()] }); + expect(canCreateThreadHandoff({ thread, hasPendingUserInput: true })).toBe(false); + }); + + it("returns false when the source thread has a handoff and no native messages", () => { + const thread = makeThread({ + handoff: { + sourceThreadId: ThreadId.make("thread-source"), + sourceProvider: "codex", + importedAt: "2026-02-13T00:00:00.000Z", + bootstrapStatus: "pending", + }, + messages: [makeMessage({ source: "handoff-import" })], + }); + expect(canCreateThreadHandoff({ thread })).toBe(false); + }); + + it("returns true when the source thread has a handoff but also has native messages", () => { + const thread = makeThread({ + handoff: { + sourceThreadId: ThreadId.make("thread-source"), + sourceProvider: "codex", + importedAt: "2026-02-13T00:00:00.000Z", + bootstrapStatus: "pending", + }, + messages: [ + makeMessage({ source: "handoff-import" }), + makeMessage({ role: "user", source: "native" }), + ], + }); + expect(canCreateThreadHandoff({ thread })).toBe(true); + }); + + it("returns false when there are no importable messages", () => { + const thread = makeThread({ messages: [] }); + expect(canCreateThreadHandoff({ thread })).toBe(false); + }); + + it("returns false when the orchestration session is running", () => { + const session: ThreadSession = { + provider: "claudeAgent", + status: "running", + createdAt: "2026-02-13T00:00:00.000Z", + updatedAt: "2026-02-13T00:00:00.000Z", + orchestrationStatus: "running", + compacting: false, + }; + const thread = makeThread({ messages: [makeMessage()], session }); + expect(canCreateThreadHandoff({ thread })).toBe(false); + }); + + it("returns true for an idle thread with native importable messages and no handoff", () => { + const thread = makeThread({ messages: [makeMessage()] }); + expect(canCreateThreadHandoff({ thread })).toBe(true); + }); + }); +}); diff --git a/apps/web/src/lib/threadHandoff.ts b/apps/web/src/lib/threadHandoff.ts new file mode 100644 index 00000000000..9aabd3b21d8 --- /dev/null +++ b/apps/web/src/lib/threadHandoff.ts @@ -0,0 +1,140 @@ +import { + DEFAULT_MODEL_BY_PROVIDER, + EventId, + MessageId, + type ModelSelection, + type OrchestrationThreadActivity, + PROVIDER_DISPLAY_NAMES, + type ProviderKind, + type ThreadHandoffImportedMessage, +} from "@marcode/contracts"; +import { type ChatMessage, type Thread } from "../types"; +import { randomUUID } from "./utils"; + +const HANDOFF_PROVIDER_ORDER: ReadonlyArray = [ + "codex", + "claudeAgent", + "cursor", + "opencode", +]; + +const IMPORTABLE_THREAD_ACTIVITY_KINDS = new Set([ + "account.rate-limits.updated", + "account.rate-limited", + "context-window.updated", + "context-window.configured", +]); + +function isImportableThreadMessage( + message: ChatMessage, +): message is ChatMessage & { role: "user" | "assistant" } { + return (message.role === "user" || message.role === "assistant") && message.streaming === false; +} + +function isImportableThreadActivity( + activity: OrchestrationThreadActivity, +): activity is OrchestrationThreadActivity { + return IMPORTABLE_THREAD_ACTIVITY_KINDS.has(activity.kind); +} + +export function resolveAvailableHandoffTargetProviders( + sourceProvider: ProviderKind, +): ReadonlyArray { + return HANDOFF_PROVIDER_ORDER.filter((provider) => provider !== sourceProvider); +} + +export function resolveThreadHandoffBadgeLabel(thread: Pick): string | null { + if (!thread.handoff) { + return null; + } + return `Handoff from ${PROVIDER_DISPLAY_NAMES[thread.handoff.sourceProvider]}`; +} + +export function buildThreadHandoffImportedMessages( + thread: Pick, +): ReadonlyArray { + return thread.messages.filter(isImportableThreadMessage).map((message) => { + const importedMessage: ThreadHandoffImportedMessage = { + messageId: MessageId.make(randomUUID()), + role: message.role, + text: message.text, + createdAt: message.createdAt, + updatedAt: message.completedAt ?? message.createdAt, + }; + const attachments = + message.attachments && message.attachments.length > 0 + ? message.attachments.map((attachment) => ({ + 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 buildThreadHandoffImportedActivities( + thread: Pick, +): ReadonlyArray { + return thread.activities.filter(isImportableThreadActivity).map((activity) => { + const { sequence: _sequence, ...rest } = activity; + return { + ...rest, + id: EventId.make(randomUUID()), + }; + }); +} + +export function hasTransferableThreadMessages(thread: Pick): boolean { + return thread.messages.some(isImportableThreadMessage); +} + +export function hasNativeThreadHandoffMessages(thread: Pick): boolean { + return thread.messages.some( + (message) => isImportableThreadMessage(message) && message.source === "native", + ); +} + +export function canCreateThreadHandoff(input: { + readonly thread: Pick; + readonly isBusy?: boolean; + readonly hasPendingApprovals?: boolean; + readonly hasPendingUserInput?: boolean; +}): boolean { + if (input.isBusy || input.hasPendingApprovals || input.hasPendingUserInput) { + return false; + } + const sessionStatus = input.thread.session?.orchestrationStatus; + if (sessionStatus === "starting" || sessionStatus === "running") { + return false; + } + const importedMessages = buildThreadHandoffImportedMessages(input.thread); + if (importedMessages.length === 0) { + return false; + } + if (input.thread.handoff !== null) { + return hasNativeThreadHandoffMessages(input.thread); + } + return true; +} + +export function resolveThreadHandoffModelSelection(input: { + readonly sourceThread: Pick; + readonly targetProvider: ProviderKind; + readonly projectDefaultModelSelection: ModelSelection | null | undefined; + readonly stickyModelSelectionByProvider: Partial>; +}): ModelSelection { + const stickySelection = input.stickyModelSelectionByProvider[input.targetProvider]; + if (stickySelection) { + return stickySelection; + } + if (input.projectDefaultModelSelection?.provider === input.targetProvider) { + return input.projectDefaultModelSelection; + } + return { + provider: input.targetProvider, + model: DEFAULT_MODEL_BY_PROVIDER[input.targetProvider], + }; +} diff --git a/apps/web/src/lib/threadSort.test.ts b/apps/web/src/lib/threadSort.test.ts index 180962c83d2..3ba4c7395f7 100644 --- a/apps/web/src/lib/threadSort.test.ts +++ b/apps/web/src/lib/threadSort.test.ts @@ -26,6 +26,11 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], turnDiffSummaries: [], @@ -48,6 +53,7 @@ describe("sortThreads", () => { text: "older", createdAt: "2026-03-09T10:01:00.000Z", streaming: false, + source: "native", completedAt: "2026-03-09T10:01:00.000Z", }, ], @@ -63,6 +69,7 @@ describe("sortThreads", () => { text: "newer", createdAt: "2026-03-09T10:06:00.000Z", streaming: false, + source: "native", completedAt: "2026-03-09T10:06:00.000Z", }, ], @@ -90,6 +97,7 @@ describe("sortThreads", () => { text: "assistant only", createdAt: "2026-03-09T10:02:00.000Z", streaming: false, + source: "native", completedAt: "2026-03-09T10:02:00.000Z", }, ], diff --git a/apps/web/src/orchestrationEventEffects.test.ts b/apps/web/src/orchestrationEventEffects.test.ts index 1408c5a39aa..1dcac9ab24d 100644 --- a/apps/web/src/orchestrationEventEffects.test.ts +++ b/apps/web/src/orchestrationEventEffects.test.ts @@ -54,6 +54,11 @@ describe("deriveOrchestrationBatchEffects", () => { interactionMode: "default", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: "2026-02-27T00:00:00.000Z", updatedAt: "2026-02-27T00:00:00.000Z", }), @@ -91,6 +96,11 @@ describe("deriveOrchestrationBatchEffects", () => { interactionMode: "default", branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: "2026-02-27T00:00:02.000Z", updatedAt: "2026-02-27T00:00:02.000Z", }), diff --git a/apps/web/src/rpc/wsRpcClient.ts b/apps/web/src/rpc/wsRpcClient.ts index e98e77415c4..d06b81a9e5e 100644 --- a/apps/web/src/rpc/wsRpcClient.ts +++ b/apps/web/src/rpc/wsRpcClient.ts @@ -100,6 +100,7 @@ export interface WsRpcClient { readonly preparePullRequestThread: RpcUnaryMethod< typeof WS_METHODS.gitPreparePullRequestThread >; + readonly handoffThread: RpcUnaryMethod; readonly workingTreeDiff: RpcUnaryMethod; }; readonly server: { @@ -225,6 +226,8 @@ export function createWsRpcClient(transport: WsTransport): WsRpcClient { transport.request((client) => client[WS_METHODS.gitResolvePullRequest](input)), preparePullRequestThread: (input) => transport.request((client) => client[WS_METHODS.gitPreparePullRequestThread](input)), + handoffThread: (input) => + transport.request((client) => client[WS_METHODS.gitHandoffThread](input)), workingTreeDiff: (input) => transport.request((client) => client[WS_METHODS.gitWorkingTreeDiff](input)), }, diff --git a/apps/web/src/session-logic.test.ts b/apps/web/src/session-logic.test.ts index cb1ca3805ac..b82c60a108a 100644 --- a/apps/web/src/session-logic.test.ts +++ b/apps/web/src/session-logic.test.ts @@ -2644,6 +2644,7 @@ describe("deriveTimelineEntries", () => { text: "hello", createdAt: "2026-02-23T00:00:01.000Z", streaming: false, + source: "native", }, ], [ @@ -2687,6 +2688,7 @@ describe("deriveTimelineEntries", () => { text: "progress update", createdAt: "2026-02-23T00:00:01.000Z", streaming: false, + source: "native", }, { id: MessageId.make("assistant-final"), @@ -2694,6 +2696,7 @@ describe("deriveTimelineEntries", () => { text: "final answer", createdAt: "2026-02-23T00:00:01.000Z", streaming: false, + source: "native", }, ], [], diff --git a/apps/web/src/store.test.ts b/apps/web/src/store.test.ts index 533a6a7ad2d..4b3960ccd44 100644 --- a/apps/web/src/store.test.ts +++ b/apps/web/src/store.test.ts @@ -95,6 +95,11 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], ...overrides, @@ -143,6 +148,7 @@ function makeState(thread: Thread): AppState { updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, + handoff: thread.handoff, additionalDirectories: thread.additionalDirectories, implementingJiraTicketKeys: thread.implementingJiraTicketKeys, }, @@ -274,6 +280,7 @@ describe("thread selection memoization", () => { text: "hello", createdAt: "2026-02-13T00:01:00.000Z", streaming: false, + source: "native", }, ], activities: [ @@ -329,6 +336,7 @@ describe("thread selection memoization", () => { text: "done", createdAt: "2026-02-13T00:01:00.000Z", streaming: false, + source: "native", }, ], }); @@ -358,6 +366,7 @@ describe("thread selection memoization", () => { text: "new", createdAt: "2026-02-13T00:04:00.000Z", streaming: false, + source: "native", }, ], }); @@ -399,6 +408,11 @@ function makeReadModelThread(overrides: Partial { createdAt: "2026-02-13T00:00:01.000Z", completedAt: "2026-02-13T00:00:01.000Z", streaming: false, + source: "native", }, ], }), @@ -564,6 +579,7 @@ describe("store read model sync", () => { interactionMode: "default", branch: "feature/renamed", worktreePath: null, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, @@ -887,6 +903,11 @@ describe("incremental orchestration updates", () => { interactionMode: DEFAULT_INTERACTION_MODE, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), @@ -913,6 +934,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:00.000Z", completedAt: "2026-02-27T00:00:00.000Z", streaming: false, + source: "native", }, ], }); @@ -1000,6 +1022,7 @@ describe("incremental orchestration updates", () => { text: " world", turnId: TurnId.make("turn-1"), streaming: true, + source: "native", createdAt: "2026-02-27T00:00:01.000Z", updatedAt: "2026-02-27T00:00:01.000Z", }), @@ -1066,6 +1089,7 @@ describe("incremental orchestration updates", () => { text: "done", turnId: TurnId.make("turn-1"), streaming: false, + source: "native", createdAt: "2026-02-27T00:00:03.000Z", updatedAt: "2026-02-27T00:00:03.000Z", }, @@ -1186,6 +1210,7 @@ describe("incremental orchestration updates", () => { text: "final answer", turnId, streaming: false, + source: "native", createdAt: "2026-02-27T00:00:03.000Z", updatedAt: "2026-02-27T00:00:03.000Z", }), @@ -1212,6 +1237,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:00.000Z", completedAt: "2026-02-27T00:00:00.000Z", streaming: false, + source: "native", }, { id: MessageId.make("assistant-1"), @@ -1221,6 +1247,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:01.000Z", completedAt: "2026-02-27T00:00:01.000Z", streaming: false, + source: "native", }, { id: MessageId.make("user-2"), @@ -1230,6 +1257,7 @@ describe("incremental orchestration updates", () => { createdAt: "2026-02-27T00:00:02.000Z", completedAt: "2026-02-27T00:00:02.000Z", streaming: false, + source: "native", }, ], proposedPlans: [ @@ -1418,6 +1446,7 @@ describe("shell events are authoritative for sidebar summary flags", () => { interactionMode: thread.interactionMode, branch: thread.branch, worktreePath: thread.worktreePath, + handoff: null, additionalDirectories: thread.additionalDirectories, implementingJiraTicketKeys: thread.implementingJiraTicketKeys as unknown as ReadonlyArray< OrchestrationThreadShell["implementingJiraTicketKeys"][number] diff --git a/apps/web/src/store.ts b/apps/web/src/store.ts index f7b8217ca1b..4d69c9bc6fb 100644 --- a/apps/web/src/store.ts +++ b/apps/web/src/store.ts @@ -142,6 +142,7 @@ function mapMessage(environmentId: EnvironmentId, message: OrchestrationMessage) role: message.role, text: message.text, turnId: message.turnId, + source: message.source, createdAt: message.createdAt, streaming: message.streaming, ...(message.streaming ? {} : { completedAt: message.updatedAt }), @@ -216,6 +217,11 @@ function mapThread(thread: OrchestrationThread, environmentId: EnvironmentId): T pendingSourceProposedPlan: thread.latestTurn?.sourceProposedPlan, branch: thread.branch, worktreePath: thread.worktreePath, + associatedWorktreePath: thread.associatedWorktreePath, + associatedWorktreeBranch: thread.associatedWorktreeBranch, + associatedWorktreeRef: thread.associatedWorktreeRef, + createBranchFlowCompleted: thread.createBranchFlowCompleted, + handoff: thread.handoff, additionalDirectories: [...(thread.additionalDirectories ?? [])], implementingJiraTicketKeys: [...(thread.implementingJiraTicketKeys ?? [])], turnDiffSummaries: thread.checkpoints.map(mapTurnDiffSummary), @@ -247,6 +253,7 @@ function mapThreadShell( updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, + handoff: thread.handoff, additionalDirectories: [...(thread.additionalDirectories ?? [])], implementingJiraTicketKeys: [...(thread.implementingJiraTicketKeys ?? [])], }; @@ -272,6 +279,7 @@ function mapThreadShell( hasPendingApprovals: thread.hasPendingApprovals, hasPendingUserInput: thread.hasPendingUserInput, hasActionableProposedPlan: thread.hasActionableProposedPlan, + handoff: thread.handoff, }; return { shell, session, turnState, summary }; } @@ -292,6 +300,7 @@ function toThreadShell(thread: Thread): ThreadShell { updatedAt: thread.updatedAt, branch: thread.branch, worktreePath: thread.worktreePath, + handoff: thread.handoff, additionalDirectories: thread.additionalDirectories, implementingJiraTicketKeys: thread.implementingJiraTicketKeys, }; @@ -326,7 +335,8 @@ function sidebarThreadSummariesEqual( left.latestUserMessageAt === right.latestUserMessageAt && left.hasPendingApprovals === right.hasPendingApprovals && left.hasPendingUserInput === right.hasPendingUserInput && - left.hasActionableProposedPlan === right.hasActionableProposedPlan + left.hasActionableProposedPlan === right.hasActionableProposedPlan && + left.handoff === right.handoff ); } @@ -1440,6 +1450,11 @@ function applyEnvironmentOrchestrationEvent( interactionMode: event.payload.interactionMode, branch: event.payload.branch, worktreePath: event.payload.worktreePath, + associatedWorktreePath: event.payload.associatedWorktreePath, + associatedWorktreeBranch: event.payload.associatedWorktreeBranch, + associatedWorktreeRef: event.payload.associatedWorktreeRef, + createBranchFlowCompleted: event.payload.createBranchFlowCompleted, + handoff: event.payload.handoff, additionalDirectories: [], implementingJiraTicketKeys: [], latestTurn: null, @@ -1486,6 +1501,19 @@ function applyEnvironmentOrchestrationEvent( ...(event.payload.worktreePath !== undefined ? { worktreePath: event.payload.worktreePath } : {}), + ...(event.payload.associatedWorktreePath !== undefined + ? { associatedWorktreePath: event.payload.associatedWorktreePath } + : {}), + ...(event.payload.associatedWorktreeBranch !== undefined + ? { associatedWorktreeBranch: event.payload.associatedWorktreeBranch } + : {}), + ...(event.payload.associatedWorktreeRef !== undefined + ? { associatedWorktreeRef: event.payload.associatedWorktreeRef } + : {}), + ...(event.payload.createBranchFlowCompleted !== undefined + ? { createBranchFlowCompleted: event.payload.createBranchFlowCompleted } + : {}), + ...(event.payload.handoff !== undefined ? { handoff: event.payload.handoff } : {}), ...(event.payload.additionalDirectories !== undefined ? { additionalDirectories: [...event.payload.additionalDirectories] } : {}), @@ -1557,6 +1585,7 @@ function applyEnvironmentOrchestrationEvent( : {}), turnId: event.payload.turnId, streaming: event.payload.streaming, + source: event.payload.source ?? "native", createdAt: event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/web/src/threadDerivation.ts b/apps/web/src/threadDerivation.ts index f205799c811..78a3d0ea1cb 100644 --- a/apps/web/src/threadDerivation.ts +++ b/apps/web/src/threadDerivation.ts @@ -137,6 +137,10 @@ export function getThreadFromEnvironmentState( proposedPlans, turnDiffSummaries, additionalDirectories: shell.additionalDirectories, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, }; threadCache.set(shell, { diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index fec1e1e29b2..67d53a3c936 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -2,12 +2,14 @@ import type { EnvironmentId, ModelSelection, OrchestrationLatestTurn, + OrchestrationMessageSource, OrchestrationProposedPlanId, RepositoryIdentity, OrchestrationSessionStatus, OrchestrationThreadActivity, ProjectScript as ContractProjectScript, JiraBoardReference, + ThreadHandoff, ThreadId, ProjectId, TurnId, @@ -49,6 +51,7 @@ export interface ChatMessage { text: string; attachments?: ChatAttachment[]; turnId?: TurnId | null; + source: OrchestrationMessageSource; createdAt: string; completedAt?: string | undefined; streaming: boolean; @@ -114,6 +117,11 @@ export interface Thread { pendingSourceProposedPlan?: OrchestrationLatestTurn["sourceProposedPlan"]; branch: string | null; worktreePath: string | null; + associatedWorktreePath: string | null; + associatedWorktreeBranch: string | null; + associatedWorktreeRef: string | null; + createBranchFlowCompleted: boolean; + handoff: ThreadHandoff | null; additionalDirectories: string[]; /** * Jira ticket keys the user is actively implementing in this thread, as @@ -140,6 +148,7 @@ export interface ThreadShell { updatedAt?: string | undefined; branch: string | null; worktreePath: string | null; + handoff: ThreadHandoff | null; additionalDirectories: string[]; implementingJiraTicketKeys: string[]; } @@ -166,6 +175,7 @@ export interface SidebarThreadSummary { hasPendingApprovals: boolean; hasPendingUserInput: boolean; hasActionableProposedPlan: boolean; + handoff: ThreadHandoff | null; } export interface ThreadSession { diff --git a/apps/web/src/worktreeCleanup.test.ts b/apps/web/src/worktreeCleanup.test.ts index 9e5714189a2..594586840c7 100644 --- a/apps/web/src/worktreeCleanup.test.ts +++ b/apps/web/src/worktreeCleanup.test.ts @@ -30,6 +30,11 @@ function makeThread(overrides: Partial = {}): Thread { latestTurn: null, branch: null, worktreePath: null, + associatedWorktreePath: null, + associatedWorktreeBranch: null, + associatedWorktreeRef: null, + createBranchFlowCompleted: false, + handoff: null, additionalDirectories: [], implementingJiraTicketKeys: [], ...overrides, diff --git a/apps/web/src/wsNativeApi.ts b/apps/web/src/wsNativeApi.ts index 48a7eaa0176..521d7558cc2 100644 --- a/apps/web/src/wsNativeApi.ts +++ b/apps/web/src/wsNativeApi.ts @@ -93,6 +93,7 @@ export function createWsNativeApi(): MarCodeNativeApi { init: rpcClient.git.init, resolvePullRequest: rpcClient.git.resolvePullRequest, preparePullRequestThread: rpcClient.git.preparePullRequestThread, + handoffThread: rpcClient.git.handoffThread, workingTreeDiff: rpcClient.git.workingTreeDiff, }, contextMenu: { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e2d83f58195..2eec23e51bf 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -44,6 +44,7 @@ const GitStatusPrState = Schema.Literals(["open", "closed", "merged"]); const GitPullRequestReference = TrimmedNonEmptyStringSchema; const GitPullRequestState = Schema.Literals(["open", "closed", "merged"]); const GitPreparePullRequestThreadMode = Schema.Literals(["local", "worktree"]); +const GitHandoffThreadMode = Schema.Literals(["local", "worktree"]); export const GitHostingProviderKind = Schema.Literals(["github", "gitlab", "unknown"]); export type GitHostingProviderKind = typeof GitHostingProviderKind.Type; export const GitHostingProvider = Schema.Struct({ @@ -169,6 +170,20 @@ export const GitPreparePullRequestThreadInput = Schema.Struct({ }); export type GitPreparePullRequestThreadInput = typeof GitPreparePullRequestThreadInput.Type; +export const GitHandoffThreadInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + targetMode: GitHandoffThreadMode, + currentBranch: Schema.NullOr(TrimmedNonEmptyStringSchema), + worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema), + associatedWorktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema), + associatedWorktreeBranch: Schema.NullOr(TrimmedNonEmptyStringSchema), + associatedWorktreeRef: Schema.NullOr(TrimmedNonEmptyStringSchema), + preferredLocalBranch: Schema.NullOr(TrimmedNonEmptyStringSchema), + preferredWorktreeBaseBranch: Schema.NullOr(TrimmedNonEmptyStringSchema), + preferredNewWorktreeName: Schema.NullOr(TrimmedNonEmptyStringSchema), +}); +export type GitHandoffThreadInput = typeof GitHandoffThreadInput.Type; + export const GitRemoveWorktreeInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, path: TrimmedNonEmptyStringSchema, @@ -290,6 +305,19 @@ export const GitPreparePullRequestThreadResult = Schema.Struct({ }); export type GitPreparePullRequestThreadResult = typeof GitPreparePullRequestThreadResult.Type; +export const GitHandoffThreadResult = Schema.Struct({ + targetMode: GitHandoffThreadMode, + branch: Schema.NullOr(TrimmedNonEmptyStringSchema), + worktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema), + associatedWorktreePath: Schema.NullOr(TrimmedNonEmptyStringSchema), + associatedWorktreeBranch: Schema.NullOr(TrimmedNonEmptyStringSchema), + associatedWorktreeRef: Schema.NullOr(TrimmedNonEmptyStringSchema), + changesTransferred: Schema.Boolean, + conflictsDetected: Schema.Boolean, + message: Schema.NullOr(Schema.String), +}); +export type GitHandoffThreadResult = typeof GitHandoffThreadResult.Type; + export const GitCheckoutResult = Schema.Struct({ branch: Schema.NullOr(TrimmedNonEmptyStringSchema), }); diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 93ab5fc4302..9f6c5a3f3a1 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -2,6 +2,8 @@ import type { GitCheckoutInput, GitCheckoutResult, GitCreateBranchInput, + GitHandoffThreadInput, + GitHandoffThreadResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullRequestRefInput, @@ -272,6 +274,7 @@ export interface EnvironmentApi { preparePullRequestThread: ( input: GitPreparePullRequestThreadInput, ) => Promise; + handoffThread: (input: GitHandoffThreadInput) => Promise; pull: (input: GitPullInput) => Promise; refreshStatus: (input: GitStatusInput) => Promise; onStatus: ( diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1cbbd95abc8..5c9b7384854 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -113,6 +113,12 @@ export type ProviderApprovalDecision = typeof ProviderApprovalDecision.Type; export const ProviderUserInputAnswers = Schema.Record(Schema.String, Schema.Unknown); export type ProviderUserInputAnswers = typeof ProviderUserInputAnswers.Type; +export const ThreadHandoffBootstrapStatus = Schema.Literals(["pending", "completed"]); +export type ThreadHandoffBootstrapStatus = typeof ThreadHandoffBootstrapStatus.Type; + +export const OrchestrationMessageSource = Schema.Literals(["native", "handoff-import"]); +export type OrchestrationMessageSource = typeof OrchestrationMessageSource.Type; + export const PROVIDER_SEND_TURN_MAX_INPUT_CHARS = 120_000; export const PROVIDER_SEND_TURN_MAX_ATTACHMENTS = 8; export const PROVIDER_SEND_TURN_MAX_IMAGE_BYTES = 10 * 1024 * 1024; @@ -198,11 +204,20 @@ export const OrchestrationMessage = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + source: OrchestrationMessageSource.pipe(Schema.withDecodingDefault(Effect.succeed("native"))), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); export type OrchestrationMessage = typeof OrchestrationMessage.Type; +export const ThreadHandoff = Schema.Struct({ + sourceThreadId: ThreadId, + sourceProvider: ProviderKind, + importedAt: IsoDateTime, + bootstrapStatus: ThreadHandoffBootstrapStatus, +}); +export type ThreadHandoff = typeof ThreadHandoff.Type; + export const OrchestrationProposedPlanId = TrimmedNonEmptyString; export type OrchestrationProposedPlanId = typeof OrchestrationProposedPlanId.Type; @@ -321,6 +336,17 @@ export const OrchestrationThread = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + associatedWorktreePath: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + associatedWorktreeBranch: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + associatedWorktreeRef: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + createBranchFlowCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + handoff: Schema.NullOr(ThreadHandoff).pipe(Schema.withDecodingDefault(Effect.succeed(null))), additionalDirectories: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -383,6 +409,7 @@ export const OrchestrationThreadShell = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + handoff: Schema.NullOr(ThreadHandoff).pipe(Schema.withDecodingDefault(Effect.succeed(null))), additionalDirectories: Schema.Array(TrimmedNonEmptyString).pipe( Schema.withDecodingDefault(Effect.succeed([])), ), @@ -499,6 +526,38 @@ const ThreadCreateCommand = Schema.Struct({ createdAt: IsoDateTime, }); +export const ThreadHandoffImportedMessage = Schema.Struct({ + messageId: MessageId, + role: Schema.Literals(["user", "assistant"]), + text: Schema.String, + attachments: Schema.optional(Schema.Array(ChatAttachment)), + createdAt: IsoDateTime, + updatedAt: IsoDateTime, +}); +export type ThreadHandoffImportedMessage = typeof ThreadHandoffImportedMessage.Type; + +const ThreadHandoffCreateCommand = Schema.Struct({ + type: Schema.Literal("thread.handoff.create"), + commandId: CommandId, + threadId: ThreadId, + sourceThreadId: ThreadId, + projectId: ProjectId, + title: TrimmedNonEmptyString, + modelSelection: ModelSelection, + runtimeMode: RuntimeMode, + interactionMode: ProviderInteractionMode.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_PROVIDER_INTERACTION_MODE)), + ), + branch: Schema.NullOr(TrimmedNonEmptyString), + worktreePath: Schema.NullOr(TrimmedNonEmptyString), + associatedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + associatedWorktreeBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + associatedWorktreeRef: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + createBranchFlowCompleted: Schema.optional(Schema.Boolean), + importedMessages: Schema.Array(ThreadHandoffImportedMessage), + createdAt: IsoDateTime, +}); + const ThreadDeleteCommand = Schema.Struct({ type: Schema.Literal("thread.delete"), commandId: CommandId, @@ -527,6 +586,11 @@ const ThreadMetaUpdateCommand = Schema.Struct({ worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), additionalDirectories: Schema.optional(Schema.Array(TrimmedNonEmptyString)), implementingJiraTicketKeys: Schema.optional(Schema.Array(JiraIssueKey)), + associatedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + associatedWorktreeBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + associatedWorktreeRef: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + createBranchFlowCompleted: Schema.optional(Schema.Boolean), + handoff: Schema.optional(Schema.NullOr(ThreadHandoff)), }); const ThreadRuntimeModeSetCommand = Schema.Struct({ @@ -657,6 +721,7 @@ const DispatchableClientOrchestrationCommand = Schema.Union([ ProjectMetaUpdateCommand, ProjectDeleteCommand, ThreadCreateCommand, + ThreadHandoffCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -678,6 +743,7 @@ export const ClientOrchestrationCommand = Schema.Union([ ProjectMetaUpdateCommand, ProjectDeleteCommand, ThreadCreateCommand, + ThreadHandoffCreateCommand, ThreadDeleteCommand, ThreadArchiveCommand, ThreadUnarchiveCommand, @@ -846,6 +912,17 @@ export const ThreadCreatedPayload = Schema.Struct({ ), branch: Schema.NullOr(TrimmedNonEmptyString), worktreePath: Schema.NullOr(TrimmedNonEmptyString), + associatedWorktreePath: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + associatedWorktreeBranch: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + associatedWorktreeRef: Schema.NullOr(TrimmedNonEmptyString).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), + createBranchFlowCompleted: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), + handoff: Schema.NullOr(ThreadHandoff).pipe(Schema.withDecodingDefault(Effect.succeed(null))), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -874,6 +951,11 @@ export const ThreadMetaUpdatedPayload = Schema.Struct({ worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), additionalDirectories: Schema.optional(Schema.Array(TrimmedNonEmptyString)), implementingJiraTicketKeys: Schema.optional(Schema.Array(JiraIssueKey)), + associatedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + associatedWorktreeBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + associatedWorktreeRef: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + createBranchFlowCompleted: Schema.optional(Schema.Boolean), + handoff: Schema.optional(Schema.NullOr(ThreadHandoff)), updatedAt: IsoDateTime, }); @@ -899,6 +981,7 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + source: OrchestrationMessageSource.pipe(Schema.withDecodingDefault(Effect.succeed("native"))), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 7512cb6e0c1..75af6db2219 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -24,6 +24,8 @@ import { GitListBranchesInput, GitListBranchesResult, GitManagerServiceError, + GitHandoffThreadInput, + GitHandoffThreadResult, GitPreparePullRequestThreadInput, GitPreparePullRequestThreadResult, GitPullInput, @@ -131,6 +133,7 @@ export const WS_METHODS = { gitWorkingTreeDiff: "git.workingTreeDiff", gitResolvePullRequest: "git.resolvePullRequest", gitPreparePullRequestThread: "git.preparePullRequestThread", + gitHandoffThread: "git.handoffThread", // Terminal methods terminalOpen: "terminal.open", @@ -270,6 +273,12 @@ export const WsGitPreparePullRequestThreadRpc = Rpc.make(WS_METHODS.gitPreparePu error: GitManagerServiceError, }); +export const WsGitHandoffThreadRpc = Rpc.make(WS_METHODS.gitHandoffThread, { + payload: GitHandoffThreadInput, + success: GitHandoffThreadResult, + error: GitManagerServiceError, +}); + export const WsGitListBranchesRpc = Rpc.make(WS_METHODS.gitListBranches, { payload: GitListBranchesInput, success: GitListBranchesResult, @@ -502,6 +511,7 @@ export const WsRpcGroup = RpcGroup.make( WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc, WsGitPreparePullRequestThreadRpc, + WsGitHandoffThreadRpc, WsGitListBranchesRpc, WsGitCreateWorktreeRpc, WsGitRemoveWorktreeRpc, diff --git a/packages/shared/package.json b/packages/shared/package.json index 48484a52707..1c9ef5fcf8c 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -75,6 +75,10 @@ "./cliArgs": { "types": "./src/cliArgs.ts", "import": "./src/cliArgs.ts" + }, + "./worktreeHandoff": { + "types": "./src/worktreeHandoff.ts", + "import": "./src/worktreeHandoff.ts" } }, "scripts": { diff --git a/packages/shared/src/worktreeHandoff.test.ts b/packages/shared/src/worktreeHandoff.test.ts new file mode 100644 index 00000000000..5411d5a0166 --- /dev/null +++ b/packages/shared/src/worktreeHandoff.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from "vitest"; + +import { hasAssociatedWorktree, resolveWorktreeHandoffIntent } from "./worktreeHandoff.ts"; + +describe("hasAssociatedWorktree", () => { + it("returns false when nothing is set", () => { + expect(hasAssociatedWorktree({})).toBe(false); + }); + + it("returns true if any associated field is non-null", () => { + expect(hasAssociatedWorktree({ associatedWorktreePath: "/tmp/wt" })).toBe(true); + expect(hasAssociatedWorktree({ associatedWorktreeBranch: "feature/x" })).toBe(true); + expect(hasAssociatedWorktree({ associatedWorktreeRef: "abc123" })).toBe(true); + }); +}); + +describe("resolveWorktreeHandoffIntent", () => { + it("returns null when neither preferred name nor associated worktree present", () => { + expect( + resolveWorktreeHandoffIntent({ + preferredWorktreeBaseBranch: "main", + currentBranch: "feature/x", + }), + ).toBeNull(); + }); + + it("returns create-new when a preferred worktree name is provided", () => { + expect( + resolveWorktreeHandoffIntent({ + preferredNewWorktreeName: "feat/y", + preferredWorktreeBaseBranch: "main", + currentBranch: "feature/x", + }), + ).toEqual({ + kind: "create-new", + worktreeName: "feat/y", + baseBranch: "main", + }); + }); + + it("trims whitespace from preferredNewWorktreeName", () => { + const result = resolveWorktreeHandoffIntent({ + preferredNewWorktreeName: " feat/y ", + }); + expect(result).toEqual({ + kind: "create-new", + worktreeName: "feat/y", + baseBranch: null, + }); + }); + + it("returns reuse-associated when only the associated worktree is set", () => { + const result = resolveWorktreeHandoffIntent({ + associatedWorktreePath: "/tmp/wt", + associatedWorktreeBranch: "feature/x", + associatedWorktreeRef: "feature/x", + currentBranch: "feature/x", + }); + expect(result).toEqual({ + kind: "reuse-associated", + associatedWorktreePath: "/tmp/wt", + associatedWorktreeBranch: "feature/x", + associatedWorktreeRef: "feature/x", + baseBranch: "feature/x", + }); + }); + + it("prefers preferredNewWorktreeName over associated worktree (create-new wins)", () => { + const result = resolveWorktreeHandoffIntent({ + preferredNewWorktreeName: "feat/y", + associatedWorktreePath: "/tmp/old", + }); + expect(result?.kind).toBe("create-new"); + }); +}); diff --git a/packages/shared/src/worktreeHandoff.ts b/packages/shared/src/worktreeHandoff.ts new file mode 100644 index 00000000000..7278e5f6de8 --- /dev/null +++ b/packages/shared/src/worktreeHandoff.ts @@ -0,0 +1,55 @@ +export type WorktreeHandoffIntent = + | { + readonly kind: "create-new"; + readonly worktreeName: string; + readonly baseBranch: string | null; + } + | { + readonly kind: "reuse-associated"; + readonly associatedWorktreePath: string | null; + readonly associatedWorktreeBranch: string | null; + readonly associatedWorktreeRef: string | null; + readonly baseBranch: string | null; + }; + +export function hasAssociatedWorktree(input: { + associatedWorktreePath?: string | null; + associatedWorktreeBranch?: string | null; + associatedWorktreeRef?: string | null; +}): boolean { + return Boolean( + input.associatedWorktreePath ?? input.associatedWorktreeBranch ?? input.associatedWorktreeRef, + ); +} + +export function resolveWorktreeHandoffIntent(input: { + preferredNewWorktreeName?: string | null; + associatedWorktreePath?: string | null; + associatedWorktreeBranch?: string | null; + associatedWorktreeRef?: string | null; + preferredWorktreeBaseBranch?: string | null; + currentBranch?: string | null; +}): WorktreeHandoffIntent | null { + const normalizedWorktreeName = input.preferredNewWorktreeName?.trim() ?? ""; + const baseBranch = input.preferredWorktreeBaseBranch ?? input.currentBranch ?? null; + + if (normalizedWorktreeName.length > 0) { + return { + kind: "create-new", + worktreeName: normalizedWorktreeName, + baseBranch, + }; + } + + if (!hasAssociatedWorktree(input)) { + return null; + } + + return { + kind: "reuse-associated", + associatedWorktreePath: input.associatedWorktreePath ?? null, + associatedWorktreeBranch: input.associatedWorktreeBranch ?? null, + associatedWorktreeRef: input.associatedWorktreeRef ?? null, + baseBranch, + }; +}