Skip to content

feat(web): fork from a selected assistant response#4248

Closed
nassimna wants to merge 1 commit into
pingdotgg:mainfrom
nassimna:agent/fork-from-message-v2
Closed

feat(web): fork from a selected assistant response#4248
nassimna wants to merge 1 commit into
pingdotgg:mainfrom
nassimna:agent/fork-from-message-v2

Conversation

@nassimna

@nassimna nassimna commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

What Changed

  • Added a fork action beneath each completed assistant response.
  • Forked the visible T3 transcript through the selected turn and opened the result as a side chat.
  • Used the provider's exact native fork boundary: Codex thread/fork, OpenCode session.fork with the selected message ID, and Claude resume/fork at the selected assistant UUID.
  • Delayed provider-session creation until the first follow-up is sent in the fork.
  • Hid the action for Cursor and Grok because their current ACP session fork cannot identify an exact historical response.

Why

Forking from the chat header could only mean "fork the latest state." When a user wants to explore an earlier answer, the visible transcript and the provider session must both stop at that exact response.

Putting the action beneath each completed response makes the boundary explicit and matches the existing message-action interaction model.

Scope

This PR contains only the user-facing "fork from this message" feature. It includes the orchestration, persistence, and provider plumbing required to preserve the selected boundary end to end. It does not expose agent thread-control tools or add agent coordination instructions.

Verification

  • vp test run on the ten affected orchestration, migration, provider-adapter, message-timeline, and side-panel test files: 244 tests passed.
  • Contracts, server, and web package typechecks passed.
  • Integrated browser verification confirmed that the action appears after completion, opens history through the selected response, and accepts an independent follow-up through the native Codex fork.

UI Evidence

Before: latest-state action in the header

Header fork action before this PR

After: action beneath the selected response

Message-level fork action

Forked side chat and native provider follow-up

Selected-response side chat

Successful native provider follow-up

Interaction

The 15-second recording keeps the real cursor visible, holds the complete message action row and Fork to side chat tooltip before the click, then shows the forked panel accepting an independent native-provider follow-up.

15-second real-cursor selected-response fork and native follow-up recording

Checklist

  • The PR contains one user-facing feature: forking from a selected assistant response.
  • Agent thread controls and instructions are excluded.
  • Before/after screenshots and a short recording are included.
  • Focused tests and affected package typechecks pass.

Note

Add fork-from-assistant-response UI and backend support for Claude, Codex, and OpenCode providers

  • Adds a fork button to completed assistant messages in the chat timeline; clicking it dispatches a thread.fork command that creates a new thread with inherited messages up to the selected turn.
  • Introduces the thread.fork command and thread.forked event in the contracts layer, with decider logic that validates the source turn and copies non-streaming messages, and projector/pipeline logic that persists the forked thread and its inherited messages.
  • Implements native fork in Claude (via forkSession/resumeSessionAt), Codex (via thread/fork RPC), and OpenCode (via session.fork) adapters; adds sessionFork: 'native' capability advertisement to each.
  • Opens the forked thread in the right panel as an embedded ChatView tab (with a fork icon) so users can view both branches side by side; the embedded view suppresses the top header and terminal drawers.
  • Adds DB migration 033 to add forked_from_thread_id and forked_from_turn_id columns (plus an index) to the projection_threads table.
  • Risk: right panel storage version bumped from 7 to 8; persisted panel state from earlier versions will not be forward-compatible.
📊 Macroscope summarized f44ff03. 26 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 4b7e4f00-942f-42d8-9285-00c806de46db

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added the vouch:unvouched PR author is not yet trusted in the VOUCHED list. label Jul 22, 2026
@github-actions github-actions Bot added the size:XL 500-999 changed lines (additions + deletions). label Jul 22, 2026
yield* directory.getBinding(requestedForkFrom.threadId),
);
if (!sourceBinding) {
if (requestedForkFrom.sourceTurnId === undefined) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High Layers/ProviderService.ts:573

When forkFrom is requested but the source thread has no persisted provider binding and forkFrom.sourceTurnId is absent, startSession silently drops the fork request and starts a fresh, unrelated session — even when forkFrom.sourceTurnIndex is provided. An index-based fork without a sourceTurnId is a valid request shape, so the fork is silently ignored and the resulting session has no relationship to the intended source thread. The fallback to undefined at line 573 should only apply when neither sourceTurnId nor sourceTurnIndex is present; if either boundary field is specified, the missing-binding condition should produce a validation error instead.

Also found in 1 other location(s)

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:516

forkSource.threadId is always set to the immediate parent fork (forkedFrom.threadId). A fork can itself be forked from one of its inherited completed responses before any follow-up is sent, and the PR explicitly delays provider-session creation until that first follow-up. In that case the immediate parent has no persisted provider binding, so ProviderService.startSession rejects the nested fork with “no persisted provider binding” even though the original ancestor has valid native resume state. The nested side chat therefore cannot send its first message; this needs to resolve the ancestor provider source/boundary rather than requiring a binding on the unstarted intermediate fork.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ProviderService.ts around line 573:

When `forkFrom` is requested but the source thread has no persisted provider binding and `forkFrom.sourceTurnId` is absent, `startSession` silently drops the fork request and starts a fresh, unrelated session — even when `forkFrom.sourceTurnIndex` is provided. An index-based fork without a `sourceTurnId` is a valid request shape, so the fork is silently ignored and the resulting session has no relationship to the intended source thread. The fallback to `undefined` at line 573 should only apply when neither `sourceTurnId` nor `sourceTurnIndex` is present; if either boundary field is specified, the missing-binding condition should produce a validation error instead.

Also found in 1 other location(s):
- apps/server/src/orchestration/Layers/ProviderCommandReactor.ts:516 -- `forkSource.threadId` is always set to the immediate parent fork (`forkedFrom.threadId`). A fork can itself be forked from one of its inherited completed responses before any follow-up is sent, and the PR explicitly delays provider-session creation until that first follow-up. In that case the immediate parent has no persisted provider binding, so `ProviderService.startSession` rejects the nested fork with “no persisted provider binding” even though the original ancestor has valid native resume state. The nested side chat therefore cannot send its first message; this needs to resolve the ancestor provider source/boundary rather than requiring a binding on the unstarted intermediate fork.

Comment on lines +607 to +621
function sessionUserMessageStartsHumanTurn(message: SessionMessage): boolean {
if (message.type !== "user") {
return false;
}
const content = (message.message as { readonly content?: unknown } | null)?.content;
if (!Array.isArray(content)) {
return true;
}
return content.some(
(block) =>
typeof block !== "object" ||
block === null ||
(block as { readonly type?: unknown }).type !== "tool_result",
);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High Layers/ClaudeAdapter.ts:607

sessionUserMessageStartsHumanTurn treats nested subagent user messages that contain text as new human turns, but those messages have no corresponding top-level visible turn. This inflates terminalAssistantUuids with phantom entries, shifting the array relative to sourceTurnIndex so resolveClaudeAssistantForkPoint returns the wrong assistant UUID (or hits an out-of-range index and returns undefined). The function should return false for any user message with a non-null parent_tool_use_id, since those belong to nested agent invocations rather than top-level human turns.

Suggested change
function sessionUserMessageStartsHumanTurn(message: SessionMessage): boolean {
if (message.type !== "user") {
return false;
}
const content = (message.message as { readonly content?: unknown } | null)?.content;
if (!Array.isArray(content)) {
return true;
}
return content.some(
(block) =>
typeof block !== "object" ||
block === null ||
(block as { readonly type?: unknown }).type !== "tool_result",
);
}
function sessionUserMessageStartsHumanTurn(message: SessionMessage): boolean {
if (message.type !== "user") {
return false;
}
const rawMessage = message.message as { readonly content?: unknown; readonly parent_tool_use_id?: unknown } | null;
if (rawMessage?.parent_tool_use_id != null) {
return false;
}
const content = rawMessage?.content;
if (!Array.isArray(content)) {
return true;
}
return content.some(
(block) =>
typeof block !== "object" ||
block === null ||
(block as { readonly type?: unknown }).type !== "tool_result",
);
}
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/provider/Layers/ClaudeAdapter.ts around lines 607-621:

`sessionUserMessageStartsHumanTurn` treats nested subagent `user` messages that contain text as new human turns, but those messages have no corresponding top-level visible turn. This inflates `terminalAssistantUuids` with phantom entries, shifting the array relative to `sourceTurnIndex` so `resolveClaudeAssistantForkPoint` returns the wrong assistant UUID (or hits an out-of-range index and returns `undefined`). The function should return `false` for any `user` message with a non-null `parent_tool_use_id`, since those belong to nested agent invocations rather than top-level human turns.

"applyThreadMessagesProjection",
)(function* (event, attachmentSideEffects) {
switch (event.type) {
case "thread.forked":

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟠 High Layers/ProjectionPipeline.ts:843

The thread.forked message projection copies inherited attachments unchanged via [...message.attachments], so the forked thread's attachment IDs still contain the source thread's segment. When the source thread is deleted or reverted past those messages, runAttachmentSideEffects deletes files by that source segment, destroying attachment files that the fork still references. Consider materializing independent attachments for the fork (as thread.message-sent does via materializeAttachmentsForProjection) or making cleanup account for cross-thread references.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/server/src/orchestration/Layers/ProjectionPipeline.ts around line 843:

The `thread.forked` message projection copies inherited `attachments` unchanged via `[...message.attachments]`, so the forked thread's attachment IDs still contain the source thread's segment. When the source thread is deleted or reverted past those messages, `runAttachmentSideEffects` deletes files by that source segment, destroying attachment files that the fork still references. Consider materializing independent attachments for the fork (as `thread.message-sent` does via `materializeAttachmentsForProjection`) or making cleanup account for cross-thread references.

@juliusmarminge

Copy link
Copy Markdown
Member

#2829

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants