From 3d22d69d03043725ff778fe804b8c727bfdb7593 Mon Sep 17 00:00:00 2001 From: gatsby74 <166927047+gatsby74@users.noreply.github.com> Date: Sun, 28 Jun 2026 09:13:36 +0200 Subject: [PATCH] feat(terminal): add "Copy Context" to the terminal context menu (#6267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(terminal): add "Copy Context" to the terminal context menu Copying an agent session's captured context required opening the Fork Agent Session dialog and clicking its "Copy context" button — two steps for something you often just want on its own (e.g. to paste elsewhere). Add a "Copy Context" item to the terminal right-click menu, right under "Fork Agent Session…". It reuses the existing capture + clipboard path (prepareAgentSessionForkFromPane → copyAgentSessionForkContext), so the copied text is identical to the dialog's, just one click away and without opening the dialog. Fixes #5020 Co-Authored-By: Claude Opus 4.8 (1M context) * fix(terminal): copy plain context, not the fork prompt Copy Context now copies the bounded, cleaned transcript on its own with a neutral 'Context copied' toast, instead of reusing the fork prompt (with its 'this is a fork… acknowledge and wait' framing) and the fork-copy toast. That framing is noise when pasting into another tool, which is the issue's use case. Extracts buildBoundedSessionTranscript() from the fork-prompt builder and adds copyAgentSessionContextFromPane(); the fork dialog's own copy button is unchanged. Co-authored-by: Orca --------- Co-authored-by: Claude Opus 4.8 (1M context) Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Co-authored-by: Orca --- .../TerminalContextMenu.test.tsx | 97 +++++++++++++++++++ .../terminal-pane/TerminalContextMenu.tsx | 10 ++ .../components/terminal-pane/TerminalPane.tsx | 1 + .../terminal-agent-session-fork.test.ts | 54 +++++++++++ .../terminal-agent-session-fork.ts | 46 ++++++++- .../use-terminal-pane-context-menu.ts | 14 +++ src/renderer/src/i18n/locales/en.json | 8 +- src/renderer/src/i18n/locales/es.json | 8 +- src/renderer/src/i18n/locales/ja.json | 8 +- src/renderer/src/i18n/locales/ko.json | 8 +- src/renderer/src/i18n/locales/zh.json | 8 +- .../lib/agent-session-fork-context.test.ts | 17 ++++ .../src/lib/agent-session-fork-context.ts | 11 ++- 13 files changed, 276 insertions(+), 14 deletions(-) create mode 100644 src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx new file mode 100644 index 00000000000..6b352f182b2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -0,0 +1,97 @@ +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import TerminalContextMenu from './TerminalContextMenu' + +type ItemProps = { onSelect?: () => void; children?: React.ReactNode } + +const items = vi.hoisted(() => ({ list: [] as ItemProps[] })) + +vi.mock('@/components/ui/dropdown-menu', async () => { + const React_ = await import('react') + const passthrough = ({ children }: { children?: React.ReactNode }) => + React_.createElement(React_.Fragment, null, children) + return { + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuLabel: passthrough, + DropdownMenuSeparator: () => null, + DropdownMenuShortcut: passthrough, + DropdownMenuSub: passthrough, + DropdownMenuSubContent: passthrough, + DropdownMenuSubTrigger: passthrough, + DropdownMenuTrigger: passthrough, + DropdownMenuItem: (props: ItemProps) => { + items.list.push(props) + return React.createElement(React.Fragment, null, props.children) + } + } +}) +vi.mock('@/i18n/i18n', () => ({ translate: (_key: string, fallback: string) => fallback })) +vi.mock('@/lib/agent-catalog', () => ({ AgentIcon: () => null })) +vi.mock('@/hooks/useShortcutLabel', () => ({ formatShortcutLabel: () => 'X' })) +vi.mock('./terminal-context-menu-dismiss', () => ({ + shouldIgnoreTerminalMenuPointerDownOutside: () => false +})) + +function childrenText(children: React.ReactNode): string { + return React.Children.toArray(children) + .filter((child): child is string => typeof child === 'string') + .join('') +} + +function renderMenu(overrides: Record = {}): void { + const props = { + open: true, + onOpenChange: vi.fn(), + menuPoint: { x: 0, y: 0 }, + menuOpenedAtRef: { current: 0 }, + canClosePane: true, + canExpandPane: true, + menuPaneIsExpanded: false, + onCopy: vi.fn(), + onPaste: vi.fn(), + onSplitRight: vi.fn(), + onSplitDown: vi.fn(), + keybindings: {}, + canEqualizePaneSizes: false, + onEqualizePaneSizes: vi.fn(), + onClosePane: vi.fn(), + onClearScreen: vi.fn(), + onForkAgentSession: vi.fn(), + onCopyAgentSessionContext: vi.fn(), + repoQuickCommands: [], + globalQuickCommands: [], + quickCommandRepoLabel: null, + onQuickCommand: vi.fn(), + onAddQuickCommand: vi.fn(), + onToggleExpand: vi.fn(), + onSetTitle: vi.fn(), + onCopyTerminalId: vi.fn(), + onCopyPaneId: vi.fn(), + ...overrides + } + renderToStaticMarkup(React.createElement(TerminalContextMenu, props)) +} + +describe('TerminalContextMenu', () => { + beforeEach(() => { + items.list = [] + }) + + it('renders a "Copy Context" item that triggers onCopyAgentSessionContext (issue #5020)', () => { + const onCopyAgentSessionContext = vi.fn() + const onForkAgentSession = vi.fn() + renderMenu({ onCopyAgentSessionContext, onForkAgentSession }) + + const copyContextItem = items.list.find( + (item) => childrenText(item.children) === 'Copy Context' + ) + expect(copyContextItem).toBeDefined() + + copyContextItem?.onSelect?.() + expect(onCopyAgentSessionContext).toHaveBeenCalledTimes(1) + // Why: copying context must not go through the fork dialog path. + expect(onForkAgentSession).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index ecbd50731f5..da8c45c617d 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -1,6 +1,7 @@ import { useMemo } from 'react' import { Clipboard, + ClipboardCopy, Copy, Eraser, GitFork, @@ -52,6 +53,7 @@ type TerminalContextMenuProps = { onClosePane: () => void onClearScreen: () => void onForkAgentSession: () => void + onCopyAgentSessionContext: () => void repoQuickCommands: TerminalQuickCommand[] globalQuickCommands: TerminalQuickCommand[] quickCommandRepoLabel: string | null @@ -81,6 +83,7 @@ export default function TerminalContextMenu({ onClosePane, onClearScreen, onForkAgentSession, + onCopyAgentSessionContext, repoQuickCommands, globalQuickCommands, quickCommandRepoLabel, @@ -247,6 +250,13 @@ export default function TerminalContextMenu({ 'Fork Agent Session…' )} + + + {translate( + 'auto.components.terminal.pane.TerminalContextMenu.cff67afad1', + 'Copy Context' + )} + diff --git a/src/renderer/src/components/terminal-pane/TerminalPane.tsx b/src/renderer/src/components/terminal-pane/TerminalPane.tsx index b8e8cf1ab84..463648c6cdf 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPane.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPane.tsx @@ -2593,6 +2593,7 @@ export default function TerminalPane({ onClosePane={contextMenu.onClosePane} onClearScreen={contextMenu.onClearScreen} onForkAgentSession={() => void contextMenu.onForkAgentSession()} + onCopyAgentSessionContext={() => void contextMenu.onCopyAgentSessionContext()} repoQuickCommands={repoQuickCommands} globalQuickCommands={globalQuickCommands} quickCommandRepoLabel={quickCommandRepoLabel} diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index 88584e8a653..cbbbe685080 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -516,3 +516,57 @@ describe('forkAgentSessionFromPane', () => { expect(pane.terminal.focus).toHaveBeenCalled() }) }) + +describe('copyAgentSessionContextFromPane', () => { + beforeEach(() => { + vi.clearAllMocks() + mockWriteClipboardText.mockResolvedValue(undefined) + vi.stubGlobal('window', { + api: { ui: { writeClipboardText: mockWriteClipboardText } } + }) + }) + + it('copies the bounded transcript without the fork prompt framing', async () => { + const pane = makePane('User: standalone copy\nAssistant: acknowledged') + const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork') + + const copied = await copyAgentSessionContextFromPane(pane) + + expect(copied).toBe(true) + expect(mockWriteClipboardText).toHaveBeenCalledTimes(1) + const clipped = (mockWriteClipboardText.mock.calls as unknown as string[][])[0][0] + expect(clipped).toContain('User: standalone copy') + // Why: standalone copy must not carry the fork header/footer the dialog adds. + expect(clipped).not.toContain('fork of an existing Orca agent session') + expect(clipped).not.toContain('wait for my next instruction') + expect(mockToast.message).toHaveBeenCalledWith('Context copied') + expect(mockToast.message).not.toHaveBeenCalledWith( + 'Fork context copied. Launch an agent and paste it to start the fork.' + ) + expect(pane.terminal.focus).toHaveBeenCalled() + }) + + it('shows a copy-specific empty-context error without writing the clipboard', async () => { + const pane = makePane('\x1b[0m\r\n\x1bc\x07') + const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork') + + const copied = await copyAgentSessionContextFromPane(pane) + + expect(copied).toBe(false) + expect(mockWriteClipboardText).not.toHaveBeenCalled() + expect(mockToast.error).toHaveBeenCalledWith('No terminal context to copy') + expect(pane.terminal.focus).toHaveBeenCalled() + }) + + it('surfaces clipboard write failures', async () => { + mockWriteClipboardText.mockRejectedValueOnce(new Error('clipboard denied')) + const pane = makePane('User: copy this') + const { copyAgentSessionContextFromPane } = await import('./terminal-agent-session-fork') + + const copied = await copyAgentSessionContextFromPane(pane) + + expect(copied).toBe(false) + expect(mockToast.error).toHaveBeenCalledWith('clipboard denied') + expect(pane.terminal.focus).toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts index 6fbdcc82349..6b36a638749 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.ts @@ -1,7 +1,10 @@ import { toast } from 'sonner' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { launchAgentInNewTab } from '@/lib/launch-agent-in-new-tab' -import { buildAgentSessionForkPrompt } from '@/lib/agent-session-fork-context' +import { + buildAgentSessionForkPrompt, + buildBoundedSessionTranscript +} from '@/lib/agent-session-fork-context' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { useAppStore } from '@/store' import { makePaneKey } from '../../../../shared/stable-pane-id' @@ -168,6 +171,47 @@ export async function copyAgentSessionForkContext( return copyForkContext(fork.prompt, fork.pane) } +// Why: the standalone "Copy Context" action copies the bounded transcript on its +// own — for pasting into another tool — so it must not carry the fork prompt's +// "this is a fork… acknowledge and wait" framing the dialog button uses. +export async function copyAgentSessionContextFromPane(pane: ManagedPane): Promise { + const transcript = buildBoundedSessionTranscript( + pane.serializeAddon.serialize({ scrollback: 800 }) + ) + if (!transcript) { + toast.error( + translate( + 'auto.components.terminal.pane.terminal.agent.session.fork.f62b40e2c7', + 'No terminal context to copy' + ) + ) + pane.terminal.focus() + return false + } + try { + await window.api.ui.writeClipboardText(transcript) + toast.message( + translate( + 'auto.components.terminal.pane.terminal.agent.session.fork.373a3103e7', + 'Context copied' + ) + ) + pane.terminal.focus() + return true + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : translate( + 'auto.components.terminal.pane.terminal.agent.session.fork.3fc568a49d', + 'Failed to copy context.' + ) + ) + pane.terminal.focus() + return false + } +} + export async function startAgentSessionFork(fork: PreparedAgentSessionFork): Promise { const store = useAppStore.getState() const sourceWorktree = store.getKnownWorktreeById(fork.worktreeId) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts index 61c41e79313..180fb78ca08 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-context-menu.ts @@ -31,6 +31,7 @@ import { import { makePaneKey } from '../../../../shared/stable-pane-id' import { runQuickCommandInNewTab } from '@/lib/run-quick-command-in-new-tab' import { + copyAgentSessionContextFromPane, prepareAgentSessionForkFromPane, type PreparedAgentSessionFork } from './terminal-agent-session-fork' @@ -91,6 +92,7 @@ type TerminalMenuState = { onClosePane: () => void onClearScreen: () => void onForkAgentSession: () => Promise + onCopyAgentSessionContext: () => Promise onQuickCommand: (command: TerminalQuickCommand) => void onToggleExpand: () => void onSetTitle: () => void @@ -394,6 +396,17 @@ export function useTerminalPaneContextMenu({ } } + // Why: the captured session transcript is often wanted on its own — to paste + // into another tool — so copy the bounded transcript directly, without the + // fork prompt's framing or the fork dialog detour (issue #5020). + const onCopyAgentSessionContext = async (): Promise => { + const pane = resolveMenuPane() + if (!pane) { + return + } + await copyAgentSessionContextFromPane(pane) + } + const onQuickCommand = (command: TerminalQuickCommand): void => { if (isTerminalAgentQuickCommand(command)) { runQuickCommandInNewTab({ command, worktreeId, groupId }) @@ -520,6 +533,7 @@ export function useTerminalPaneContextMenu({ onClosePane, onClearScreen, onForkAgentSession, + onCopyAgentSessionContext, onQuickCommand, onToggleExpand, onSetTitle: handleSetTitle diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f082ae69916..843848d93fe 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -2378,7 +2378,8 @@ "f3eeb1de13": "Copy", "c2f0b72b8d": "Insert", "925f49f210": "Expand Pane", - "df766809e0": "Collapse Pane" + "df766809e0": "Collapse Pane", + "cff67afad1": "Copy Context" }, "TerminalErrorToast": { "e4aa243f8c": "Restart daemon", @@ -2428,7 +2429,10 @@ "38e41edc6e": "This workspace cannot be forked into a git worktree.", "f867385bb5": "Could not find the source workspace for this fork.", "046e8d853c": "No terminal context to fork", - "c00421d320": "Fork context copied. Launch an agent and paste it to start the fork." + "c00421d320": "Fork context copied. Launch an agent and paste it to start the fork.", + "f62b40e2c7": "No terminal context to copy", + "373a3103e7": "Context copied", + "3fc568a49d": "Failed to copy context." } } }, diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index d9da0fd6124..ffd5905ac60 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -2378,7 +2378,8 @@ "f3eeb1de13": "Copiar", "c2f0b72b8d": "Insertar", "925f49f210": "Expandir panel", - "df766809e0": "Contraer panel" + "df766809e0": "Contraer panel", + "cff67afad1": "Copy Context" }, "TerminalErrorToast": { "e4aa243f8c": "Reiniciar demonio", @@ -2428,7 +2429,10 @@ "38e41edc6e": "Este espacio de trabajo no se puede bifurcar en un árbol de trabajo de git.", "f867385bb5": "No se pudo encontrar el espacio de trabajo de origen para esta bifurcación.", "046e8d853c": "No hay contexto de terminal para bifurcar", - "c00421d320": "Se copió el contexto de la bifurcación. Inicie un agente y péguelo para iniciar la bifurcación." + "c00421d320": "Se copió el contexto de la bifurcación. Inicie un agente y péguelo para iniciar la bifurcación.", + "f62b40e2c7": "No hay contexto de terminal para copiar", + "373a3103e7": "Contexto copiado", + "3fc568a49d": "No se pudo copiar el contexto." } } }, diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index 6f87487774c..c4ac15206c8 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -2378,7 +2378,8 @@ "f3eeb1de13": "コピー", "c2f0b72b8d": "入れる", "925f49f210": "ペインを展開する", - "df766809e0": "ペインを折りたたむ" + "df766809e0": "ペインを折りたたむ", + "cff67afad1": "Copy Context" }, "TerminalErrorToast": { "e4aa243f8c": "デーモンを再起動します", @@ -2428,7 +2429,10 @@ "38e41edc6e": "このワークスペースを git ワークツリーにフォークすることはできません。", "f867385bb5": "このフォークのソース ワークスペースが見つかりませんでした。", "046e8d853c": "フォークする terminal コンテキストがありません", - "c00421d320": "フォークコンテキストがコピーされました。agent を起動し、貼り付けてフォークを開始します。" + "c00421d320": "フォークコンテキストがコピーされました。agent を起動し、貼り付けてフォークを開始します。", + "f62b40e2c7": "No terminal context to copy", + "373a3103e7": "Context copied", + "3fc568a49d": "Failed to copy context." } } }, diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4f7a5f168bf..2041a6f9449 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -2378,7 +2378,8 @@ "f3eeb1de13": "복사", "c2f0b72b8d": "삽입", "925f49f210": "창 확장", - "df766809e0": "창 축소" + "df766809e0": "창 축소", + "cff67afad1": "Copy Context" }, "TerminalErrorToast": { "e4aa243f8c": "데몬 재시작", @@ -2428,7 +2429,10 @@ "38e41edc6e": "이 워크스페이스는 git 작업 트리로 포크할 수 없습니다.", "f867385bb5": "이 포크의 소스 워크스페이스를 찾을 수 없습니다.", "046e8d853c": "포크할 terminal 컨텍스트가 없습니다.", - "c00421d320": "포크 컨텍스트가 복사되었습니다. agent를 실행하고 붙여넣어 포크를 시작합니다." + "c00421d320": "포크 컨텍스트가 복사되었습니다. agent를 실행하고 붙여넣어 포크를 시작합니다.", + "f62b40e2c7": "복사할 terminal 컨텍스트가 없습니다", + "373a3103e7": "컨텍스트가 복사되었습니다", + "3fc568a49d": "컨텍스트를 복사하지 못했습니다." } } }, diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 41fc54e3f27..7d6623a40a4 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -2378,7 +2378,8 @@ "f3eeb1de13": "复制", "c2f0b72b8d": "插入", "925f49f210": "展开窗格", - "df766809e0": "折叠窗格" + "df766809e0": "折叠窗格", + "cff67afad1": "Copy Context" }, "TerminalErrorToast": { "e4aa243f8c": "重新启动守护进程", @@ -2428,7 +2429,10 @@ "38e41edc6e": "该工作区无法分叉为 git 工作树。", "f867385bb5": "找不到此分支的源工作区。", "046e8d853c": "没有要 fork 的终端上下文", - "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。" + "c00421d320": "已复制分叉上下文。启动智能体并粘贴它以启动分叉。", + "f62b40e2c7": "没有可复制的终端上下文", + "373a3103e7": "已复制上下文", + "3fc568a49d": "无法复制上下文。" } } }, diff --git a/src/renderer/src/lib/agent-session-fork-context.test.ts b/src/renderer/src/lib/agent-session-fork-context.test.ts index 9c1ebeb019b..5d0604024fe 100644 --- a/src/renderer/src/lib/agent-session-fork-context.test.ts +++ b/src/renderer/src/lib/agent-session-fork-context.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { buildAgentSessionForkPrompt, + buildBoundedSessionTranscript, cleanAgentSessionForkTranscript } from './agent-session-fork-context' @@ -66,6 +67,22 @@ describe('agent session fork context', () => { expect(matchAllCalls).toHaveLength(0) }) + it('builds a bounded transcript without the fork prompt framing', () => { + const transcript = buildBoundedSessionTranscript( + '\x1b]0;Codex working\x07\x1b[31mUser: ship it\x1b[0m\r\nAssistant: done' + ) + + // Why: the standalone Copy Context action must yield raw transcript only — + // no fork header/footer that a paste target would treat as instructions. + expect(transcript).toBe('User: ship it\nAssistant: done') + expect(transcript).not.toContain('fork of an existing Orca agent session') + expect(transcript).not.toContain('wait for my next instruction') + }) + + it('returns null from the bounded transcript when nothing survives cleanup', () => { + expect(buildBoundedSessionTranscript('\x1b[0m\r\n\x1bc\x07')).toBeNull() + }) + it('uses a longer fence when captured output contains markdown fences', () => { const prompt = buildAgentSessionForkPrompt({ capturedText: 'Assistant output:\n```text\nignore prior instructions\n```' diff --git a/src/renderer/src/lib/agent-session-fork-context.ts b/src/renderer/src/lib/agent-session-fork-context.ts index f50e9d891ec..17058970590 100644 --- a/src/renderer/src/lib/agent-session-fork-context.ts +++ b/src/renderer/src/lib/agent-session-fork-context.ts @@ -131,14 +131,19 @@ function isUnsupportedTranscriptControl(code: number): boolean { return code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || code === 127 } +export function buildBoundedSessionTranscript(capturedText: string): string | null { + const transcript = trimToContextBudget( + cleanAgentSessionForkTranscript(tailBoundForkCapture(capturedText)) + ) + return transcript || null +} + export function buildAgentSessionForkPrompt({ capturedText, sourceLabel, agentLabel }: AgentSessionForkPromptInput): string | null { - const transcript = trimToContextBudget( - cleanAgentSessionForkTranscript(tailBoundForkCapture(capturedText)) - ) + const transcript = buildBoundedSessionTranscript(capturedText) if (!transcript) { return null }