Skip to content

Commit 239a0c8

Browse files
frenchie4111claude
andauthored
Fork a Claude conversation into a new worktree (#237)
## Summary Lets a new worktree be seeded with an **existing Claude conversation** instead of a hand-written summary prompt, so the new agent resumes actually holding what was said rather than being briefed about it. Two entry points: - **UI** — a "fork this conversation" option on the new-worktree screen, launched from a Chat tab. - **MCP** — `create_worktree` gains `forkConversation: true`. Always self-scoped: for Chat tabs the bridge's terminal id *is* the session id, so both fork inputs are derived from the request header. Rejected up front (before any git work) for terminal tabs, callers with no transcript, and any combination with `prNumber`. ### The relocation preamble The load-bearing piece. A forked transcript is full of edits to a worktree the new agent isn't in, and the new branch is cut from the base ref — so it carries neither the uncommitted work nor, usually, the commits. `fork-relocation.ts` prepends a note stating where the agent is now, where the history happened, and **what actually survived** — computed from git, not templated. When git can't answer, it says so explicitly rather than guessing. ## Verification Forked a real conversation into a real worktree over both paths and read what the resumed agent did. The forking agent's `initialPrompt` asserted a plan file *would* be present ("the commit is on main, so it should be there"). The forked agent didn't take it on faith — it checked, found the file missing, and diagnosed it exactly: *"this branch was cut from `7937032`, one commit behind main, so commit `41da774` didn't come along."* It then verified with `merge-base --is-ancestor`, fast-forwarded, re-read the plan, and got to work. The preamble's "check before you build on anything you edited earlier" did its job — and caught a wrong assumption the agent's own past self had written. The first end-to-end run **failed on the tool description, not the plumbing**: given an explicit continuity request the agent still hand-wrote a briefing, reasoning that a forked transcript "would mostly be noise." The old decision test asked whether it *could* write a sufficient briefing — a diligent agent always answers yes, so that test only ever resolved to "don't fork." Replaced with "ask what the briefing would have to *contain*" (`d32ce04`); the agent then forked on the identical prompt. ## Test plan - [x] `npm run typecheck` - [x] `npx electron-vite build` - [x] `npx vitest run` — new coverage in `fork-transcript.test.ts`, `fork-relocation.test.ts`, `control-server.test.ts`, `worktrees-fsm.test.ts` (pre-existing unrelated failures in `path-fix` / `git-ops-state` only) - [x] End-to-end fork over the UI path - [x] End-to-end fork over the MCP path — matching `fork wrote` / `spawn` session ids - [x] Rejection paths: terminal tab, no transcript, `prNumber` combination ## Notes for review - API is `forkConversation: true` (self-scoped boolean) rather than a caller-supplied session id — an agent can't reliably observe its own session id, and cross-worktree forking would need a session→worktree index nothing currently asks for. Easy to widen later. - The MCP `POST /worktrees` branch path bypasses `WorktreesFSM` and calls `addWorktree` directly, so it has no pending-list entry to guard the async seeding window. `seedingWorktreePaths` claims the path instead — without it the pane sweep re-opens the race fixed in `4dcf8b1`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d30b285 commit 239a0c8

29 files changed

Lines changed: 1209 additions & 180 deletions

resources/mcp-bridge.js

Lines changed: 57 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -123,11 +123,18 @@ function callControl(method, path, body) {
123123
})
124124
}
125125

126+
// Appended to create_worktree's description, and removed again by
127+
// stripForkAffordance when the feature is off. Kept as its own constant so the
128+
// two stay in sync — a literal that drifts would silently stop being stripped.
129+
const FORK_DESCRIPTION_SENTENCE =
130+
' The new tab normally starts as a blank conversation seeded with initialPrompt; set forkConversation to instead hand it a copy of THIS conversation to continue from.'
131+
126132
const TOOLS = [
127133
{
128134
name: 'create_worktree',
129135
description:
130-
"Create a new git worktree in a Harness-managed repo. Either create a brand-new branch (set branchName) OR check out an existing GitHub PR for review (set prNumber). Harness will open a new agent chat tab inside the new worktree automatically. Defaults to the caller's current repo when repoRoot is omitted.",
136+
"Create a new git worktree in a Harness-managed repo. Either create a brand-new branch (set branchName) OR check out an existing GitHub PR for review (set prNumber). Harness will open a new agent chat tab inside the new worktree automatically. Defaults to the caller's current repo when repoRoot is omitted." +
137+
FORK_DESCRIPTION_SENTENCE,
131138
inputSchema: {
132139
type: 'object',
133140
properties: {
@@ -172,6 +179,11 @@ const TOOLS = [
172179
type: 'string',
173180
description:
174181
'Optional display alias applied to the new worktree once creation succeeds. Same semantics as set_worktree_alias — trimmed and clamped to 80 chars, empty string is ignored. Useful when the user gave the task a memorable label ("call this one auth-refactor") so the sidebar/window title show that instead of the branch name.'
182+
},
183+
forkConversation: {
184+
type: 'boolean',
185+
description:
186+
'Copy YOUR current conversation into the new worktree, so its agent resumes holding everything said here instead of starting blank. Only works when you are a Harness Chat tab with existing history; otherwise the call is rejected and you should retry without it. Cannot be combined with prNumber.\n\nFORK when the new worktree continues THIS thread of work. Strongest signal: the user asks for continuity — "pick up where we left off", "they should already know what we discussed", "carry on from here". Take that at face value; it is a request to fork, and answering it with a hand-written briefing instead is the wrong call. Also fork when the work leans on things that only exist in this conversation: what you already read and ruled out, why the user rejected an earlier approach, a design the two of you converged on over several turns.\n\nDO NOT FORK for a task that merely sits next to this one ("also fix the flaky test", "do the same on the other service"), for a clean retry after an approach failed, or for reviewing someone else\'s code. There the history is noise the new agent must read past.\n\nDeciding: do not ask yourself whether you COULD write a sufficient briefing — you almost always can, so that question always answers "no fork" and is useless. Ask instead what the briefing would have to contain. If it needs to relay specific findings, discarded options, or user decisions from this conversation, fork: the transcript already holds those, faithfully, and your summary of them will be lossier than you expect. If it would just be a task description someone could have written before this conversation started, do not fork.\n\nCost of forking, so you can weigh it: the transcript is full of your earlier file edits, but the new branch is cut from the base ref, so it does NOT contain your uncommitted work and may not contain your commits. Harness prepends a note telling the new agent where it now is and which of those changes actually survived, and it will spend a little effort re-verifying before it builds.\n\nWhen you fork, still pass initialPrompt — it lands right after that note and is what actually directs the new agent. Write it as a continuation ("now build the page we just planned, here") and do not re-explain what the conversation already contains.'
175187
}
176188
}
177189
}
@@ -523,16 +535,29 @@ const FULL_CONTROL_BROWSER_TOOLS = new Set([
523535
'show_cursor'
524536
])
525537

526-
let cachedBrowserPerms = null
527-
async function getBrowserPerms() {
528-
if (cachedBrowserPerms) return cachedBrowserPerms
538+
// One /scope fetch backs every capability gate below. Defaults on failure are
539+
// permissive for browser tools (pre-existing behaviour) but the fork gate
540+
// defaults off — the setting is opt-in, and a server that doesn't report it
541+
// would reject the call anyway, so advertising it would only waste a turn.
542+
let cachedScope = null
543+
async function getScopeInfo() {
544+
if (cachedScope) return cachedScope
529545
try {
530-
const r = await callControl('GET', '/scope')
531-
cachedBrowserPerms = (r && r.browser) || { enabled: true, mode: 'full' }
546+
cachedScope = (await callControl('GET', '/scope')) || {}
532547
} catch {
533-
cachedBrowserPerms = { enabled: true, mode: 'full' }
548+
cachedScope = {}
534549
}
535-
return cachedBrowserPerms
550+
return cachedScope
551+
}
552+
553+
async function getBrowserPerms() {
554+
const s = await getScopeInfo()
555+
return s.browser || { enabled: true, mode: 'full' }
556+
}
557+
558+
async function getConversationForkEnabled() {
559+
const s = await getScopeInfo()
560+
return s.conversationFork ? s.conversationFork.enabled === true : false
536561
}
537562

538563
function filterToolsByPerms(tools, perms) {
@@ -546,6 +571,20 @@ function filterToolsByPerms(tools, perms) {
546571
})
547572
}
548573

574+
// Strip every trace of forking from create_worktree when it's disabled, rather
575+
// than advertising a parameter whose only outcome is a rejection.
576+
function stripForkAffordance(tools) {
577+
return tools.map((t) => {
578+
if (t.name !== 'create_worktree') return t
579+
const { forkConversation, ...rest } = t.inputSchema.properties
580+
return {
581+
...t,
582+
description: t.description.replace(FORK_DESCRIPTION_SENTENCE, ''),
583+
inputSchema: { ...t.inputSchema, properties: rest }
584+
}
585+
})
586+
}
587+
549588
async function handleToolCall(name, args) {
550589
if (name === 'create_worktree') {
551590
const prNumber = args && args.prNumber
@@ -574,14 +613,19 @@ async function handleToolCall(name, args) {
574613
initialPrompt: args.initialPrompt,
575614
agentKind: args.agentKind,
576615
model: args.model,
577-
alias: args.alias
616+
alias: args.alias,
617+
forkConversation: args.forkConversation === true
578618
})
579619
const agentLabel = args.agentKind === 'codex' ? 'Codex' : 'Claude'
580620
const modelSuffix = args.model ? ` (model: ${args.model})` : ''
581621
const aliasSuffix = args.alias && args.alias.trim() ? ` (alias: "${args.alias.trim()}")` : ''
622+
const forkSuffix =
623+
args.forkConversation === true
624+
? ' It resumes a copy of this conversation, and has been told where it is and which of your earlier changes came along.'
625+
: ''
582626
return prNumber
583627
? `Created worktree ${r.path} on branch ${r.branch} for PR #${prNumber}${aliasSuffix}. Harness will open a new ${agentLabel} chat tab in it${modelSuffix}.`
584-
: `Created worktree ${r.path} on branch ${r.branch}${aliasSuffix}. Harness will open a new ${agentLabel} chat tab in it${modelSuffix}.`
628+
: `Created worktree ${r.path} on branch ${r.branch}${aliasSuffix}. Harness will open a new ${agentLabel} chat tab in it${modelSuffix}.${forkSuffix}`
585629
}
586630
if (name === 'list_worktrees') {
587631
const q =
@@ -803,7 +847,9 @@ async function handle(msg) {
803847
if (method === 'tools/list') {
804848
logErr('tools/list received')
805849
const perms = await getBrowserPerms()
806-
return { jsonrpc: '2.0', id, result: { tools: filterToolsByPerms(TOOLS, perms) } }
850+
let tools = filterToolsByPerms(TOOLS, perms)
851+
if (!(await getConversationForkEnabled())) tools = stripForkAffordance(tools)
852+
return { jsonrpc: '2.0', id, result: { tools } }
807853
}
808854
if (method === 'tools/call') {
809855
logErr('tools/call received name=' + (params && params.name))

src/main/agents/claude.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,37 @@ describe('buildSpawnArgs', () => {
6969
expect(result).toContain('--append-system-prompt')
7070
expect(result).toContain("'\\''")
7171
})
72+
73+
const sessionPath = (cwd: string, id: string): string =>
74+
join(homedir(), '.claude', 'projects', cwd.replace(/[^a-zA-Z0-9]/g, '-'), `${id}.jsonl`)
75+
76+
it('passes the initial prompt through on the --resume path', () => {
77+
// A worktree forked from an existing conversation resumes a transcript
78+
// AND needs its new instructions; dropping the prompt here left the
79+
// agent sitting idle with history it was never told what to do with.
80+
fsState.files.set(sessionPath('/tmp/test', 'abc'), '{}')
81+
const result = buildSpawnArgs({ ...base, sessionId: 'abc', initialPrompt: 'do the thing' })
82+
expect(result).toContain('--resume abc')
83+
expect(result).toContain("'do the thing'")
84+
})
85+
86+
it('omits a positional prompt on --resume when there is none', () => {
87+
fsState.files.set(sessionPath('/tmp/test', 'abc'), '{}')
88+
const result = buildSpawnArgs({ ...base, sessionId: 'abc' })
89+
expect(result).toBe('claude --resume abc')
90+
})
91+
92+
it('uses --session-id with the prompt when no transcript exists yet', () => {
93+
const result = buildSpawnArgs({ ...base, sessionId: 'abc', initialPrompt: 'hello' })
94+
expect(result).toContain('--session-id abc')
95+
expect(result).toContain("'hello'")
96+
})
97+
98+
it('shell-quotes the initial prompt on the resume path', () => {
99+
fsState.files.set(sessionPath('/tmp/test', 'abc'), '{}')
100+
const result = buildSpawnArgs({ ...base, sessionId: 'abc', initialPrompt: "it's got quotes" })
101+
expect(result).toContain("'\\''")
102+
})
72103
})
73104

74105
describe('claude extractSessionId', () => {

src/main/agents/claude.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,7 +196,8 @@ export function buildSpawnArgs(opts: AgentSpawnOpts): string {
196196
}
197197

198198
const exists = sessionFileExists(opts.cwd, opts.sessionId)
199-
if (exists) return `${cmd} --resume ${opts.sessionId}`
200-
const base = `${cmd} --session-id ${opts.sessionId}`
199+
const base = exists
200+
? `${cmd} --resume ${opts.sessionId}`
201+
: `${cmd} --session-id ${opts.sessionId}`
201202
return opts.initialPrompt ? `${base} ${shellQuote(opts.initialPrompt)}` : base
202203
}

src/main/build-initial-state.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,7 @@ export function buildInitialAppState(
135135
wsTransportHost: config.wsTransportHost ?? '127.0.0.1',
136136
browserToolsEnabled: config.browserToolsEnabled !== false,
137137
browserToolsMode: config.browserToolsMode === 'view' ? 'view' : 'full',
138+
conversationForkEnabled: config.conversationForkEnabled === true,
138139
defaultClaudeTabType: config.defaultClaudeTabType === 'json' ? 'json' : 'xterm',
139140
chatPromotionDismissed: config.chatPromotionDismissed === true,
140141
autoApprovePermissions: config.autoApprovePermissions === true,

src/main/claude-launch.test.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@ import { describe, it, expect } from 'vitest'
22
import { buildClaudeLaunchSettings } from './claude-launch'
33
import {
44
DEFAULT_HARNESS_SYSTEM_PROMPT,
5-
DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN
5+
DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN,
6+
HARNESS_SYSTEM_PROMPT_FORK_PARAGRAPH
67
} from './persistence'
78
import type { Worktree } from '../shared/state/worktrees'
89

10+
/** Conversation fork is opt-in, so the default prompt omits its paragraph. */
11+
const PROMPT_WITHOUT_FORK = DEFAULT_HARNESS_SYSTEM_PROMPT.replace(
12+
`\n\n${HARNESS_SYSTEM_PROMPT_FORK_PARAGRAPH}`,
13+
''
14+
)
15+
916
function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
1017
return {
1118
path: '/tmp/repo/feat-x',
@@ -27,7 +34,29 @@ describe('buildClaudeLaunchSettings', () => {
2734
worktrees: [wt],
2835
config: {}
2936
})
30-
expect(out.systemPrompt).toBe(DEFAULT_HARNESS_SYSTEM_PROMPT)
37+
expect(out.systemPrompt).toBe(PROMPT_WITHOUT_FORK)
38+
})
39+
40+
it('includes the fork paragraph only when conversationForkEnabled is true', () => {
41+
const wt = makeWorktree()
42+
const on = buildClaudeLaunchSettings({
43+
cwd: wt.path,
44+
worktrees: [wt],
45+
config: { conversationForkEnabled: true }
46+
})
47+
expect(on.systemPrompt).toBe(DEFAULT_HARNESS_SYSTEM_PROMPT)
48+
expect(on.systemPrompt).toContain(HARNESS_SYSTEM_PROMPT_FORK_PARAGRAPH)
49+
expect(PROMPT_WITHOUT_FORK).not.toContain(HARNESS_SYSTEM_PROMPT_FORK_PARAGRAPH)
50+
})
51+
52+
it('leaves a custom system prompt untouched when fork is disabled', () => {
53+
const wt = makeWorktree()
54+
const out = buildClaudeLaunchSettings({
55+
cwd: wt.path,
56+
worktrees: [wt],
57+
config: { harnessSystemPrompt: 'MY PROMPT' }
58+
})
59+
expect(out.systemPrompt).toBe('MY PROMPT')
3160
})
3261

3362
it('appends the main-worktree addition when isMain', () => {
@@ -38,7 +67,7 @@ describe('buildClaudeLaunchSettings', () => {
3867
config: {}
3968
})
4069
expect(out.systemPrompt).toBe(
41-
`${DEFAULT_HARNESS_SYSTEM_PROMPT}\n\n${DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN}`
70+
`${PROMPT_WITHOUT_FORK}\n\n${DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN}`
4271
)
4372
})
4473

src/main/claude-launch.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { Worktree } from '../shared/state/worktrees'
22
import {
33
DEFAULT_HARNESS_SYSTEM_PROMPT,
4-
DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN
4+
DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN,
5+
HARNESS_SYSTEM_PROMPT_FORK_PARAGRAPH
56
} from './persistence'
67

78
export interface ClaudeLaunchConfig {
@@ -11,6 +12,7 @@ export interface ClaudeLaunchConfig {
1112
harnessSystemPromptMain?: string
1213
claudeTuiFullscreen?: boolean
1314
nameClaudeSessions?: boolean
15+
conversationForkEnabled?: boolean
1416
}
1517

1618
export interface ClaudeLaunchSettings {
@@ -36,7 +38,10 @@ export function buildClaudeLaunchSettings(input: {
3638

3739
let systemPrompt: string | undefined
3840
if (config.harnessSystemPromptEnabled !== false) {
39-
const base = config.harnessSystemPrompt || DEFAULT_HARNESS_SYSTEM_PROMPT
41+
let base = config.harnessSystemPrompt || DEFAULT_HARNESS_SYSTEM_PROMPT
42+
if (config.conversationForkEnabled !== true) {
43+
base = base.replace(`\n\n${HARNESS_SYSTEM_PROMPT_FORK_PARAGRAPH}`, '')
44+
}
4045
if (isMain) {
4146
const mainAddition =
4247
config.harnessSystemPromptMain || DEFAULT_HARNESS_SYSTEM_PROMPT_MAIN

src/main/control-server.test.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ const clearAlias = vi.fn<(worktreePath: string) => void>()
2222
const CALLER_TERMINAL = 'terminal-abc'
2323
const CALLER_WORKTREE = '/repo/wt/callers-tree'
2424
const EXPLICIT_WORKTREE = '/repo/wt/other'
25+
/** Scoped to a worktree like a chat tab, but with no transcript on disk —
26+
* how a plain terminal tab looks to the fork path. */
27+
const NO_TRANSCRIPT_TERMINAL = 'terminal-no-transcript'
2528

2629
const scope: CallerScope = {
2730
terminalId: CALLER_TERMINAL,
@@ -30,14 +33,21 @@ const scope: CallerScope = {
3033
isMain: false
3134
}
3235

36+
/** Mutable so a test can flip the setting off without restarting the server —
37+
* mirrors how the real dep re-reads config per request. */
38+
let conversationForkEnabled = true
39+
3340
const deps: ControlServerDeps = {
3441
getRepoRoots: () => ['/repo'],
3542
getWorktreeBase: () => 'remote',
3643
getPrReviewPrompt: () => '',
3744
broadcast: () => {},
3845
runWorktreeSetup: async () => {},
3946
runPendingPRWorktree: async () => ({ ok: false, error: 'not used in these tests' }),
40-
resolveCallerScope: (terminalId) => (terminalId === CALLER_TERMINAL ? scope : null),
47+
resolveCallerScope: (terminalId) =>
48+
terminalId === CALLER_TERMINAL || terminalId === NO_TRANSCRIPT_TERMINAL ? scope : null,
49+
hasForkableTranscript: (sessionId) => sessionId === CALLER_TERMINAL,
50+
getConversationForkEnabled: () => conversationForkEnabled,
4151
getBrowserPerms: () => ({ enabled: false, mode: 'full' }),
4252
browser: {
4353
listTabsForWorktree: () => [],
@@ -94,15 +104,15 @@ async function call(
94104
return { status: res.status, json }
95105
}
96106

97-
describe('control-server /aliases endpoint', () => {
98-
beforeAll(async () => {
99-
await startControlServer(deps)
100-
const info = getControlServerInfo()
101-
if (!info) throw new Error('control server failed to start')
102-
baseUrl = `http://127.0.0.1:${info.port}`
103-
token = info.token
104-
})
107+
beforeAll(async () => {
108+
await startControlServer(deps)
109+
const info = getControlServerInfo()
110+
if (!info) throw new Error('control server failed to start')
111+
baseUrl = `http://127.0.0.1:${info.port}`
112+
token = info.token
113+
})
105114

115+
describe('control-server /aliases endpoint', () => {
106116
it('POST /aliases with explicit worktreePath dispatches setAlias', async () => {
107117
setAlias.mockClear()
108118
const r = await call('POST', '/aliases', {
@@ -184,3 +194,58 @@ describe('control-server /aliases endpoint', () => {
184194
expect(clearAlias).toHaveBeenCalledWith(CALLER_WORKTREE)
185195
})
186196
})
197+
198+
// These all reject before addWorktree runs, so no git work happens. The
199+
// accept path isn't covered here — it would create a real worktree.
200+
describe('control-server POST /worktrees forkConversation', () => {
201+
it('rejects a caller whose terminal has no forkable transcript', async () => {
202+
const r = await call(
203+
'POST',
204+
'/worktrees',
205+
{ branchName: 'spinoff', forkConversation: true },
206+
{ terminalId: NO_TRANSCRIPT_TERMINAL }
207+
)
208+
expect(r.status).toBe(400)
209+
expect(r.json.error).toMatch(/only available from a Harness Chat tab/)
210+
})
211+
212+
it('rejects a caller with no resolvable scope', async () => {
213+
const r = await call(
214+
'POST',
215+
'/worktrees',
216+
{ branchName: 'spinoff', forkConversation: true },
217+
{ terminalId: 'unknown-terminal' }
218+
)
219+
expect(r.status).toBe(400)
220+
expect(r.json.error).toMatch(/only available from a Harness Chat tab/)
221+
})
222+
223+
it('rejects forkConversation combined with prNumber', async () => {
224+
const r = await call('POST', '/worktrees', { prNumber: 7, forkConversation: true })
225+
expect(r.status).toBe(400)
226+
expect(r.json.error).toMatch(/cannot be combined with prNumber/)
227+
})
228+
229+
it('rejects forkConversation into an agent that cannot resume a transcript', async () => {
230+
for (const agentKind of ['codex', 'cursor']) {
231+
const r = await call('POST', '/worktrees', {
232+
branchName: 'spinoff',
233+
agentKind,
234+
forkConversation: true
235+
})
236+
expect(r.status).toBe(400)
237+
expect(r.json.error).toMatch(/only Claude Code can resume a forked transcript/)
238+
}
239+
})
240+
241+
it('rejects forkConversation when the setting is disabled', async () => {
242+
conversationForkEnabled = false
243+
try {
244+
const r = await call('POST', '/worktrees', { branchName: 'spinoff', forkConversation: true })
245+
expect(r.status).toBe(400)
246+
expect(r.json.error).toMatch(/disabled in Harness settings/)
247+
} finally {
248+
conversationForkEnabled = true
249+
}
250+
})
251+
})

0 commit comments

Comments
 (0)