From b9205b235a13e8b02ca80f30409b29e28d3b5667 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 11:21:16 +0000 Subject: [PATCH 1/8] test(server): reproduce side chat forks inheriting the parent's Claude goal (#32439) These tests reproduce pingdotgg/t3code#32439: a native side chat fork of a Claude thread with a /goal keeps the goal in the forked transcript, so the goal Stop hook blocks the side chat's turns. They expect the side chat fork to be marked as one and to run /goal clear on the forked session first. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 141 ++++++++++++++++++ .../ProviderTurnStartService.test.ts | 13 ++ 2 files changed, 154 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index d7d03c779b4bb..01fc76a52c015 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2628,6 +2628,147 @@ describe("ClaudeAdapterV2 native fork", () => { ), ), ); + + // The forked transcript keeps the parent's /goal, so its Stop hook would block side-chat turns. + it.effect("clears an inherited goal on a side chat fork before its first turn", () => + Effect.scoped( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const attachmentsDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-v2-side-chat-goal-", + }); + const prompts: Array<{ readonly session: string | undefined; readonly text: string }> = []; + const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + settings: DEFAULT_CLAUDE_SETTINGS, + environment: {}, + attachmentsDir, + fileSystem, + path: yield* Path.Path, + random: yield* SecureRandom.SecureRandom, + idAllocator, + queryRunner: { + allocateSessionId: Effect.succeed("source-native-session"), + open: (input) => + Effect.gen(function* () { + const session = input.options.resume ?? input.options.sessionId; + const messages = yield* Queue.unbounded(); + return { + messages: Stream.fromQueue(messages), + offer: (message: SDKUserMessage) => + Effect.gen(function* () { + const content = message.message.content; + const text = + typeof content === "string" + ? content + : content + .flatMap((block) => (block.type === "text" ? [block.text] : [])) + .join(""); + prompts.push({ session, text }); + if (!text.startsWith("/goal clear")) return; + // Claude Code answers /goal clear locally, without a model turn. + yield* Queue.offer(messages, { + type: "result", + subtype: "success", + duration_ms: 1, + duration_api_ms: 0, + is_error: false, + num_turns: 0, + result: "Goal cleared: The user has sent a later chat message", + stop_reason: null, + total_cost_usd: 0, + usage: { + input_tokens: 0, + output_tokens: 0, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + modelUsage: {}, + permission_denials: [], + uuid: "00000000-0000-4000-8000-000000032439", + session_id: session ?? "", + } as unknown as SDKMessage); + }), + setModel: () => Effect.void, + applyFlagSettings: () => Effect.void, + setPermissionMode: () => Effect.void, + backgroundTasks: () => Effect.succeed(false), + mcpServerStatus: Effect.succeed([]), + readMcpResource: () => Effect.die("unexpected MCP resource read"), + callMcpTool: () => Effect.die("unexpected MCP tool call"), + listPermissionRules: Effect.succeed([]), + stopTask: () => Effect.void, + interrupt: Effect.void, + close: Queue.shutdown(messages), + }; + }), + forkSession: () => Effect.succeed({ sessionId: "side-chat-native-session" }), + subagentLaunchToolUseId: () => Effect.succeed(null), + assertComplete: Effect.void, + }, + }); + const modelSelection = { + instanceId: ProviderInstanceId.make(ClaudeAdapterV2.CLAUDE_PROVIDER), + model: "claude-sonnet-4-6", + }; + const sourceThreadId = ThreadId.make("thread-claude-goal-source"); + const sideChatThreadId = ThreadId.make("thread-claude-goal-side-chat"); + const runtime = yield* adapter.openSession({ + threadId: sourceThreadId, + providerSessionId: ProviderSessionId.make("provider-session-claude-goal-fork"), + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const sourceProviderThread = { + ...(yield* runtime.ensureThread({ + threadId: sourceThreadId, + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + })), + goal: { + objective: "The user has sent a later chat message containing the word banana", + status: "complete", + checks: 9, + }, + } satisfies OrchestrationV2ProviderThread; + + const sideChat = yield* runtime.forkThread({ + sourceProviderThread, + targetThreadId: sideChatThreadId, + sideChat: true, + }); + yield* runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: sideChatThreadId, + providerThread: sideChat, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("side-chat-goal-attempt"), + text: "do we support feature X? Check README.md.", + attachments: [], + modelSelection, + }), + ); + + assert.deepEqual(prompts, [ + { session: "side-chat-native-session", text: "/goal clear" }, + { + session: "side-chat-native-session", + text: "do we support feature X? Check README.md.", + }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); }); function makeFakeClaudeCli(answerSetServers = true) { diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts index d1b5168258874..dae8cd3a03f68 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.test.ts @@ -1338,6 +1338,19 @@ effectIt.effect.each([ }), ); +effectIt.effect("marks a native side chat fork as a side chat for the provider", () => + Effect.gen(function* () { + const harness = makeLocalCommandHarness({ + text: "do we support feature X? Check README.md.", + sideChat: { contextRun: "current", nativeForkFrom: "idle" }, + }); + + yield* harness.start; + + expect(harness.forkThread).toHaveBeenCalledWith(expect.objectContaining({ sideChat: true })); + }), +); + effectIt.effect.each([ { runStatus: "failed", turnStatus: "completed" }, { runStatus: "completed", turnStatus: "completed" }, From 07f574694903d3ab7bf79ebdab0b9447344f5364 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 11:26:15 +0000 Subject: [PATCH 2/8] fix(server): side chats no longer inherit the parent's Claude goal (#32439) A native side chat fork of a Claude thread keeps the parent's /goal in its forked transcript, so Claude Code's goal Stop hook blocked every side-chat turn until its cap. Side chat forks are now marked as such, and the Claude adapter runs `/goal clear` on the forked session in a short-lived query before returning the fork. Claude Code answers it locally without a model turn. A failed clear is logged and the fork is kept. Fixes pingdotgg/t3code#32439 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.ts | 48 +++++++++++++++++++ .../ProviderTurnStartService.ts | 1 + .../src/server/ProviderAdapter.ts | 1 + 3 files changed, 50 insertions(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index df80f1316fb77..f044492e3ac85 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11775,6 +11775,50 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return found && !unreadable ? ("empty" as const) : ("missing" as const); }); + // Claude Code answers `/goal clear` locally, without a model turn. + const clearForkedGoal = Effect.fn("ClaudeAdapterV2.clearForkedGoal")( + function* ( + forkInput: ProviderAdapter.ProviderAdapterV2ForkThreadInput, + nativeId: string, + ) { + const query = yield* Effect.acquireRelease( + queryRunner.open({ + threadId: forkInput.targetThreadId, + providerSessionId: input.providerSessionId, + options: makeClaudeQueryOptions({ + modelSelection: forkInput.modelSelection ?? input.modelSelection, + modelCatalog, + nativeThreadId: nativeId, + resume: true, + cwd: input.runtimePolicy.cwd, + settings: adapterOptions.settings, + environment: adapterOptions.environment, + }), + }), + (query) => query.close.pipe(Effect.ignore), + ); + yield* query.offer(makeClaudeUserMessage({ text: "/goal clear" })); + const result = yield* query.messages.pipe( + Stream.filter((message) => message.type === "result"), + Stream.runHead, + ); + if (Option.isNone(result) || result.value.is_error) { + return yield* Effect.fail(result); + } + }, + Effect.scoped, + // The side chat still works with the goal; its turns just run into the goal's Stop hook. + (effect, forkInput) => + effect.pipe( + Effect.catchCause((cause) => + Effect.logWarning("orchestration-v2.claude-side-chat-goal-clear-failed", { + threadId: forkInput.targetThreadId, + cause, + }), + ), + ), + ); + const runtime: ProviderAdapter.ProviderAdapterV2SessionRuntime = { instanceId: adapterOptions.instanceId, driver: CLAUDE_PROVIDER, @@ -12278,6 +12322,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.add(forked.sessionId); return updated; }); + // A side chat keeps the parent's goal in its forked transcript, and the goal's Stop hook would block its turns. + if (forkInput.sideChat === true && forkInput.sourceProviderThread.goal != null) { + yield* clearForkedGoal(forkInput, forked.sessionId); + } const now = yield* DateTime.now; // Native forks retain document/image history. Inherit full current // usage even at an earlier fork point; this is conservative. diff --git a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts index 54b16d7aa2aef..f4e8a074ef758 100644 --- a/apps/server/src/orchestration-v2/ProviderTurnStartService.ts +++ b/apps/server/src/orchestration-v2/ProviderTurnStartService.ts @@ -1167,6 +1167,7 @@ export const layer: Layer.Layer< ...(sourceProviderTurn === undefined ? {} : { providerTurnId: sourceProviderTurn.id }), + ...(projection.thread.lineage.sideChat === true ? { sideChat: true } : {}), }), ); } diff --git a/packages/provider-core/src/server/ProviderAdapter.ts b/packages/provider-core/src/server/ProviderAdapter.ts index 9ea7597e6d087..52ab3bc9a6e80 100644 --- a/packages/provider-core/src/server/ProviderAdapter.ts +++ b/packages/provider-core/src/server/ProviderAdapter.ts @@ -553,6 +553,7 @@ export interface ProviderAdapterV2ForkThreadInput { readonly ownerNodeId?: NodeId; readonly modelSelection?: ModelSelection; readonly runtimePolicy?: ProviderAdapterV2RuntimePolicy; + readonly sideChat?: boolean; } export interface ProviderAdapterV2EventSubscription { From 4cfa6f79f8144a43451d5709cd38af32ff68de6f Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 11:46:26 +0000 Subject: [PATCH 3/8] fix(server): side-chat goal clear honours launch settings and survives a rewind The /goal clear query now loads the instance's --settings launch argument like normal turns and runs with no tools. Its prompt carries a fresh uuid, which becomes the side chat's fork head, so rewinding the side chat to its start resumes after the clear instead of restoring the parent's goal. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 5 +++ .../Adapters/ClaudeAdapterV2.ts | 33 ++++++++++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 01fc76a52c015..56a37b449582d 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2639,6 +2639,7 @@ describe("ClaudeAdapterV2 native fork", () => { prefix: "t3-claude-v2-side-chat-goal-", }); const prompts: Array<{ readonly session: string | undefined; readonly text: string }> = []; + let goalClearUuid: string | undefined; const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -2667,6 +2668,7 @@ describe("ClaudeAdapterV2 native fork", () => { .join(""); prompts.push({ session, text }); if (!text.startsWith("/goal clear")) return; + goalClearUuid = message.uuid; // Claude Code answers /goal clear locally, without a model turn. yield* Queue.offer(messages, { type: "result", @@ -2757,6 +2759,9 @@ describe("ClaudeAdapterV2 native fork", () => { text: "do we support feature X? Check README.md.", }, ]); + // Rewinding the side chat to its start resumes here; an earlier cursor restores the goal. + assert.isDefined(goalClearUuid); + assert.strictEqual(sideChat.nativeMetadata?.forkHeadMessageId, goalClearUuid); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f044492e3ac85..f7b9672e1fd70 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11775,12 +11775,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return found && !unreadable ? ("empty" as const) : ("missing" as const); }); - // Claude Code answers `/goal clear` locally, without a model turn. + // Claude Code answers `/goal clear` locally, without a model turn. Returns the command's + // uuid: resuming at it keeps the goal cleared, resuming before it restores the goal. const clearForkedGoal = Effect.fn("ClaudeAdapterV2.clearForkedGoal")( function* ( forkInput: ProviderAdapter.ProviderAdapterV2ForkThreadInput, nativeId: string, ) { + const launchArgSettings = yield* readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd: input.runtimePolicy.cwd, + fileSystem, + path, + }); const query = yield* Effect.acquireRelease( queryRunner.open({ threadId: forkInput.targetThreadId, @@ -11792,12 +11799,17 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resume: true, cwd: input.runtimePolicy.cwd, settings: adapterOptions.settings, + ...(launchArgSettings === undefined + ? {} + : { sdkSettings: claudeSdkLaunchArgSettings(launchArgSettings) }), environment: adapterOptions.environment, + tools: [], }), }), (query) => query.close.pipe(Effect.ignore), ); - yield* query.offer(makeClaudeUserMessage({ text: "/goal clear" })); + const uuid = yield* random.uuidV4().pipe(Effect.filterOrFail(isClaudePromptUuid)); + yield* query.offer(makeClaudeUserMessage({ text: "/goal clear", uuid })); const result = yield* query.messages.pipe( Stream.filter((message) => message.type === "result"), Stream.runHead, @@ -11805,6 +11817,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( if (Option.isNone(result) || result.value.is_error) { return yield* Effect.fail(result); } + return uuid; }, Effect.scoped, // The side chat still works with the goal; its turns just run into the goal's Stop hook. @@ -11814,7 +11827,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Effect.logWarning("orchestration-v2.claude-side-chat-goal-clear-failed", { threadId: forkInput.targetThreadId, cause, - }), + }).pipe(Effect.as(undefined)), ), ), ); @@ -12323,9 +12336,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return updated; }); // A side chat keeps the parent's goal in its forked transcript, and the goal's Stop hook would block its turns. - if (forkInput.sideChat === true && forkInput.sourceProviderThread.goal != null) { - yield* clearForkedGoal(forkInput, forked.sessionId); - } + const goalClearedAt = + forkInput.sideChat === true && forkInput.sourceProviderThread.goal != null + ? yield* clearForkedGoal(forkInput, forked.sessionId) + : undefined; + const headMessageId = goalClearedAt ?? forked.headMessageId; const now = yield* DateTime.now; // Native forks retain document/image history. Inherit full current // usage even at an earlier fork point; this is conservative. @@ -12344,13 +12359,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ? {} : { providerTurnId: forkInput.providerTurnId }), }, - ...(forked.headMessageId === undefined && claudeAttachmentBudget === undefined + ...(headMessageId === undefined && claudeAttachmentBudget === undefined ? {} : { nativeMetadata: { - ...(forked.headMessageId === undefined + ...(headMessageId === undefined ? {} - : { forkHeadMessageId: forked.headMessageId }), + : { forkHeadMessageId: headMessageId }), ...(claudeAttachmentBudget === undefined ? {} : { claudeAttachmentBudget }), }, }), From 4bbf0d0ced29dedd4ef496039ab3e6b4cc9e2045 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 7 Nov 2026 12:01:39 +0000 Subject: [PATCH 4/8] fix(server): bound the side-chat goal clear to 15 seconds A Claude CLI that starts but never answers /goal clear would otherwise hold the side chat's first turn indefinitely; on timeout the clear fails soft like any other clear failure. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index f7b9672e1fd70..932427ebfa541 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11813,6 +11813,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const result = yield* query.messages.pipe( Stream.filter((message) => message.type === "result"), Stream.runHead, + Effect.timeout("15 seconds"), ); if (Option.isNone(result) || result.value.is_error) { return yield* Effect.fail(result); From 3932502e5036eb5bd75ad6356a6c991d21efc26d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 15:10:58 +0000 Subject: [PATCH 5/8] fix(server): side chat forked behind the parent's head clears its inherited goal --- .../Adapters/ClaudeAdapterV2.test.ts | 36 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 27 +++++++++++--- 2 files changed, 58 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 618fce0ddfa45..c3ba848d10752 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2737,6 +2737,42 @@ describe("ClaudeAdapterV2 native fork", () => { }, } satisfies OrchestrationV2ProviderThread; + // The parent cleared its goal with a later turn, which says nothing about the fork point. + const now = yield* DateTime.now; + const sourceProviderTurns = [1, 2].map((ordinal) => ({ + id: ProviderTurnId.make(`provider-turn-claude-goal-source-${ordinal}`), + providerThreadId: sourceProviderThread.id, + nodeId: NodeId.make(`node-claude-goal-source-${ordinal}`), + runAttemptId: RunAttemptId.make(`run-attempt-claude-goal-source-${ordinal}`), + nativeTurnRef: { + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeId: `assistant-message-cursor-${ordinal}`, + strength: "weak" as const, + }, + ordinal, + status: "completed" as const, + startedAt: now, + completedAt: now, + })); + const [forkPointTurn, sourceHeadTurn] = sourceProviderTurns; + yield* runtime.forkThread({ + sourceProviderThread: { ...sourceProviderThread, goal: null }, + sourceProviderTurns, + providerTurnId: forkPointTurn!.id, + targetThreadId: ThreadId.make("thread-claude-goal-side-chat-behind-head"), + sideChat: true, + }); + assert.deepEqual(prompts, [{ session: "side-chat-native-session", text: "/goal clear" }]); + prompts.length = 0; + yield* runtime.forkThread({ + sourceProviderThread: { ...sourceProviderThread, goal: null }, + sourceProviderTurns, + providerTurnId: sourceHeadTurn!.id, + targetThreadId: ThreadId.make("thread-claude-goal-side-chat-at-head"), + sideChat: true, + }); + assert.deepEqual(prompts, []); + const sideChat = yield* runtime.forkThread({ sourceProviderThread, targetThreadId: sideChatThreadId, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 4ca8cd92dbfd5..3e0f5bce6cb39 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -2269,15 +2269,30 @@ const getNativeConversationHeadId = Effect.fnUntraced(function* ( return nativeHeadRef.nativeId; }); +const claudeForkSourceTurns = ( + input: ProviderAdapter.ProviderAdapterV2ForkThreadInput, +): ReadonlyArray => + (input.sourceProviderTurns ?? []) + .filter((turn) => turn.providerThreadId === input.sourceProviderThread.id) + .toSorted((left, right) => left.ordinal - right.ordinal); + +const isClaudeForkBehindSourceHead = ( + input: ProviderAdapter.ProviderAdapterV2ForkThreadInput, +): boolean => { + if (input.providerTurnId === undefined) { + return false; + } + const latest = claudeForkSourceTurns(input).at(-1); + return latest !== undefined && latest.id !== input.providerTurnId; +}; + const resolveClaudeForkUpToMessageId = Effect.fn("ClaudeAdapterV2.resolveForkUpToMessageId")( function* (input: ProviderAdapter.ProviderAdapterV2ForkThreadInput) { if (input.providerTurnId === undefined || input.sourceProviderTurns === undefined) { return undefined; } - const sourceTurns = input.sourceProviderTurns - .filter((turn) => turn.providerThreadId === input.sourceProviderThread.id) - .toSorted((left, right) => left.ordinal - right.ordinal); + const sourceTurns = claudeForkSourceTurns(input); const boundaryIndex = sourceTurns.findIndex((turn) => turn.id === input.providerTurnId); if (boundaryIndex < 0) { return yield* new ProviderAdapter.ProviderAdapterForkThreadError({ @@ -12416,9 +12431,11 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( updated.add(forked.sessionId); return updated; }); - // A side chat keeps the parent's goal in its forked transcript, and the goal's Stop hook would block its turns. + // A side chat keeps the fork point's goal, whose Stop hook would block its turns; only at the parent's head does the parent's goal show it. const goalClearedAt = - forkInput.sideChat === true && forkInput.sourceProviderThread.goal != null + forkInput.sideChat === true && + (forkInput.sourceProviderThread.goal != null || + isClaudeForkBehindSourceHead(forkInput)) ? yield* clearForkedGoal(forkInput, forked.sessionId) : undefined; const headMessageId = goalClearedAt ?? forked.headMessageId; From 587c9d3ac9995878ab97ca1fb63c0abcf83946b4 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 15:27:27 +0000 Subject: [PATCH 6/8] test(server): side chat goal clear falls back to the inherited head when it fails --- .../Adapters/ClaudeAdapterV2.test.ts | 75 +++++++++++++++++-- 1 file changed, 70 insertions(+), 5 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index c3ba848d10752..fccab29b07745 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2641,7 +2641,10 @@ describe("ClaudeAdapterV2 native fork", () => { prefix: "t3-claude-v2-side-chat-goal-", }); const prompts: Array<{ readonly session: string | undefined; readonly text: string }> = []; + const closedSessions: Array = []; let goalClearUuid: string | undefined; + let goalClearReply: "success" | "error" | "end" | "silent" = "success"; + const goalClearOffered = yield* Deferred.make(); const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, settings: DEFAULT_CLAUDE_SETTINGS, @@ -2656,7 +2659,7 @@ describe("ClaudeAdapterV2 native fork", () => { open: (input) => Effect.gen(function* () { const session = input.options.resume ?? input.options.sessionId; - const messages = yield* Queue.unbounded(); + const messages = yield* Queue.unbounded(); return { messages: Stream.fromQueue(messages), offer: (message: SDKUserMessage) => @@ -2671,13 +2674,17 @@ describe("ClaudeAdapterV2 native fork", () => { prompts.push({ session, text }); if (!text.startsWith("/goal clear")) return; goalClearUuid = message.uuid; + if (goalClearReply === "end") return yield* Queue.end(messages); + if (goalClearReply === "silent") { + return yield* Deferred.succeed(goalClearOffered, undefined); + } // Claude Code answers /goal clear locally, without a model turn. yield* Queue.offer(messages, { type: "result", - subtype: "success", + subtype: goalClearReply === "error" ? "error_during_execution" : "success", duration_ms: 1, duration_api_ms: 0, - is_error: false, + is_error: goalClearReply === "error", num_turns: 0, result: "Goal cleared: The user has sent a later chat message", stop_reason: null, @@ -2704,10 +2711,16 @@ describe("ClaudeAdapterV2 native fork", () => { listPermissionRules: Effect.succeed([]), stopTask: () => Effect.void, interrupt: Effect.void, - close: Queue.shutdown(messages), + close: Effect.sync(() => closedSessions.push(session)).pipe( + Effect.andThen(Queue.shutdown(messages)), + ), }; }), - forkSession: () => Effect.succeed({ sessionId: "side-chat-native-session" }), + forkSession: () => + Effect.succeed({ + sessionId: "side-chat-native-session", + headMessageId: "inherited-fork-head", + }), subagentLaunchToolUseId: () => Effect.succeed(null), assertComplete: Effect.void, }, @@ -2771,6 +2784,10 @@ describe("ClaudeAdapterV2 native fork", () => { targetThreadId: ThreadId.make("thread-claude-goal-side-chat-at-head"), sideChat: true, }); + yield* runtime.forkThread({ + sourceProviderThread, + targetThreadId: ThreadId.make("thread-claude-goal-plain-fork"), + }); assert.deepEqual(prompts, []); const sideChat = yield* runtime.forkThread({ @@ -2800,6 +2817,54 @@ describe("ClaudeAdapterV2 native fork", () => { // Rewinding the side chat to its start resumes here; an earlier cursor restores the goal. assert.isDefined(goalClearUuid); assert.strictEqual(sideChat.nativeMetadata?.forkHeadMessageId, goalClearUuid); + + // The clear is best effort: a side chat whose clear fails still opens at the inherited head. + for (const reply of ["error", "end", "silent"] as const) { + goalClearReply = reply; + prompts.length = 0; + closedSessions.length = 0; + const threadId = ThreadId.make(`thread-claude-goal-side-chat-${reply}`); + const failedRuntime = yield* adapter.openSession({ + threadId: sourceThreadId, + providerSessionId: ProviderSessionId.make(`provider-session-claude-goal-${reply}`), + modelSelection, + runtimePolicy: CLAUDE_TEST_RUNTIME_POLICY, + }); + const fork = yield* failedRuntime + .forkThread({ sourceProviderThread, targetThreadId: threadId, sideChat: true }) + .pipe(Effect.forkChild); + if (reply === "silent") { + yield* Deferred.await(goalClearOffered); + assert.deepEqual(closedSessions, []); + yield* TestClock.adjust("15 seconds"); + } + const failedSideChat = yield* Fiber.join(fork); + assert.deepEqual(closedSessions, ["side-chat-native-session"], reply); + assert.strictEqual( + failedSideChat.nativeMetadata?.forkHeadMessageId, + "inherited-fork-head", + reply, + ); + yield* failedRuntime.startTurn( + makeClaudeTestTurnInput({ + threadId, + providerThread: failedSideChat, + now: yield* DateTime.now, + attemptId: RunAttemptId.make(`side-chat-goal-${reply}-attempt`), + text: "still answers", + attachments: [], + modelSelection, + }), + ); + assert.deepEqual( + prompts, + [ + { session: "side-chat-native-session", text: "/goal clear" }, + { session: "side-chat-native-session", text: "still answers" }, + ], + reply, + ); + } }).pipe( Effect.provide( Layer.mergeAll( From e4dc478fefe607435215d874c61561d2e711c209 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 17:42:58 +0000 Subject: [PATCH 7/8] fix(server): side-chat goal clear logs only the result subtype on failure --- .../src/orchestration-v2/Adapters/ClaudeAdapterV2.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 3e0f5bce6cb39..110bca343c81e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11910,8 +11910,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( Stream.runHead, Effect.timeout("15 seconds"), ); - if (Option.isNone(result) || result.value.is_error) { - return yield* Effect.fail(result); + if (Option.isNone(result)) { + return yield* Effect.fail("no result" as const); + } + // The result's free-text errors can quote the transcript; log only its subtype. + if (result.value.is_error) { + return yield* Effect.fail(result.value.subtype); } return uuid; }, From ba614803b1b83c180bccd84e4ab2b1b67f1b46af Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Tue, 10 Nov 2026 17:55:07 +0000 Subject: [PATCH 8/8] test(server): failed side-chat goal clears warn without the result text --- .../Adapters/ClaudeAdapterV2.test.ts | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index fccab29b07745..69378c924e5ea 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -66,6 +66,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; @@ -2632,8 +2633,12 @@ describe("ClaudeAdapterV2 native fork", () => { ); // The forked transcript keeps the parent's /goal, so its Stop hook would block side-chat turns. - it.effect("clears an inherited goal on a side chat fork before its first turn", () => - Effect.scoped( + it.effect("clears an inherited goal on a side chat fork before its first turn", () => { + const warnings: Array> = []; + const logger = Logger.make(({ logLevel, message }) => { + if (logLevel === "Warn") warnings.push([message].flat()); + }); + return Effect.scoped( Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const idAllocator = yield* IdAllocator.IdAllocatorV2; @@ -2643,7 +2648,7 @@ describe("ClaudeAdapterV2 native fork", () => { const prompts: Array<{ readonly session: string | undefined; readonly text: string }> = []; const closedSessions: Array = []; let goalClearUuid: string | undefined; - let goalClearReply: "success" | "error" | "end" | "silent" = "success"; + let goalClearReply: "success" | "error" | "end" | "silent" | "offer-fails" = "success"; const goalClearOffered = yield* Deferred.make(); const adapter = yield* ClaudeAdapterV2.makeClaudeAdapterV2({ instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, @@ -2675,6 +2680,8 @@ describe("ClaudeAdapterV2 native fork", () => { if (!text.startsWith("/goal clear")) return; goalClearUuid = message.uuid; if (goalClearReply === "end") return yield* Queue.end(messages); + if (goalClearReply === "offer-fails") + return yield* Effect.die("offer failed"); if (goalClearReply === "silent") { return yield* Deferred.succeed(goalClearOffered, undefined); } @@ -2819,10 +2826,11 @@ describe("ClaudeAdapterV2 native fork", () => { assert.strictEqual(sideChat.nativeMetadata?.forkHeadMessageId, goalClearUuid); // The clear is best effort: a side chat whose clear fails still opens at the inherited head. - for (const reply of ["error", "end", "silent"] as const) { + for (const reply of ["error", "end", "silent", "offer-fails"] as const) { goalClearReply = reply; prompts.length = 0; closedSessions.length = 0; + warnings.length = 0; const threadId = ThreadId.make(`thread-claude-goal-side-chat-${reply}`); const failedRuntime = yield* adapter.openSession({ threadId: sourceThreadId, @@ -2840,6 +2848,13 @@ describe("ClaudeAdapterV2 native fork", () => { } const failedSideChat = yield* Fiber.join(fork); assert.deepEqual(closedSessions, ["side-chat-native-session"], reply); + assert.include( + warnings.map((message) => message[0]), + "orchestration-v2.claude-side-chat-goal-clear-failed", + reply, + ); + // The errored result's free text can quote the transcript, so it stays out of logs. + assert.notInclude(JSON.stringify(warnings), "Goal cleared:", reply); assert.strictEqual( failedSideChat.nativeMetadata?.forkHeadMessageId, "inherited-fork-head", @@ -2872,11 +2887,12 @@ describe("ClaudeAdapterV2 native fork", () => { McpProviderSessions.layer, NodeServices.layer, NodeCrypto.layer, + Logger.layer([logger]), ), ), ), - ), - ); + ); + }); }); function makeFakeClaudeCli(answerSetServers = true) {