Skip to content

feat(chat-fork): provider-generated handoff pipeline with B/A/D fallback ladder - #499

Merged
chuks-qua merged 57 commits into
mainfrom
feat/chat-branch-enhancement
May 22, 2026
Merged

chuks-qua merged 57 commits into
mainfrom
feat/chat-branch-enhancement

Conversation

@chuks-qua

@chuks-qua chuks-qua commented May 22, 2026 •

Copy link
Copy Markdown
Contributor

What

Replaces the legacy transcript-replay branching with a provider-generated chat fork handoff system. When the user forks a chat at a specific message, the parent thread's provider summarizes the conversation into a structured markdown handoff doc. The doc is inlined into the child thread's first turn so the new agent picks up with real context, not raw transcripts. Three execution paths handle different provider capabilities and failure modes.

Why

The previous flow dumped the entire parent transcript into the child's first turn. New providers re-derived everything from scratch and were unable to reference specific prior decisions. The vendored /handoff skill is exactly the right primitive: trust the LLM to summarize what matters, given the conversation it already has loaded. By running it side-channel on the parent's provider, we get a high-fidelity handoff without polluting the parent's UI or forcing the user to wait through provider-by-provider migrations.

The feature is also a rename pass: chat "branch" became chat "fork" in all user-visible copy to disambiguate from git branches. The schema field forkedFromMessageId was already aligned.

Key Changes

Pipeline architecture (B / A / D ladder)

  • Path B: clean side-channel resume of the parent provider (Claude). Sessionless fallback that bakes conversation history into a fresh provider call when sessions are unresumable after server restart.
  • Path A: hidden-turn injection on the parent thread for providers whose resume mutates session state (Cursor today). 10s settle-wait so an in-flight parent turn cannot be clobbered.
  • Path D: deterministic local builder. Universal fallback when provider is unavailable, unsupported, or has no session yet.
  • Error classifier routes provider failures (quota / auth / context-overflow / transient / fatal) to the appropriate fall-through step.

Storage

  • ULID-named handoff directories at <MCODE_DATA_DIR>/threads/<id>/handoffs/<ulid>/handoff.md + handoff.json.
  • 25 MB attachment cap with reference-only fallback for oversized assets.
  • Overflow-to-OS-temp when provider output exceeds the child's input budget.
  • Hard-delete wiring so thread cleanup also wipes handoff artifacts.

Provider integration

  • Added sessionForkOnResume (clean / mutating / unsupported) and maxInputCharactersPerTurn capabilities to IAgentProvider.
  • Claude side-channel mirrors the SDK's resume primitive; sessionless retry on "no conversation found".
  • Cursor hidden-turn dance with disregard cleanup. To be replaced when @cursor/sdk migration lands (see follow-up doc).
  • Codex / Copilot declared unsupported (route to path D until verified).
  • Side-channel queries run in the parent's worktree (not the server's cwd).
  • AbortController forwarding via event listener; the original Object.assign hack threw on Node 24.

UI

  • Branch -> Fork rename across all user-visible copy. ComposerBranchBar reads "Forking from". GitFork icon. Tooltip says "Fork from here".
  • Child thread shows a skeleton during handoff generation. Composer queues sends if the user types while waiting.
  • HandoffFallbackBanner with trigger-aware copy and a "View doc" dialog showing metadata strip + frontmatter-stripped markdown.
  • Per-thread fork-mode state preserved across navigation.
  • Slash command palette sources from the in-flight composer provider, not the parent's, with send-time validation.

Schema

  • messages.is_internal flag for hidden turns. Filtered from every user-facing query.

Robustness phase

  • 60s side-channel timeout. Per-thread mutex on path A. Post-write budget truncation at section boundaries. Abandoned-child cleanup. Legacy catch path persists artifacts to disk so View doc works under any fallback condition.

Documentation

  • CONTEXT.md glossary at repo root for the fork + handoff vocabulary.
  • docs/guides/chat-fork-handoff.md user-facing guide.
  • docs/guides/cursor-sdk-migration.md handoff doc for the Cursor SDK migration that would unlock path B for Cursor and let us delete path A entirely.
  • docs/plans/2026-05-21-chat-branch-handoff-pipeline.md design and implementation plan.
  • docs/plans/prototypes/2026-05-21-chat-branch-handoff.html UX storyboard used to validate the design.

Tests

  • 108 server-side tests covering capability declarations, ULID generation, paths resolver, prompt builder, error classifier, storage, pipeline orchestrator with all three paths, sessionless fallback, attachment cap, budget truncation, abandoned-child guard.
  • Updated web fixtures and a Playwright spec for the fallback banner.
  • All tests pass; typecheck clean across all 5 packages.

Deferred items (documented in the plan, not in this PR)

  • @cursor/sdk migration to unlock path B for Cursor and delete the hidden-turn infrastructure. Handoff doc: docs/guides/cursor-sdk-migration.md.
  • Live regenerate handoff RPC (stub currently returns not-implemented).
  • Same-thread cross-provider switch.
  • Content-addressable blob store for attachments (v2 optimization).
  • Post-window handoff regeneration after the child has had turns.
  • Phase 17.1 stream-settle guard for path B (path A has it; path B doesn't yet).

View with Codesmith Autofix with Codesmith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

Summary by CodeRabbit

  • New Features

    • Chat fork handoff: multi-path generation with automatic fallback, on-disk handoff artifacts, view latest handoff documents, and a v1 regenerate stub.
    • UI: fork mode, queued-send while handoff generates, fallback banner and handoff document viewer, updated “Forked” labels.
  • Bug Fixes

    • Internal/system messages are hidden from the chat timeline.
  • Documentation

    • Added chat fork handoff and cursor SDK migration guides; glossary updated.
  • Chores

    • Provider capability metadata, new user setting to toggle local fallback notifications, terminology switched from “branch” → “fork.”

Review Change Stack

chuks-qua added 30 commits May 21, 2026 23:21
- Claude: clean fork, 180k char budget (45k tokens with headroom)
- Cursor: mutating fork, 4k chars (known per-turn cap)
- Codex + Copilot: unsupported fork, 16k chars (conservative until verified)

Also fold in three code-review followups on the interface file:
- Fix em-dash in maxInputCharactersPerTurn JSDoc
- Clarify conservative placeholder values for Codex/Copilot
- Hoist SessionForkBehavior to a named type alias for clarity

Update test mocks to include the new required fields.
…ser-msg fork

Moves fork mode from ChatView local useState into threadStore so it
survives thread navigation. On user-message fork, pre-fills the lexical
editor with the quoted text styled italic so it is visually distinct
from fresh input. Adds queued-send: messages typed while the handoff
context is still generating are held locally and fired automatically
when the handoff lands.
…send time

Guards handleSend against sending a /cmd that doesn't exist in the active
provider's command set, surfacing an error toast and blocking the send.
The palette already sourced from effectiveProviderId (composer state for
new threads, thread.provider for existing ones).
Adds a boolean setting (default true) that controls whether a banner is
shown when a fork's handoff was produced by the local deterministic path.
Surfaced as a toggle in the Notifications settings section.
…tion

Renders an amber banner above the message list when a child fork thread's
handoff was produced by the local deterministic path (path D). Respects
the chat.handoff.notifyOnLocalFallback setting. Regenerate button is a
disabled v1 stub.
Registers the method in WS_METHODS with typed params/result and adds the
case handler in ws-router returning { status: "not-implemented" }. Live
regeneration via AI provider is deferred to a follow-on plan.
chuks-qua added 12 commits May 22, 2026 21:46
…get; use resolve

The local IProviderRegistry interface shadowed the real contracts type and
declared get() instead of resolve(). All call sites now go through a private
tryResolveProvider() helper that wraps resolve() to return null on miss rather
than throwing. Test mocks updated from get to resolve; the deps type is now
Pick<IProviderRegistry, "resolve"> so future interface drift is caught at
compile time.
…adata strip

Adds an X dismiss button to HandoffFallbackBanner that clears the thread's
handoff status to "ready" so the banner won't reappear on navigation.
The View doc dialog is widened to max-w-4xl and capped at 90vh. The viewer
now renders a metadata strip (ladderStep, mode, generatedBy, provider, chars,
when) from artifact.meta and strips YAML frontmatter from the markdown body
so the doc text renders cleanly via MarkdownContent.
… overflow for oversized docs

- Restructures SECTIONS_FULL/SECTIONS_MINIMAL to quote verbatim parent message and concrete key facts instead of abstract summaries
- Adds userFollowUpMessage field to HandoffPromptInput and HandoffRequest so the side-channel prompt bridges parent context to the child's first question
- Rewrites anchor-role framing: user-fork = retry framing, assistant-fork = follow-up-about-reply framing
- Makes applyBudgetGuard async; on overflow writes the full doc to OS temp dir and inlines a truncated head + file pointer
- Updates all test fixtures (BASE_REQ in pipeline + e2e tests, buildHandoffPrompt baseInput) and adds new assertions for follow-up message inclusion and stronger framing language
@coderabbitai

coderabbitai Bot commented May 22, 2026 •

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fb42d2da-7d20-421c-b349-421b24ae75fa

📥 Commits

Reviewing files that changed from the base of the PR and between 4eb46b6 and 84a21fc.

📒 Files selected for processing (38)
  • apps/desktop/scripts/dev-electron.mjs
  • apps/server/drizzle/meta/_journal.json
  • apps/server/src/__tests__/cleanup-integration.test.ts
  • apps/server/src/__tests__/cleanup-worker.test.ts
  • apps/server/src/__tests__/message-repo.test.ts
  • apps/server/src/__tests__/workspace-hard-delete.test.ts
  • apps/server/src/providers/claude/claude-provider.ts
  • apps/server/src/providers/codex/codex-provider.ts
  • apps/server/src/providers/cursor/cursor-provider.ts
  • apps/server/src/services/__tests__/agent-service-plan-marker.test.ts
  • apps/server/src/services/agent-service.ts
  • apps/server/src/services/cleanup-worker.ts
  • apps/server/src/services/handoff/__tests__/fork-flow.e2e.test.ts
  • apps/server/src/services/handoff/__tests__/handoff-pipeline.test.ts
  • apps/server/src/services/handoff/handoff-pipeline.ts
  • apps/server/src/services/handoff/handoff-prompt.ts
  • apps/server/src/services/handoff/handoff-storage.ts
  • apps/server/src/services/handoff/path-d-deterministic.ts
  • apps/server/src/store/schema.ts
  • apps/server/src/transport/ws-router.ts
  • apps/web/src/__tests__/mocks/transport.ts
  • apps/web/src/components/chat/ChatView.tsx
  • apps/web/src/components/chat/Composer.tsx
  • apps/web/src/components/chat/HandoffFallbackBanner.tsx
  • apps/web/src/components/chat/MessageBubble.tsx
  • apps/web/src/components/chat/MessageList.tsx
  • apps/web/src/stores/threadStore.ts
  • apps/web/src/transport/types.ts
  • apps/web/src/transport/ws-events.ts
  • apps/web/src/transport/ws-transport.ts
  • docs/guides/chat-fork-handoff.md
  • docs/guides/cursor-sdk-migration.md
  • docs/plans/2026-05-21-chat-branch-handoff-pipeline.md
  • docs/plans/prototypes/2026-05-21-chat-branch-handoff.html
  • packages/contracts/src/index.ts
  • packages/contracts/src/models/settings.ts
  • packages/contracts/src/providers/interfaces.ts
  • packages/contracts/src/ws/methods.ts

📝 Walkthrough

Walkthrough

Adds a complete chat fork handoff system: provider capability contracts and hooks, DB migration and message-repo internal flag, filesystem handoff storage, HandoffPipelineService implementing B→A→D ladder (side-channel, hidden turn, deterministic fallback), provider-side implementations (Claude, Cursor, others), AgentService/server wiring, WebSocket transport and client APIs, UI state/components for fork mode and fallback banners, tests, and docs.

Changes

Chat Fork Handoff Implementation

Layer / File(s) Summary
Contracts, provider interfaces, WS methods
packages/contracts/src/providers/interfaces.ts, packages/contracts/src/ws/methods.ts, packages/contracts/src/ws/channels.ts, packages/contracts/src/index.ts, packages/contracts/src/models/settings.ts
Adds SessionForkBehavior union, extends IAgentProvider with sessionForkOnResume and maxInputCharactersPerTurn, and optional runSideChannelQuery/runHiddenTurn hooks. Adds thread.handoff push channel and handoff.readLatest/handoff.regenerate RPCs. Adds chat.handoff.notifyOnLocalFallback setting and updates contract exports.
Database migration & message repo
apps/server/drizzle/0008_brave_big_bertha.sql, apps/server/drizzle/meta/0008_snapshot.json, apps/server/src/store/schema.ts, apps/server/src/repositories/message-repo.ts
Adds is_internal integer column (NOT NULL DEFAULT 0) to messages; updates MessageRepo to persist/read is_internal, exclude internal messages from UI listing queries, and add listIncludingInternal for server-side access.
Handoff storage & path helpers
packages/shared/src/paths/handoffs.ts, packages/shared/src/index.ts, apps/server/src/services/handoff/handoff-storage.ts
Implements ULID-based handoff directories, HandoffStorage service to write handoff.md + handoff.json, read latest, copy attachments (25MB cap), and delete thread handoff subtree; exposes test hooks and path resolvers.
Handoff types, prompt, error classifier
apps/server/src/services/handoff/handoff-types.ts, apps/server/src/services/handoff/handoff-prompt.ts, apps/server/src/services/handoff/error-classifier.ts
Defines ladder/mode/error unions and artifact/request metadata types; builds /handoff prompt utilities (mode selection, budget, truncation) and error classifier (classifyProviderError, shouldSkipToDeterministic).
Handoff pipeline orchestration
apps/server/src/services/handoff/handoff-pipeline.ts, apps/server/src/services/handoff/path-d-deterministic.ts
Adds HandoffPipelineService orchestrating B (side-channel) → A (hidden-turn serialized) → D (deterministic) ladder with budget guard, overflow temp write, artifact building, error classification, and forTesting factory; runPathDDeterministic produces deterministic fallback artifacts.
Provider capabilities & implementations
apps/server/src/providers/claude/claude-provider.ts, apps/server/src/providers/cursor/cursor-provider.ts, apps/server/src/providers/codex/codex-provider.ts, apps/server/src/providers/copilot/copilot-provider.ts
All providers declare sessionForkOnResume and maxInputCharactersPerTurn. Claude implements runSideChannelQuery with resume + sessionless retry logic; Cursor implements runHiddenTurn persisting bracketed internal messages; Codex/Copilot declare unsupported resume and per-turn caps.
AgentService integration, DI, server wiring
apps/server/src/services/agent-service.ts, apps/server/src/container.ts, apps/server/src/index.ts, apps/server/src/transport/ws-router.ts, apps/server/src/services/cleanup-worker.ts
AgentService now broadcasts thread.handoff status, calls the handoff pipeline, copies attachments into child thread, persists handoff artifact and seq-1 internal system message, and falls back to deterministic path on failures; handoff services registered in DI; server startup resolves handoffStorage into createWsServer; CleanupWorker deletes handoff files during cleanup; ws-router exposes handoff RPCs.
Web transport, thread store, and client APIs
apps/web/src/transport/ws-transport.ts, apps/web/src/transport/types.ts, apps/web/src/transport/ws-events.ts, apps/web/src/__tests__/mocks/transport.ts
Client transport adds readLatestHandoff; McodeTransport types updated; WS push thread.handoff listener updates client thread store; mock transport extended for tests.
Thread store and fork mode state
apps/web/src/stores/threadStore.ts
Adds HandoffMeta interface, handoffMeta map, derived handoffStatus cache with setters, and per-thread forkMode state with setForkMode for persisting inline fork-mode across navigation.
UI components and interactions
apps/web/src/components/chat/ChatView.tsx, apps/web/src/components/chat/Composer.tsx, apps/web/src/components/chat/ComposerBranchBar.tsx, apps/web/src/components/chat/HandoffFallbackBanner.tsx, apps/web/src/components/chat/HandoffCard.tsx, apps/web/src/components/chat/MessageBubble.tsx, apps/web/src/components/chat/MessageList.tsx
ChatView uses fork-mode from store and renders HandoffFallbackBanner; Composer queues user input during "generating" and auto-sends on "ready"/"fallback"; several UI labels/icons changed from "branch" to "fork"; HandoffFallbackBanner shows deterministic-fallback banner and a dialog viewer that fetches and renders latest handoff artifact. MessageList shows skeleton while generating.
Settings and notifications toggle
apps/web/src/components/settings/sections/NotificationsSection.tsx, packages/contracts/src/models/settings.ts
Adds chat.handoff.notifyOnLocalFallback setting (default true) and renders a toggle in NotificationsSection to enable/disable fallback banners.
Tests
apps/server/src/services/handoff/__tests__/*, apps/server/src/providers/claude/__tests__/*, apps/server/src/__tests__/*, apps/web/src/components/chat/__tests__/*, packages/shared/src/paths/__tests__/handoffs.test.ts
Adds/updates tests for error classification, pipeline orchestration, fork-flow e2e, prompt generation, handoff storage, deterministic path, provider sessionless side-channel behavior, message-repo schema, cleanup integration, and many unit tests updated to wire new dependencies and fork terminology.
Miscellaneous
apps/desktop/scripts/dev-electron.mjs, packages/contracts/tsconfig.json, apps/server/drizzle/meta/_journal.json
Moves electronProcess declaration earlier in dev script, adds DOM lib to contracts tsconfig, and appends Drizzle migration journal entry for the new migration snapshot.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related issues

Possibly related PRs

Poem

🐰 I dug a little tunnel from parent to child,
A handoff of context, tidy and mild.
B then A, or D when things stall—
The fork hops along, sharing it all. 🌿

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch

…ncement

# Conflicts:
#	apps/server/drizzle/meta/_journal.json
#	apps/web/src/components/chat/Composer.tsx

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 18

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
apps/web/src/stores/threadStore.ts (1)

1403-1436: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Prune newly added per-thread maps during thread cleanup.

clearThreadState and clearThreadStateMany don’t remove handoffMeta, handoffStatus, or forkMode. Deleted threads can retain stale fork/handoff UI state in memory.

Suggested fix
       return {
         runningThreadIds: nextRunning,
+        handoffMeta: omitKey(state.handoffMeta, threadId),
+        handoffStatus: omitKey(state.handoffStatus, threadId),
+        forkMode: omitKey(state.forkMode, threadId),
         errorByThread: omitKey(state.errorByThread, threadId),
         streamingByThread: omitKey(state.streamingByThread, threadId),
         ...
       };
       return {
         runningThreadIds: nextRunning,
+        handoffMeta: pruneAll(state.handoffMeta),
+        handoffStatus: pruneAll(state.handoffStatus),
+        forkMode: pruneAll(state.forkMode),
         errorByThread: pruneAll(state.errorByThread),
         streamingByThread: pruneAll(state.streamingByThread),
         ...
       };

Also applies to: 1495-1528

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/stores/threadStore.ts` around lines 1403 - 1436, The cleanup
functions clearThreadState and clearThreadStateMany are missing removals for the
new per-thread maps handoffMeta, handoffStatus, and forkMode, leaving stale UI
state; update the returned state object in both clearThreadState and
clearThreadStateMany to include handoffMeta: omitKey(state.handoffMeta,
threadId), handoffStatus: omitKey(state.handoffStatus, threadId), and forkMode:
omitKey(state.forkMode, threadId) (and when clearing many, apply the same
omission logic for each threadId) so those per-thread entries are pruned during
thread delete.
🧹 Nitpick comments (1)
apps/web/src/transport/ws-transport.ts (1)

751-781: ⚡ Quick win

De-duplicate the handoff response type to prevent schema drift.

This large inline type repeats the transport contract shape and is likely to diverge over time. Derive it from McodeTransport["readLatestHandoff"] instead.

♻️ Suggested refactor
+type ReadLatestHandoffResult = Awaited<
+  ReturnType<McodeTransport["readLatestHandoff"]>
+>;
+
 ...
-    readLatestHandoff: (threadId: string) =>
-      rpc<{
-        markdown: string;
-        meta: {
-          schemaVersion: 1;
-          parentThreadId: string;
-          forkedFromMessageId: string;
-          forkAnchorRole: "user" | "assistant";
-          childThreadId: string;
-          generatedBy: "provider" | "deterministic";
-          provider: string | null;
-          ladderStep: "B" | "A" | "D";
-          mode: "full" | "minimal";
-          generatedAt: string;
-          characterCount: number;
-          parentSdkSessionId: string | null;
-          providerErrorOnGenerate: "quota" | "auth" | "context-overflow" | "transient" | "fatal" | "clean" | null;
-          regenerationHistory: Array<{
-            at: string;
-            ladderStep: "B" | "A" | "D";
-            reason: "quota" | "auth" | "context-overflow" | "transient" | "fatal" | "clean" | "user-requested";
-          }>;
-          attachments: Array<{
-            id: string;
-            originalName: string;
-            sha256: string;
-            mime: string;
-            parentMessageId: string;
-          }>;
-        };
-      } | null>("handoff.readLatest", { threadId }),
+    readLatestHandoff: (threadId: string) =>
+      rpc<ReadLatestHandoffResult>("handoff.readLatest", { threadId }),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/web/src/transport/ws-transport.ts` around lines 751 - 781, The inline,
verbose response type in the readLatestHandoff call is duplicating the transport
contract and risks schema drift; change the generic type passed to rpc to derive
from the canonical transport type (use McodeTransport["readLatestHandoff"] or
the appropriate member) instead of the long inline shape so the call signature
stays in sync with the shared contract (ensure you preserve the union with null
if the transport type allows null), update the rpc invocation around
readLatestHandoff to use that referenced type and remove the inline object type.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/server/src/providers/claude/claude-provider.ts`:
- Around line 446-449: The code is using literal defaults for Claude models in
multiple places (e.g., the parentSessionId lookup that sets model to
"claude-sonnet-4-5" and the doSendMessage() default "claude-sonnet-4-6"),
causing drift; extract a single shared constant like DEFAULT_CLAUDE_MODEL and
replace the hardcoded strings in the parentSessionId resolution (where model is
set via this.sessions.get(parentSessionId)?.model) and the doSendMessage()
default so both paths reference DEFAULT_CLAUDE_MODEL (also update the other
occurrence around lines 581-583) to ensure a single canonical default.

In `@apps/server/src/providers/cursor/cursor-provider.ts`:
- Around line 929-945: runRawPrompt currently assigns a normal activeTurnState
(via createCursorAcpTurnState) so deliverSessionUpdate still maps and emits
hidden-turn events; change the turn state used for this hidden handoff to a
suppressed variant and ensure deliverSessionUpdate skips mapping for it: update
runRawPrompt to set entry.activeTurnState to a turn state with a suppression
flag (e.g., turnState.suppressed = true) or to use a separate hiddenAccumulator
field (e.g., turnState.hiddenAccumulator) and then modify deliverSessionUpdate
to check that flag/field and avoid emitting textDelta/tool/completion events for
suppressed turns; keep resolveCursorAssistantMessageContent using the hidden
accumulator so the function returns the content but no ACP events are propagated
to the parent thread.
- Around line 857-919: runHiddenTurn currently bypasses the per-session
serialization (entry.turnChain) and doesn't check abortSignal between steps;
enqueue the entire hidden-turn bracket onto the session's turnChain so it runs
under the same mutex as sendMessage(): use entry.turnChain =
entry.turnChain.then(async () => { ... }) (or the project's equivalent
promise-chaining helper) and move the logic for openLogicalSession,
messageRepo.create calls, runRawPrompt calls, and final return inside that
chained callback; additionally, before each major step (before persisting each
hidden message and before each runRawPrompt) check abortSignal (throw or return
early if signaled) so a timeout/abort can't continue mutating the session, and
keep using
entry.activeTurnState/runRawPrompt/openLogicalSession/messageRepo.create as
currently named inside the chained block.

In `@apps/server/src/services/agent-service.ts`:
- Around line 1049-1055: Re-check the child thread existence inside the
catch/fallback path before executing the legacy fallback: call
threadRepo.findById(thread.id) (same as the happy-path guard) and if it returns
null or has deleted_at set, log an info/error (e.g., via logger.info) noting the
child thread vanished and abort the fallback flow instead of writing seq 1,
persisting fallback artifacts, or queuing sends; ensure the code in the legacy
fallback branch (the catch that writes seq 1, persists fallback artifacts and
emits thread.handoff) returns/throws early when the re-check fails so no FK
errors, stale files, or misleading fallback events are produced.

In `@apps/server/src/services/cleanup-worker.ts`:
- Line 23: reconcileOnStartup() and finalizeWorkspaceIfDone() currently only
call attachmentService.removeForThread(...), so add deletion of handoff
artifacts by invoking HandoffStorage.removeForThread(...) (or the appropriate
HandoffStorage API used elsewhere) in those hard-delete paths so forked direct
threads' handoffs are cleaned up too; also change the existing executeJob() call
sites that invoke finalizeWorkspaceIfDone(...) to await the promise (i.e., make
them await finalizeWorkspaceIfDone(...)) so cleanup completes before proceeding.
Ensure you reference the same HandoffStorage implementation imported at top and
modify reconcileOnStartup(), finalizeWorkspaceIfDone(), and executeJob() callers
accordingly.

In `@apps/server/src/services/handoff/__tests__/fork-flow.e2e.test.ts`:
- Around line 249-264: The test currently calls broadcastMock(...) directly and
then asserts that call, which makes the assertion meaningless; remove the direct
invocation and instead set up broadcast as a mocked spy (vi.mocked(broadcast)),
trigger the real code path that should emit the broadcast (e.g., invoke the
fork/fallback integration flow or AgentService entry point used in this test
harness—look for the function under test in fork-flow.e2e.test.ts such as the
fork handler or AgentService.run that processes BASE_REQ and artifact), then
assert broadcastMock was calledWith the expected payload (thread.handoff, {
threadId: BASE_REQ.childThreadId, status: "fallback", ladderStep: "D",
providerErrorOnGenerate: "quota" }). Ensure the test arranges
artifact.meta.ladderStep="D" and providerErrorOnGenerate="quota" before
triggering the real flow so the broadcast is emitted by the system under test
rather than invoked directly in the test.

In `@apps/server/src/services/handoff/__tests__/handoff-pipeline.test.ts`:
- Around line 108-118: The test mocks deps.providerRegistry.resolve to return a
fresh object each call, so the assertion on provider.runSideChannelQuery is
checking a different instance than the one used in
HandoffPipelineService.forTesting(). Fix by stubbing resolve to return the same
mock object reference (create a single mock provider with runSideChannelQuery =
vi.fn(...) and any other props, then have deps.providerRegistry.resolve =
vi.fn(() => mockProvider)), then call svc.orchestrate(BASE_REQ) and assert
mockProvider.runSideChannelQuery.not.toHaveBeenCalled(); this ensures the tested
provider instance matches the asserted mock.
- Around line 141-156: The test temporarily replaces globalThis.AbortController
(saving OriginalAbortController) before calling
HandoffPipelineService.forTesting(...).orchestrate(BASE_REQ); ensure the
restoration of globalThis.AbortController happens in a finally block so the
original AbortController is always restored even if svc.orchestrate or the
assertion throws; move the assignment globalThis.AbortController =
OriginalAbortController into a finally and keep the override and test logic
inside the try so cross-test leakage cannot occur.

In `@apps/server/src/services/handoff/handoff-pipeline.ts`:
- Around line 144-146: The parent thread is loaded entirely via
messageRepo.listIncludingInternal into messages and later reused for
replay/fallback, which can leak messages after the fork anchor; locate where
messages and forkedFromMessageId are used (the messages array, forkMsg lookup
using messages.find) and compute the fork index (e.g., const forkIndex =
messages.findIndex(m => m.id === req.forkedFromMessageId); if forkIndex < 0
throw) then slice the array to only include messages up to and including the
fork anchor (messages = messages.slice(0, forkIndex + 1)) and replace all
subsequent uses (replay/fallback history, references previously using the full
messages array) with this truncated array so no later parent messages are
included in the child handoff context.

In `@apps/server/src/services/handoff/handoff-prompt.ts`:
- Around line 46-59: Add a one-line TSDoc/JSDoc summary for the exported
interface HandoffPromptInput at its declaration to satisfy the coding guideline;
open the HandoffPromptInput interface and add a top-level comment (e.g. /**
One-line summary describing the purpose of HandoffPromptInput */) above the
export so the interface and its intent are documented for consumers.

In `@apps/server/src/services/handoff/handoff-storage.ts`:
- Around line 145-147: The current unanchored regex that builds body from
markdownBody can remove valid content; update the stripping logic so you only
remove frontmatter when it matches a known handoff signature: either (a) tighten
the regex to require a known key (e.g., require a line like "handoff:" or
"provider:" inside the frontmatter) when computing body (replace the current
/^---\n[\s\S]*?\n---\n/ with a pattern that only matches if those keys exist),
or (b) safer—parse the leading frontmatter with a frontmatter parser (or
js-yaml): extract the YAML block from markdownBody, parse it and only strip it
(assign to body) if the parsed object contains one of the expected keys (e.g.,
"handoff", "provider", "handoff_version"); otherwise leave markdownBody intact.
Apply this change where markdownBody is transformed into body in
handoff-storage.ts.

In `@apps/server/src/services/handoff/path-d-deterministic.ts`:
- Around line 14-22: Add a one-line TSDoc/JSDoc summary above the exported
interface PathDInput describing its purpose (e.g., represents the input data
needed to construct/record a deterministic "D" fork path including parentThread,
messagesUpToFork, forkedFromMessageId, forkAnchorRole, childThreadId, and
optional reason). Place the comment immediately above the PathDInput declaration
and ensure it follows project TSDoc style (/** ... */) so the exported interface
is documented for consumers and tooling.

In `@apps/web/src/components/chat/Composer.tsx`:
- Around line 1696-1709: The handoff queue currently saves only the trimmed
string (trimmed) which drops attachments, reply/context/provider overrides and
prevents attachment-only sends; change the queue to store a full message
payload/descriptor (including text, attachments, replyTo, providerOverride,
metadata) instead of just trimmed, update setQueuedSend usage and the auto-fire
path to call the existing send function with that stored message object (so
sends fire even when trimmed === "" for attachment-only) and preserve
branchFromMessageId/threadId/isNewThread semantics; apply the same fix to the
other occurrence (the block around the 1959-1979 region) so both enqueue and
dequeue paths use the complete message object from
useThreadStore.getState().handoffStatus and hasSeenHandoffTransition checks.

In `@apps/web/src/components/chat/HandoffFallbackBanner.tsx`:
- Around line 97-117: When useEffect re-runs for a new threadId the previous
ready/error state can persist; inside the effect before calling
getTransport().readLatestHandoff, immediately call setState({ phase: "loading"
}) (or the component's loading phase shape) so the UI resets while the new fetch
is in-flight; update the effect surrounding getTransport().readLatestHandoff in
the same block that uses cancelled, and keep the existing .then/.catch logic
that sets phase to "ready" or "error" based on the result.

In `@docs/guides/chat-fork-handoff.md`:
- Around line 39-42: Update the fenced code blocks in
docs/guides/chat-fork-handoff.md to include a language identifier (use "text")
to satisfy markdown linting: replace the opening backticks for the blocks
containing the paths
"<MCODE_DATA_DIR>/threads/<threadId>/handoffs/<ulid>/handoff.md" and
"<MCODE_DATA_DIR>/threads/<threadId>/handoffs/<ulid>/handoff.json" and the block
containing "<MCODE_DATA_DIR>/threads/<threadId>/attachments/<id>.<ext>" with
```text so each fenced code block begins with ```text and closes with ```.

In `@docs/guides/cursor-sdk-migration.md`:
- Line 29: Change the unhyphenated compound modifier "10 second" to "10-second"
at each occurrence in this document (notably the phrase in the sentence
containing "settle-wait poll and a disregard-turn cleanup" and the other
instances referenced around lines 68 and 211) so the compound numeric modifier
is consistently hyphenated as "10-second".

In `@packages/contracts/src/ws/channels.ts`:
- Around line 105-113: The "thread.handoff" Zod object is being constructed
eagerly; wrap its schema in lazySchema to defer construction (replace the direct
z.object({...}) with lazySchema(() => z.object({...}))). Ensure lazySchema is
imported into the module and retain the existing fields (threadId, status,
ladderStep, providerErrorOnGenerate) unchanged inside the returned object. This
makes the schema lazy-loaded while preserving the original shape and options.

In `@packages/contracts/src/ws/methods.ts`:
- Around line 682-684: The providerErrorOnGenerate enum in the WebSocket
contract (symbol: providerErrorOnGenerate in methods.ts) includes the value
"clean" which is not present on the push-channel contract (symbol:
WS_CHANNELS["thread.handoff"].providerErrorOnGenerate); update the enum so both
contracts match—either remove "clean" from providerErrorOnGenerate here or add
"clean" to WS_CHANNELS["thread.handoff"].providerErrorOnGenerate so they are
identical, keeping the .nullable() behavior intact.

---

Outside diff comments:
In `@apps/web/src/stores/threadStore.ts`:
- Around line 1403-1436: The cleanup functions clearThreadState and
clearThreadStateMany are missing removals for the new per-thread maps
handoffMeta, handoffStatus, and forkMode, leaving stale UI state; update the
returned state object in both clearThreadState and clearThreadStateMany to
include handoffMeta: omitKey(state.handoffMeta, threadId), handoffStatus:
omitKey(state.handoffStatus, threadId), and forkMode: omitKey(state.forkMode,
threadId) (and when clearing many, apply the same omission logic for each
threadId) so those per-thread entries are pruned during thread delete.

---

Nitpick comments:
In `@apps/web/src/transport/ws-transport.ts`:
- Around line 751-781: The inline, verbose response type in the
readLatestHandoff call is duplicating the transport contract and risks schema
drift; change the generic type passed to rpc to derive from the canonical
transport type (use McodeTransport["readLatestHandoff"] or the appropriate
member) instead of the long inline shape so the call signature stays in sync
with the shared contract (ensure you preserve the union with null if the
transport type allows null), update the rpc invocation around readLatestHandoff
to use that referenced type and remove the inline object type.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 02a8b8e6-6307-4360-9293-812a2bcd3691

📥 Commits

Reviewing files that changed from the base of the PR and between 955b93d and 4eb46b6.

📒 Files selected for processing (68)
  • AGENTS.md
  • CONTEXT.md
  • apps/desktop/scripts/dev-electron.mjs
  • apps/server/drizzle/0008_brave_big_bertha.sql
  • apps/server/drizzle/meta/0008_snapshot.json
  • apps/server/drizzle/meta/_journal.json
  • apps/server/src/__tests__/agent-service-turn-started.test.ts
  • apps/server/src/__tests__/thread-forking.test.ts
  • apps/server/src/__tests__/utility-completion-service.test.ts
  • apps/server/src/container.ts
  • apps/server/src/index.ts
  • apps/server/src/providers/claude/__tests__/side-channel-sessionless.test.ts
  • apps/server/src/providers/claude/claude-provider.ts
  • apps/server/src/providers/codex/codex-provider.ts
  • apps/server/src/providers/copilot/copilot-provider.ts
  • apps/server/src/providers/cursor/cursor-provider.ts
  • apps/server/src/repositories/message-repo.ts
  • apps/server/src/services/__tests__/agent-service-gate.test.ts
  • apps/server/src/services/__tests__/agent-service-goal-command.test.ts
  • apps/server/src/services/__tests__/agent-service-narrative-persist.test.ts
  • apps/server/src/services/__tests__/agent-service-plan-marker.test.ts
  • apps/server/src/services/__tests__/agent-service-snapshot-flag.test.ts
  • apps/server/src/services/__tests__/agent-service-stack-fallback.test.ts
  • apps/server/src/services/__tests__/agent-service-turn-cleanup.test.ts
  • apps/server/src/services/__tests__/provider-availability-service.test.ts
  • apps/server/src/services/agent-service.ts
  • apps/server/src/services/cleanup-worker.ts
  • apps/server/src/services/handoff/__tests__/error-classifier.test.ts
  • apps/server/src/services/handoff/__tests__/fork-flow.e2e.test.ts
  • apps/server/src/services/handoff/__tests__/handoff-pipeline.test.ts
  • apps/server/src/services/handoff/__tests__/handoff-prompt.test.ts
  • apps/server/src/services/handoff/__tests__/handoff-storage.test.ts
  • apps/server/src/services/handoff/__tests__/path-d-deterministic.test.ts
  • apps/server/src/services/handoff/error-classifier.ts
  • apps/server/src/services/handoff/handoff-pipeline.ts
  • apps/server/src/services/handoff/handoff-prompt.ts
  • apps/server/src/services/handoff/handoff-storage.ts
  • apps/server/src/services/handoff/handoff-types.ts
  • apps/server/src/services/handoff/path-d-deterministic.ts
  • apps/server/src/store/schema.ts
  • apps/server/src/transport/ws-router.ts
  • apps/web/src/__tests__/mocks/transport.ts
  • apps/web/src/__tests__/workspace-behavior.test.ts
  • apps/web/src/components/chat/ChatView.tsx
  • apps/web/src/components/chat/Composer.tsx
  • apps/web/src/components/chat/ComposerBranchBar.tsx
  • apps/web/src/components/chat/HandoffCard.tsx
  • apps/web/src/components/chat/HandoffFallbackBanner.tsx
  • apps/web/src/components/chat/MessageBubble.tsx
  • apps/web/src/components/chat/MessageList.tsx
  • apps/web/src/components/chat/__tests__/composer-branch-bar.test.tsx
  • apps/web/src/components/settings/sections/NotificationsSection.tsx
  • apps/web/src/stores/threadStore.ts
  • apps/web/src/transport/types.ts
  • apps/web/src/transport/ws-events.ts
  • apps/web/src/transport/ws-transport.ts
  • docs/guides/chat-fork-handoff.md
  • docs/guides/cursor-sdk-migration.md
  • packages/contracts/src/index.ts
  • packages/contracts/src/models/message.ts
  • packages/contracts/src/models/settings.ts
  • packages/contracts/src/providers/interfaces.ts
  • packages/contracts/src/ws/channels.ts
  • packages/contracts/src/ws/methods.ts
  • packages/contracts/tsconfig.json
  • packages/shared/src/index.ts
  • packages/shared/src/paths/__tests__/handoffs.test.ts
  • packages/shared/src/paths/handoffs.ts

Comment thread apps/server/src/providers/claude/claude-provider.ts Outdated
Comment thread apps/server/src/providers/cursor/cursor-provider.ts
Comment thread apps/server/src/providers/cursor/cursor-provider.ts
Comment thread apps/server/src/services/agent-service.ts
Comment thread apps/server/src/services/cleanup-worker.ts
Comment thread apps/web/src/components/chat/HandoffFallbackBanner.tsx
Comment thread docs/guides/chat-fork-handoff.md Outdated
Comment thread docs/guides/cursor-sdk-migration.md Outdated
Comment thread packages/contracts/src/ws/channels.ts
Comment thread packages/contracts/src/ws/methods.ts
@chuks-qua

Copy link
Copy Markdown
Contributor Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@chuks-qua
chuks-qua merged commit fb4e712 into main May 22, 2026
6 checks passed
@chuks-qua
chuks-qua deleted the feat/chat-branch-enhancement branch May 22, 2026 23:40
chuks-qua added a commit that referenced this pull request May 23, 2026
docs(cursor-handoff): defer SDK migration; drop shipped chat-fork plans

Investigation on 2026-05-23 found the @cursor/sdk v1.0.13 migration is
blocked: the SDK requires an API key (CURSOR_API_KEY env or apiKey arg)
with no path to read the cursor-agent CLI's stored login. That conflicts
with the project rule that mcode never requires users to set API keys.
The ACP session/load upstream bug is also still unresolved (latest
cursor-cli 2026.05.16-0338208 still affected), so we cannot flip Cursor
to sessionForkOnResume "clean" without the SDK either.

Prepend a Status: DEFERRED block to the handoff doc with the two
un-defer triggers (Cursor fixes ACP, or SDK gains CLI-auth) and a
2026-08-23 recheck date with the exact commands to re-evaluate.

Drop docs/plans/2026-05-21-chat-branch-handoff-pipeline.md and its
prototype: the chat-fork handoff feature they described already shipped
in #499 (commit fb4e712), and docs/plans/ is already gitignored. Fix
the residual reference in CONTEXT.md.
@mze-bot mze-bot Bot mentioned this pull request Jun 9, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant