diff --git a/.changeset/btw-side-agent.md b/.changeset/btw-side-agent.md new file mode 100644 index 00000000..9224db09 --- /dev/null +++ b/.changeset/btw-side-agent.md @@ -0,0 +1,7 @@ +--- +"@moonshot-ai/agent-core": minor +"@moonshot-ai/kimi-code-sdk": minor +"@moonshot-ai/kimi-code": minor +--- + +Add `/btw` for side-channel conversations without steering the active main turn. diff --git a/apps/kimi-code/src/tui/commands/btw.ts b/apps/kimi-code/src/tui/commands/btw.ts new file mode 100644 index 00000000..5b857151 --- /dev/null +++ b/apps/kimi-code/src/tui/commands/btw.ts @@ -0,0 +1,25 @@ +import { LLM_NOT_SET_MESSAGE } from '../constant/kimi-tui'; +import { formatErrorMessage } from '../utils/event-payload'; +import type { SlashCommandHost } from './dispatch'; + +export async function handleBtwCommand(host: SlashCommandHost, args: string): Promise { + const prompt = args.trim(); + if (prompt.length === 0) { + host.showError('Usage: /btw '); + return; + } + + const session = host.session; + if (host.state.appState.model.trim().length === 0 || session === undefined) { + host.showError(LLM_NOT_SET_MESSAGE); + return; + } + host.btwPanelController.closeOrCancel(); + + try { + const agentId = await session.startBtw(); + host.btwPanelController.open(agentId, prompt); + } catch (error) { + host.showError(`Failed to start /btw: ${formatErrorMessage(error)}`); + } +} diff --git a/apps/kimi-code/src/tui/commands/dispatch.ts b/apps/kimi-code/src/tui/commands/dispatch.ts index 90250f87..6102cf51 100644 --- a/apps/kimi-code/src/tui/commands/dispatch.ts +++ b/apps/kimi-code/src/tui/commands/dispatch.ts @@ -15,12 +15,14 @@ import { } from './resolve'; import type { BuiltinSlashCommandName } from './registry'; import type { AuthFlowController } from '../controllers/auth-flow'; +import type { BtwPanelController } from '../controllers/btw-panel'; import type { StreamingUIController } from '../controllers/streaming-ui'; import type { TasksBrowserController } from '../controllers/tasks-browser'; import type { AppState, LoginProgressSpinnerHandle, QueuedMessage } from '../types'; import type { TUIState } from '../tui-state'; import { handleLoginCommand, handleLogoutCommand } from './auth'; +import { handleBtwCommand } from './btw'; import { tryHandleDanceCommand } from '../easter-eggs/dance'; import { handleAutoCommand, @@ -55,6 +57,7 @@ export { handleLoginCommand, handleLogoutCommand, } from './auth'; +export { handleBtwCommand } from './btw'; export { handleAutoCommand, handleCompactCommand, @@ -132,6 +135,7 @@ export interface SlashCommandHost { // Controller refs readonly streamingUI: StreamingUIController; + readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; readonly authFlow: AuthFlowController; } @@ -258,6 +262,9 @@ async function handleBuiltInSlashCommand( case 'feedback': await handleFeedbackCommand(host); return; + case 'btw': + await handleBtwCommand(host, args); + return; case 'title': await handleTitleCommand(host, args); return; diff --git a/apps/kimi-code/src/tui/commands/index.ts b/apps/kimi-code/src/tui/commands/index.ts index bdf794d8..ba639312 100644 --- a/apps/kimi-code/src/tui/commands/index.ts +++ b/apps/kimi-code/src/tui/commands/index.ts @@ -10,6 +10,7 @@ export { handleLoginCommand, handleLogoutCommand, } from './auth'; +export { handleBtwCommand } from './btw'; export { handleCompactCommand, handleEditorCommand, diff --git a/apps/kimi-code/src/tui/commands/registry.ts b/apps/kimi-code/src/tui/commands/registry.ts index c7a8d478..9abe88e5 100644 --- a/apps/kimi-code/src/tui/commands/registry.ts +++ b/apps/kimi-code/src/tui/commands/registry.ts @@ -67,6 +67,13 @@ export const BUILTIN_SLASH_COMMANDS = [ priority: 95, availability: 'always', }, + { + name: 'btw', + aliases: [], + description: 'Ask a forked side agent a question', + priority: 90, + availability: 'always', + }, { name: 'help', aliases: ['h', '?'], diff --git a/apps/kimi-code/src/tui/components/editor/custom-editor.ts b/apps/kimi-code/src/tui/components/editor/custom-editor.ts index 3ceff7a9..c77f4af2 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -111,7 +111,10 @@ export class CustomEditor extends Editor { * through so pi-tui's built-in history navigation runs. */ public onUpArrowEmpty?: () => boolean; + public onDownArrowEmpty?: () => boolean; public onShiftTab?: () => void; + public connectedAbove = false; + public borderHighlighted = false; /** * Called when the user triggers "paste image" (Ctrl-V on Unix, * Alt-V on Windows — Ctrl-V is terminal-reserved there). Return @@ -213,7 +216,9 @@ export class CustomEditor extends Editor { // overwrite it (e.g. plan-mode / slash-context highlight via // `editor.borderColor = chalk.hex(primary)`), so we route corners and // side bars through the same hook to stay in sync. - return wrapWithSideBorders(lines, (s) => this.borderColor(s)); + return wrapWithSideBorders(lines, (s) => this.borderColor(s), { + connectedAbove: this.connectedAbove && !this.borderHighlighted, + }); } override handleInput(data: string): void { @@ -320,6 +325,12 @@ export class CustomEditor extends Editor { } } + if (matchesKey(normalized, Key.down)) { + if (this.getText().length === 0 && this.onDownArrowEmpty) { + if (this.onDownArrowEmpty()) return; + } + } + if (matchesKey(normalized, Key.escape)) { if (this.hasAutocompleteActivity()) { this.cancelAutocompleteActivity(); @@ -398,13 +409,14 @@ export function injectPromptSymbol(line: string): string | undefined { export function wrapWithSideBorders( lines: string[], paint: (s: string) => string, + options: { readonly connectedAbove?: boolean } = {}, ): string[] { let seenTop = false; return lines.map((line) => { const plain = stripSgr(line); if (plain.length > 0 && plain[0] === '─') { - const leftCorner = seenTop ? '╰' : '╭'; - const rightCorner = seenTop ? '╯' : '╮'; + const leftCorner = seenTop ? '╰' : options.connectedAbove === true ? '├' : '╭'; + const rightCorner = seenTop ? '╯' : options.connectedAbove === true ? '┤' : '╮'; seenTop = true; if (plain.length === 1) return paint(leftCorner); const middle = plain.slice(1, -1); diff --git a/apps/kimi-code/src/tui/components/panes/btw-panel.ts b/apps/kimi-code/src/tui/components/panes/btw-panel.ts new file mode 100644 index 00000000..252438e0 --- /dev/null +++ b/apps/kimi-code/src/tui/components/panes/btw-panel.ts @@ -0,0 +1,247 @@ +import type { Component, MarkdownTheme } from '@earendil-works/pi-tui'; +import { + Markdown, + Text, + truncateToWidth, + visibleWidth, +} from '@earendil-works/pi-tui'; +import chalk from 'chalk'; + +import { THINKING_PREVIEW_LINES } from '../../constant/rendering'; +import type { ColorPalette } from '../../theme/colors'; + +type BtwPanelPhase = 'running' | 'done' | 'failed'; + +const MIN_COLLAPSED_PANEL_LINES = 3; + +interface BtwTurn { + readonly prompt: string; + answer: string; + thinking: string; + error?: string | undefined; + phase: BtwPanelPhase; +} + +interface BtwBodyRender { + readonly lines: string[]; + readonly truncated: boolean; +} + +export interface BtwPanelOptions { + readonly colors: ColorPalette; + readonly markdownTheme: MarkdownTheme; + readonly canUseScrollKeys: () => boolean; + readonly onPrompt: (prompt: string) => void; + readonly terminalRows: () => number; +} + +export class BtwPanelComponent implements Component { + private readonly turns: BtwTurn[] = []; + private readonly transientNotices: string[] = []; + private minBodyLines = 0; + private followTail = true; + private scrollTop = 0; + private maxScrollTop = 0; + + constructor(private readonly options: BtwPanelOptions) {} + + submit(prompt: string): void { + const normalized = prompt.trim(); + if (normalized.length === 0 || this.isRunning()) return; + this.followTail = true; + this.scrollTop = 0; + this.transientNotices.length = 0; + this.turns.push({ + prompt: normalized, + answer: '', + thinking: '', + phase: 'running', + }); + this.options.onPrompt(normalized); + } + + addTransientNotice(message: string): void { + this.transientNotices.push(message); + this.followTail = true; + } + + appendAnswer(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.answer += delta; + } + + appendThinking(delta: string): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + turn.thinking += delta; + } + + markDone(resultSummary?: string | undefined): void { + const turn = this.currentTurn(); + if (turn === undefined) return; + if (turn.answer.trim().length === 0 && resultSummary !== undefined) { + turn.answer = resultSummary; + } + this.transientNotices.length = 0; + turn.phase = 'done'; + } + + markFailed(error: string): void { + const turn = this.currentTurn(); + if (turn === undefined || turn.phase !== 'running') { + this.turns.push({ + prompt: '', + answer: '', + thinking: '', + error, + phase: 'failed', + }); + this.transientNotices.length = 0; + return; + } + turn.error = error; + this.transientNotices.length = 0; + turn.phase = 'failed'; + } + + invalidate(): void {} + + render(width: number): string[] { + const safeWidth = Math.max(4, width); + const contentWidth = Math.max(1, safeWidth - 4); + const body = this.renderBody(contentWidth); + const lines = [this.renderTopBorder(safeWidth, body.truncated)]; + for (const line of body.lines) { + lines.push(this.renderBodyLine(line, safeWidth)); + } + return lines; + } + + private renderTopBorder(width: number, truncated: boolean): string { + const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const hint = truncated && this.options.canUseScrollKeys() + ? 'Esc close · ↑↓ scroll ' + : 'Esc close '; + const title = + chalk.hex(this.options.colors.accent).bold(' BTW ') + + paint('─ ') + + chalk.hex(this.options.colors.textMuted)(hint); + const innerWidth = Math.max(1, width - 2); + const clippedTitle = + visibleWidth(title) > innerWidth ? truncateToWidth(title, innerWidth, '') : title; + const dashCount = Math.max(0, innerWidth - visibleWidth(clippedTitle)); + return paint('╭') + clippedTitle + paint('─'.repeat(dashCount)) + paint('╮'); + } + + private renderBody(width: number): BtwBodyRender { + const lines: string[] = []; + for (const [index, turn] of this.turns.entries()) { + if (index > 0) lines.push(''); + lines.push(...this.renderTurn(turn, width)); + } + if (this.turns.length === 0) { + lines.push(chalk.hex(this.options.colors.textDim)('Ready for a side question...')); + } + lines.push(...this.renderTransientNotices(width)); + return this.fitBodyLines(lines); + } + + private renderTransientNotices(width: number): string[] { + const lines: string[] = []; + for (const notice of this.transientNotices) { + lines.push(...new Text(chalk.hex(this.options.colors.textDim)(notice), 0, 0).render(width)); + } + return lines; + } + + private fitBodyLines(lines: string[]): BtwBodyRender { + const bodyLimit = this.collapsedBodyLimit(); + const targetUncapped = Math.max(this.minBodyLines, lines.length); + const target = + bodyLimit === undefined ? targetUncapped : Math.min(bodyLimit, targetUncapped); + this.minBodyLines = Math.max(this.minBodyLines, target); + + if (lines.length > target) { + this.maxScrollTop = lines.length - target; + if (this.followTail) { + this.scrollTop = this.maxScrollTop; + } else { + this.scrollTop = Math.min(this.scrollTop, this.maxScrollTop); + } + const start = this.scrollTop; + return { lines: lines.slice(start, start + target), truncated: true }; + } + + this.followTail = true; + this.scrollTop = 0; + this.maxScrollTop = 0; + const padded = [...lines]; + while (padded.length < target) { + padded.push(''); + } + return { lines: padded, truncated: false }; + } + + private collapsedBodyLimit(): number | undefined { + const terminalRows = this.options.terminalRows(); + if (!Number.isFinite(terminalRows) || terminalRows <= 0) return undefined; + const maxPanelLines = Math.max(MIN_COLLAPSED_PANEL_LINES, Math.floor(terminalRows / 2)); + return Math.max(1, maxPanelLines - 1); + } + + private renderTurn(turn: BtwTurn, width: number): string[] { + const prompt = chalk.hex(this.options.colors.accent)(`Q: ${turn.prompt}`); + const lines = [...new Text(prompt, 0, 0).render(width)]; + const answer = turn.answer.trim(); + const thinking = turn.thinking.trim(); + if (answer.length > 0) { + lines.push(...new Markdown(answer, 0, 0, this.options.markdownTheme).render(width)); + } else if (thinking.length > 0) { + const thinkingLines = new Text(chalk.hex(this.options.colors.textDim)(thinking), 0, 0).render( + width, + ); + const visibleThinking = + thinkingLines.length > THINKING_PREVIEW_LINES + ? thinkingLines.slice(thinkingLines.length - THINKING_PREVIEW_LINES) + : thinkingLines; + lines.push(...visibleThinking); + } else if (turn.error === undefined) { + lines.push(chalk.hex(this.options.colors.textDim)('Waiting for answer...')); + } + if (turn.error !== undefined) { + const error = chalk.hex(this.options.colors.error)(turn.error); + lines.push(...new Text(error, 0, 0).render(width)); + } + return lines; + } + + private renderBodyLine(line: string, width: number): string { + const paint = (s: string): string => chalk.hex(this.options.colors.border)(s); + const contentWidth = Math.max(1, width - 4); + const clipped = + visibleWidth(line) > contentWidth ? truncateToWidth(line, contentWidth, '…') : line; + const padding = Math.max(0, contentWidth - visibleWidth(clipped)); + return paint('│') + ' ' + clipped + ' '.repeat(padding) + ' ' + paint('│'); + } + + private currentTurn(): BtwTurn | undefined { + return this.turns.at(-1); + } + + isRunning(): boolean { + return this.currentTurn()?.phase === 'running'; + } + + scroll(direction: 'up' | 'down'): boolean { + if (this.maxScrollTop <= 0) return false; + const current = this.followTail ? this.maxScrollTop : this.scrollTop; + const next = + direction === 'up' + ? Math.max(0, current - 1) + : Math.min(this.maxScrollTop, current + 1); + this.scrollTop = next; + this.followTail = next === this.maxScrollTop; + return true; + } +} diff --git a/apps/kimi-code/src/tui/controllers/btw-panel.ts b/apps/kimi-code/src/tui/controllers/btw-panel.ts new file mode 100644 index 00000000..1ec1eab4 --- /dev/null +++ b/apps/kimi-code/src/tui/controllers/btw-panel.ts @@ -0,0 +1,236 @@ +import { Spacer } from '@earendil-works/pi-tui'; +import type { + Event, + KimiHarness, + Session, + TurnEndedEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { NO_ACTIVE_SESSION_MESSAGE } from '../constant/kimi-tui'; +import { BtwPanelComponent } from '../components/panes/btw-panel'; +import { formatErrorMessage } from '../utils/event-payload'; +import { formatHookResultPlain } from '../utils/hook-result-format'; +import type { TUIState } from '../tui-state'; + +const BTW_BUSY_NOTICE = 'Wait for /btw to finish before sending another question.'; + +export interface BtwPanelHost { + state: TUIState; + session: Session | undefined; + readonly harness: KimiHarness; + + showError(msg: string): void; +} + +export class BtwPanelController { + private active: + | { + readonly agentId: string; + readonly panel: BtwPanelComponent; + } + | undefined; + private readonly panelsByAgentId = new Map(); + + constructor(private readonly host: BtwPanelHost) {} + + open(agentId: string, initialPrompt: string): void { + let panel: BtwPanelComponent; + panel = new BtwPanelComponent({ + colors: this.host.state.theme.colors, + markdownTheme: this.host.state.theme.markdownTheme, + canUseScrollKeys: () => this.host.state.editor.getText().length === 0, + terminalRows: () => this.host.state.terminal.rows, + onPrompt: (prompt) => { + this.promptAgent(agentId, prompt, panel); + }, + }); + this.active = { agentId, panel }; + this.panelsByAgentId.set(agentId, panel); + this.mount(panel); + panel.submit(initialPrompt); + } + + clear(): void { + const active = this.active; + if (active?.panel.isRunning()) { + void this.cancelAgent(active.agentId); + } + this.active = undefined; + this.panelsByAgentId.clear(); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + } + + closeOrCancel(): boolean { + const active = this.active; + if (active === undefined) return false; + const wasRunning = active.panel.isRunning(); + this.close(active.panel); + if (wasRunning) { + void this.cancelAgent(active.agentId); + } + return true; + } + + cancelRunning(): boolean { + const active = this.active; + if (active === undefined || !active.panel.isRunning()) return false; + void this.cancelAgent(active.agentId); + return true; + } + + sendUserInput(text: string): boolean { + const active = this.active; + if (active === undefined) return false; + if (active.panel.isRunning()) { + this.showBusyNotice(active, text); + return true; + } + active.panel.submit(text); + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + return true; + } + + scroll(direction: 'up' | 'down'): boolean { + const panel = this.active?.panel; + if (panel === undefined || !panel.scroll(direction)) return false; + this.host.state.ui.requestRender(); + return true; + } + + routeEvent(event: Event): boolean { + const panel = this.panelsByAgentId.get(event.agentId); + if (panel === undefined) return false; + + switch (event.type) { + case 'assistant.delta': + panel.appendAnswer(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'thinking.delta': + panel.appendThinking(event.delta); + this.host.state.ui.requestRender(); + return true; + case 'hook.result': + panel.appendAnswer(formatHookResultPlain(event)); + this.host.state.ui.requestRender(); + return true; + case 'turn.ended': + if (event.reason === 'completed') { + panel.markDone(); + } else { + panel.markFailed(formatBtwTurnEnd(event)); + } + this.host.state.ui.requestRender(); + return true; + case 'agent.status.updated': + case 'background.task.started': + case 'background.task.terminated': + case 'compaction.blocked': + case 'compaction.cancelled': + case 'compaction.completed': + case 'compaction.started': + case 'cron.fired': + case 'error': + case 'mcp.server.status': + case 'session.meta.updated': + case 'skill.activated': + case 'subagent.completed': + case 'subagent.failed': + case 'subagent.spawned': + case 'tool.call.delta': + case 'tool.call.started': + case 'tool.list.updated': + case 'tool.progress': + case 'tool.result': + case 'turn.started': + case 'turn.step.completed': + case 'turn.step.interrupted': + case 'turn.step.retrying': + case 'turn.step.started': + case 'warning': + return true; + default: + return true; + } + } + + private mount(panel: BtwPanelComponent): void { + this.host.state.btwPanelContainer.clear(); + this.host.state.btwPanelContainer.addChild(new Spacer(1)); + this.host.state.btwPanelContainer.addChild(panel); + this.host.state.editor.connectedAbove = true; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(); + } + + private close(panel: BtwPanelComponent): void { + if (!this.host.state.btwPanelContainer.children.includes(panel)) return; + this.unregister(panel); + this.host.state.btwPanelContainer.clear(); + this.host.state.editor.connectedAbove = false; + this.host.state.ui.setFocus(this.host.state.editor); + this.host.state.ui.requestRender(true); + } + + private unregister(panel: BtwPanelComponent): void { + for (const [agentId, candidate] of this.panelsByAgentId) { + if (candidate === panel) { + this.panelsByAgentId.delete(agentId); + } + } + if (this.active?.panel === panel) this.active = undefined; + } + + private showBusyNotice( + active: { readonly panel: BtwPanelComponent }, + input: string, + ): void { + this.host.state.editor.setText(input); + active.panel.addTransientNotice(BTW_BUSY_NOTICE); + this.host.state.ui.requestRender(); + } + + private promptAgent(agentId: string, prompt: string, panel: BtwPanelComponent): void { + const session = this.host.session; + if (session === undefined) { + panel.markFailed(NO_ACTIVE_SESSION_MESSAGE); + this.host.state.ui.requestRender(); + return; + } + void this.withInteractiveAgent(agentId, () => session.prompt(prompt)).catch((error: unknown) => { + panel.markFailed(`Failed to send /btw prompt: ${formatErrorMessage(error)}`); + this.host.state.ui.requestRender(); + }); + } + + private async cancelAgent(agentId: string): Promise { + const session = this.host.session; + if (session === undefined) return; + await this.withInteractiveAgent(agentId, () => session.cancel()).catch((error: unknown) => { + this.host.showError(`Failed to cancel /btw: ${formatErrorMessage(error)}`); + }); + } + + private withInteractiveAgent(agentId: string, fn: () => Promise): Promise { + const previousAgentId = this.host.harness.interactiveAgentId; + this.host.harness.interactiveAgentId = agentId; + try { + // SDK RPC methods snapshot interactiveAgentId before their first await. + return fn(); + } finally { + this.host.harness.interactiveAgentId = previousAgentId; + } + } +} + +function formatBtwTurnEnd(event: TurnEndedEvent): string { + if (event.error !== undefined) { + return `[${event.error.code}] ${event.error.message}`; + } + if (event.reason === 'cancelled') { + return 'Interrupted by user'; + } + return `BTW turn ended with reason: ${event.reason}`; +} diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 75473d7a..b3691726 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -15,6 +15,7 @@ import { formatErrorMessage } from '../utils/event-payload'; import type { ImageAttachmentStore } from '../utils/image-attachment-store'; import type { PendingExit } from '../types'; import type { TUIState } from '../tui-state'; +import type { BtwPanelController } from './btw-panel'; export interface EditorKeyboardHost { state: TUIState; @@ -22,6 +23,7 @@ export interface EditorKeyboardHost { cancelInFlight: (() => void) | undefined; handleUserInput(text: string): void; + readonly btwPanelController: BtwPanelController; steerMessage(session: Session, input: string[]): void; recallLastQueued(): string | undefined; showError(msg: string): void; @@ -73,6 +75,15 @@ export class EditorKeyboardController { return; } + if (host.btwPanelController.cancelRunning()) { + this.clearPendingExit(); + return; + } + if (host.btwPanelController.closeOrCancel()) { + this.clearPendingExit(); + return; + } + if (host.state.appState.streamingPhase !== 'idle') { this.clearPendingExit(); this.cancelCurrentStream(); @@ -110,6 +121,9 @@ export class EditorKeyboardController { this.cancelCurrentCompaction(); return; } + if (host.btwPanelController.closeOrCancel()) { + return; + } if (host.state.appState.streamingPhase !== 'idle') { this.cancelCurrentStream(); } @@ -177,6 +191,7 @@ export class EditorKeyboardController { }; editor.onUpArrowEmpty = () => { + if (host.btwPanelController.scroll('up')) return true; if (host.state.appState.streamingPhase === 'idle' && !host.state.appState.isCompacting) return false; const recalled = host.recallLastQueued(); if (recalled !== undefined) { @@ -188,6 +203,8 @@ export class EditorKeyboardController { return false; }; + editor.onDownArrowEmpty = () => host.btwPanelController.scroll('down'); + editor.onPasteImage = async () => this.handleClipboardImagePaste(); } diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 183c6c01..61770d81 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -62,6 +62,7 @@ import { setProcessTitle } from '../utils/proctitle'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; import { nextTranscriptId } from '../utils/transcript-id'; +import type { BtwPanelController } from './btw-panel'; import type { StreamingUIController } from './streaming-ui'; import type { TasksBrowserController } from './tasks-browser'; import type { @@ -92,6 +93,7 @@ export interface SessionEventHost { appendTranscriptEntry(entry: TranscriptEntry): void; sendQueuedMessage(session: Session, item: QueuedMessage): void; shiftQueuedMessage(): QueuedMessage | undefined; + readonly btwPanelController: BtwPanelController; readonly tasksBrowserController: TasksBrowserController; } @@ -234,8 +236,11 @@ export class SessionEventHandler { if (subagentId === MAIN_AGENT_ID) return false; const { streamingUI } = this.host; + if (this.host.btwPanelController.routeEvent(event)) return true; + const info = this.subagentInfo.get(subagentId); - if (info === undefined || info.parentToolCallId.length === 0) return true; + if (info === undefined) return true; + if (info.parentToolCallId.length === 0) return true; const { parentToolCallId } = info; const sourceName = info.name; const toolCall = streamingUI.getToolComponent(parentToolCallId); diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index daa98c4a..ec026115 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -58,6 +58,7 @@ import { HelpPanelComponent } from './components/dialogs/help-panel'; import { QuestionDialogComponent } from './components/dialogs/question-dialog'; import { SessionPickerComponent } from './components/dialogs/session-picker'; import { AuthFlowController } from './controllers/auth-flow'; +import { BtwPanelController } from './controllers/btw-panel'; import { EditorKeyboardController } from './controllers/editor-keyboard'; import { SessionEventHandler } from './controllers/session-event-handler'; import * as slashCommands from './commands/dispatch'; @@ -216,6 +217,7 @@ export class KimiTUI { private lastHistoryContent: string | undefined; readonly streamingUI: StreamingUIController; readonly authFlow: AuthFlowController; + readonly btwPanelController: BtwPanelController; readonly sessionEventHandler: SessionEventHandler; readonly sessionReplay: SessionReplayRenderer; readonly tasksBrowserController: TasksBrowserController; @@ -287,6 +289,7 @@ export class KimiTUI { ); this.streamingUI = new StreamingUIController(this); this.authFlow = new AuthFlowController(this); + this.btwPanelController = new BtwPanelController(this); this.sessionEventHandler = new SessionEventHandler(this); this.sessionReplay = new SessionReplayRenderer(this); this.tasksBrowserController = new TasksBrowserController(this); @@ -649,6 +652,7 @@ export class KimiTUI { ui.addChild(this.state.activityContainer); ui.addChild(this.state.todoPanelContainer); ui.addChild(this.state.queueContainer); + ui.addChild(this.state.btwPanelContainer); ui.addChild(this.state.editorContainer); // Footer is mounted later (mountFooter), not here. } @@ -683,6 +687,7 @@ export class KimiTUI { } sendNormalUserInput(text: string): void { + if (this.btwPanelController.sendUserInput(text)) return; if (this.state.appState.model.trim().length === 0) { this.showError(LLM_NOT_SET_MESSAGE); return; @@ -1098,6 +1103,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.resetRuntimeState(); this.tasksBrowserController.close(); + this.btwPanelController.clear(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); this.streamingUI.setTurnId(undefined); @@ -1351,6 +1357,7 @@ export class KimiTUI { this.streamingUI.resetToolUi(); this.sessionEventHandler.stopAllMcpServerStatusSpinners(); this.state.transcriptContainer.clear(); + this.btwPanelController.clear(); this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); @@ -1539,10 +1546,9 @@ export class KimiTUI { updateEditorBorderHighlight(text?: string): void { const trimmed = (text ?? this.state.editor.getText()).trimStart(); - const colorToken = - this.state.appState.planMode || trimmed.startsWith('/') - ? this.state.theme.colors.primary - : this.state.theme.colors.border; + const highlighted = this.state.appState.planMode || trimmed.startsWith('/'); + const colorToken = highlighted ? this.state.theme.colors.primary : this.state.theme.colors.border; + this.state.editor.borderHighlighted = highlighted; this.state.editor.borderColor = (s: string) => chalk.hex(colorToken)(s); this.state.ui.requestRender(); } diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 1fb1f444..41c71463 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -32,6 +32,7 @@ export interface TUIState { todoPanelContainer: Container; todoPanel: TodoPanelComponent; queueContainer: Container; + btwPanelContainer: Container; editorContainer: Container; footer: FooterComponent; editor: CustomEditor; @@ -64,6 +65,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanel = new TodoPanelComponent(theme.colors); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editorContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const editor = new CustomEditor(ui, theme.colors); const footer = new FooterComponent({ ...initialAppState }, theme.colors, () => { @@ -78,6 +80,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { todoPanelContainer, todoPanel, queueContainer, + btwPanelContainer, editorContainer, footer, editor, diff --git a/apps/kimi-code/test/tui/commands/registry.test.ts b/apps/kimi-code/test/tui/commands/registry.test.ts index 8d338a36..b67aaff6 100644 --- a/apps/kimi-code/test/tui/commands/registry.test.ts +++ b/apps/kimi-code/test/tui/commands/registry.test.ts @@ -32,6 +32,7 @@ describe('built-in slash command registry', () => { expect(findBuiltInSlashCommand('quit')?.name).toBe('exit'); expect(findBuiltInSlashCommand('q')?.name).toBe('exit'); expect(findBuiltInSlashCommand('clear')?.name).toBe('new'); + expect(findBuiltInSlashCommand('btw')?.name).toBe('btw'); expect(findBuiltInSlashCommand('mcp')?.name).toBe('mcp'); expect(findBuiltInSlashCommand('status')?.name).toBe('status'); expect(findBuiltInSlashCommand('usage')?.aliases).not.toContain('status'); @@ -97,6 +98,7 @@ describe('built-in slash command registry', () => { expect(names).toEqual( expect.arrayContaining([ 'compact', + 'btw', 'editor', 'exit', 'export-debug-zip', diff --git a/apps/kimi-code/test/tui/commands/resolve.test.ts b/apps/kimi-code/test/tui/commands/resolve.test.ts index 8a680ccf..ad19cdc5 100644 --- a/apps/kimi-code/test/tui/commands/resolve.test.ts +++ b/apps/kimi-code/test/tui/commands/resolve.test.ts @@ -36,6 +36,11 @@ describe('resolveSlashCommandInput', () => { args: 'New title', }); expect(resolve('/init')).toMatchObject({ kind: 'builtin', name: 'init', args: '' }); + expect(resolve('/btw what are you doing?')).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: 'what are you doing?', + }); }); it('blocks idle-only built-ins while streaming', () => { @@ -95,6 +100,11 @@ describe('resolveSlashCommandInput', () => { name: 'mcp', args: '', }); + expect(resolve('/btw side question', { isStreaming: true })).toMatchObject({ + kind: 'builtin', + name: 'btw', + args: 'side question', + }); }); it('blocks plan clear while compacting because it is idle-only', () => { diff --git a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts index 11801aa7..d137558b 100644 --- a/apps/kimi-code/test/tui/components/editor/side-borders.test.ts +++ b/apps/kimi-code/test/tui/components/editor/side-borders.test.ts @@ -10,6 +10,14 @@ describe('wrapWithSideBorders', () => { expect(out[0]).toBe('╭────────╮'); }); + it('turns the top horizontal border into connectors when connected above', () => { + const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id, { + connectedAbove: true, + }); + expect(out[0]).toBe('├────────┤'); + expect(out[2]).toBe('╰────────╯'); + }); + it('turns the bottom horizontal border into a ╰…╯ run', () => { const out = wrapWithSideBorders(['──────────', ' hi ', '──────────'], id); expect(out[2]).toBe('╰────────╯'); diff --git a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts index b5924ca1..4655b342 100644 --- a/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-message-flow.test.ts @@ -12,6 +12,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { ApprovalPanelComponent } from '#/tui/components/dialogs/approval-panel'; import { KIMI_CODE_PLUGIN_MARKETPLACE_URL } from '#/constant/app'; +import { BtwPanelComponent } from '#/tui/components/panes/btw-panel'; import { WelcomeComponent } from '#/tui/components/chrome/welcome'; import { ModelSelectorComponent } from '#/tui/components/dialogs/model-selector'; import { TabbedModelSelectorComponent } from '#/tui/components/dialogs/tabbed-model-selector'; @@ -113,6 +114,7 @@ function makeSession(overrides: Record = {}) { prompt: vi.fn(async () => {}), steer: vi.fn(async () => {}), init: vi.fn(async () => {}), + startBtw: vi.fn(async () => 'agent-btw'), undoHistory: vi.fn(async () => {}), cancel: vi.fn(async () => {}), cancelCompaction: vi.fn(async () => {}), @@ -243,6 +245,37 @@ function renderTranscript(driver: MessageDriver): string { return driver.state.transcriptContainer.render(120).join('\n'); } +function renderBtwPanel(driver: MessageDriver): string { + return driver.state.btwPanelContainer.render(120).join('\n'); +} + +function getMountedBtwPanel(driver: MessageDriver): BtwPanelComponent { + const panel = driver.state.btwPanelContainer.children.find( + (child) => child instanceof BtwPanelComponent, + ); + if (panel === undefined) throw new Error('Expected a mounted /btw panel.'); + return panel; +} + +async function openBtwPanel( + driver: MessageDriver, + session: ReturnType, + prompt = 'side question', +): Promise { + driver.handleUserInput(`/btw ${prompt}`); + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + }); +} + +function setTerminalRows(driver: MessageDriver, rows: number): void { + Object.defineProperty(driver.state.terminal, 'rows', { + configurable: true, + get: () => rows, + }); +} + function countOccurrences(haystack: string, needle: string): number { return haystack.split(needle).length - 1; } @@ -1312,6 +1345,596 @@ describe('KimiTUI message flow', () => { expect(harness.track).toHaveBeenCalledWith('init_complete', undefined); }); + it('starts /btw through a forked side agent without changing the main busy state', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + harness.track.mockClear(); + driver.state.appState.streamingPhase = 'composing'; + driver.state.livePane.mode = 'thinking'; + + driver.handleUserInput('/btw 你现在在做啥?'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledWith(); + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('你现在在做啥?'); + }); + expect(session.steer).not.toHaveBeenCalled(); + expect(driver.state.appState.streamingPhase).toBe('composing'); + expect(driver.state.livePane.mode).toBe('thinking'); + expect(harness.track).toHaveBeenCalledWith('input_command', { command: 'btw' }); + }); + + it('renders /btw output in a dedicated panel instead of an Agent tool card', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, '你现在在做啥?'); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: '正在实现 /btw 的独立面板。', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.btwPanelContainer.render(120)[0]?.trim()).toBe(''); + expect(getMountedBtwPanel(driver).isRunning()).toBe(false); + expect(driver.state.editor.focused).toBe(true); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(panel).toContain('BTW ─ Esc close'); + expect(panel).not.toContain('ctrl+o expand'); + expect(editorTopBorder.startsWith('├')).toBe(true); + expect(editorTopBorder.endsWith('┤')).toBe(true); + + driver.state.editor.handleInput('/'); + const highlightedEditorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(highlightedEditorTopBorder.startsWith('╭')).toBe(true); + expect(highlightedEditorTopBorder.endsWith('╮')).toBe(true); + expect(panel).not.toContain('BTW done'); + expect(panel).not.toContain('BTW running'); + expect(panel).not.toContain('BTW failed'); + expect(panel).not.toContain('Ask:'); + expect(panel).not.toContain('Type follow-up'); + expect(panel).toContain('Q: 你现在在做啥?'); + expect(panel).toContain('正在实现 /btw 的独立面板。'); + expect(panel).not.toContain('Agent'); + expect(transcript).not.toContain('BTW'); + expect(transcript).not.toContain('Esc close'); + expect(transcript).not.toContain('正在实现 /btw 的独立面板。'); + }); + + it('keeps the /btw panel closest to the input after later transcript output', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'side answer', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.started', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + origin: { kind: 'user' }, + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'main', + sessionId: 'ses-1', + turnId: 1, + delta: 'main answer after btw', + } as Event, + () => {}, + ); + driver.streamingUI.flushNow(); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + const rootChildren = driver.state.ui.children; + expect(rootChildren.indexOf(driver.state.btwPanelContainer)).toBe( + rootChildren.indexOf(driver.state.editorContainer) - 1, + ); + expect(transcript).toContain('main answer after btw'); + expect(transcript).not.toContain('side answer'); + expect(panel).toContain('BTW'); + expect(panel).not.toContain('BTW done'); + expect(panel).not.toContain('BTW running'); + expect(panel).not.toContain('BTW failed'); + expect(panel).toContain('side answer'); + expect(panel).not.toContain('main answer after btw'); + }); + + it('renders only the tail of /btw thinking output', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: ['line1', 'line2', 'line3', 'line4', 'line5', 'line6', 'line7'].join('\n'), + } as Event, + () => {}, + ); + + const transcript = stripSgr(renderTranscript(driver)); + const panel = stripSgr(renderBtwPanel(driver)); + expect(transcript).not.toContain('line7'); + expect(panel).not.toContain('line1'); + expect(panel).not.toContain('line5'); + expect(panel).toContain('line6'); + expect(panel).toContain('line7'); + }); + + it('renders /btw body at its actual content height when under the cap', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + const lines = getMountedBtwPanel(driver).render(80).map(stripSgr); + expect(lines).toHaveLength(3); + expect(lines.join('\n')).toContain('Q: side question'); + expect(lines.join('\n')).toContain('Waiting for answer...'); + }); + + it('keeps /btw panel height stable when final output is shorter than thinking', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'thinking.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'thinking line 1\nthinking line 2', + } as Event, + () => {}, + ); + + const mountedPanel = getMountedBtwPanel(driver); + const thinkingLines = mountedPanel.render(80).map(stripSgr); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'final answer', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const finalLines = mountedPanel.render(80).map(stripSgr); + expect(finalLines).toHaveLength(thinkingLines.length); + expect(finalLines.join('\n')).toContain('final answer'); + expect(finalLines.at(-1)).toMatch(/^│\s+│$/); + }); + + it('caps /btw height to half the terminal and supports scrolling', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + setTerminalRows(driver, 12); + await openBtwPanel(driver, session, 'question 1'); + + const panel = getMountedBtwPanel(driver); + panel.appendAnswer('answer 1'); + panel.markDone(); + for (let i = 2; i <= 8; i++) { + panel.submit(`question ${String(i)}`); + panel.appendAnswer(`answer ${String(i)}`); + panel.markDone(); + } + + const collapsed = panel.render(80).map(stripSgr); + expect(collapsed).toHaveLength(6); + expect(collapsed.join('\n')).toContain('BTW ─ Esc close · ↑↓ scroll'); + expect(collapsed.join('\n')).not.toContain('ctrl+o expand'); + expect(collapsed.join('\n')).toContain('question 8'); + expect(collapsed.join('\n')).toContain('answer 8'); + expect(collapsed.join('\n')).not.toContain('question 1'); + + driver.state.editor.setText('draft main input'); + const collapsedWithInput = panel.render(80).map(stripSgr); + expect(collapsedWithInput.join('\n')).toContain('BTW ─ Esc close'); + expect(collapsedWithInput.join('\n')).not.toContain('↑↓ scroll'); + driver.state.editor.setText(''); + + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); + for (let i = 0; i < 20; i++) { + driver.state.editor.handleInput('\u001B[A'); + } + const scrolledUp = panel.render(80).map(stripSgr); + expect(requestRender).toHaveBeenCalled(); + expect(scrolledUp.join('\n')).toContain('question 1'); + expect(scrolledUp.join('\n')).not.toContain('answer 8'); + + panel.appendAnswer('\nstreamed tail while scrolled'); + expect(panel.render(80).map(stripSgr)).toEqual(scrolledUp); + + requestRender.mockClear(); + for (let i = 0; i < 20; i++) { + driver.state.editor.handleInput('\u001B[B'); + } + const scrolledDown = panel.render(80).map(stripSgr); + expect(requestRender).toHaveBeenCalled(); + expect(scrolledDown.join('\n')).toContain('question 8'); + expect(scrolledDown.join('\n')).toContain('answer 8'); + expect(scrolledDown.join('\n')).toContain('streamed tail while scrolled'); + + setTerminalRows(driver, 4); + const tiny = panel.render(80).map(stripSgr); + expect(tiny).toHaveLength(3); + expect(tiny.join('\n')).not.toContain('ctrl+o expand'); + expect(tiny.join('\n')).toContain('answer 8'); + + requestRender.mockClear(); + driver.state.editor.onToggleToolExpand?.(); + expect(driver.state.toolOutputExpanded).toBe(true); + expect(panel.render(80).map(stripSgr)).toEqual(tiny); + }); + + it('cancels and closes a running /btw panel on Escape', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(true); + expect(driver.state.editor.focused).toBe(true); + + const requestRender = vi.mocked(driver.state.ui.requestRender); + requestRender.mockClear(); + driver.state.editor.onEscape?.(); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(requestRender.mock.calls.at(-1)).toEqual([true]); + const editorTopBorder = stripSgr(driver.state.editor.render(80)[0] ?? ''); + expect(editorTopBorder.startsWith('╭')).toBe(true); + expect(editorTopBorder.endsWith('╮')).toBe(true); + expect(driver.state.editor.focused).toBe(true); + }); + + it('cancels a running /btw panel on Ctrl-C without closing it or cancelling main streaming', async () => { + const session = makeSession(); + const { driver, harness } = await makeDriver(session); + const cancelledAgentIds: string[] = []; + session.cancel.mockImplementation(async () => { + cancelledAgentIds.push(harness.interactiveAgentId); + }); + await openBtwPanel(driver, session); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft main input'); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(true); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).toHaveBeenCalledOnce(); + expect(cancelledAgentIds).toEqual(['agent-btw']); + expect(getMountedBtwPanel(driver)).toBe(panel); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.editor.focused).toBe(true); + expect(driver.state.editor.getText()).toBe('draft main input'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + }); + + it('preserves rendered /btw output when a running panel is cancelled', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + delta: 'partial side answer', + } as Event, + () => {}, + ); + + driver.state.editor.onCtrlC?.(); + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'cancelled', + } as Event, + () => {}, + ); + + const panel = stripSgr(renderBtwPanel(driver)); + expect(panel).toContain('partial side answer'); + expect(panel).toContain('Interrupted by user'); + }); + + it('cancels a running /btw panel when starting a new session clears it', async () => { + const initialSession = makeSession({ id: 'ses-initial' }); + const nextSession = makeSession({ id: 'ses-next' }); + const createSession = vi + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValueOnce(nextSession); + const { driver, harness } = await makeDriver(initialSession, { createSession }); + const cancelledAgentIds: string[] = []; + initialSession.cancel.mockImplementation(async () => { + cancelledAgentIds.push(harness.interactiveAgentId); + }); + await openBtwPanel(driver, initialSession); + + driver.handleUserInput('/new'); + + await vi.waitFor(() => { + expect(driver.getCurrentSessionId()).toBe('ses-next'); + }); + expect(initialSession.cancel).toHaveBeenCalledOnce(); + expect(cancelledAgentIds).toEqual(['agent-btw']); + expect(nextSession.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + }); + + it('closes a completed /btw panel on Ctrl-C without cancelling main streaming', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + driver.state.appState.streamingPhase = 'waiting'; + driver.state.editor.setText('draft main input'); + + expect(getMountedBtwPanel(driver).isRunning()).toBe(false); + + driver.state.editor.onCtrlC?.(); + + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + expect(driver.state.editor.getText()).toBe('draft main input'); + expect(driver.state.appState.streamingPhase).toBe('waiting'); + }); + + it('closes a completed /btw panel on Escape without cancelling it', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(false); + expect(driver.state.editor.focused).toBe(true); + + driver.state.editor.onEscape?.(); + + expect(session.cancel).not.toHaveBeenCalled(); + expect(driver.state.btwPanelContainer.children).toHaveLength(0); + expect(driver.state.editor.focused).toBe(true); + }); + + it('sends follow-up /btw input through ordinary prompt on the same side agent', async () => { + const session = makeSession(); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, 'first question'); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + const panel = getMountedBtwPanel(driver); + expect(panel.isRunning()).toBe(false); + driver.handleUserInput('follow up'); + + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('follow up'); + }); + expect(session.prompt).toHaveBeenCalledTimes(2); + expect(driver.state.btwPanelContainer.children).toHaveLength(2); + expect(driver.state.editor.focused).toBe(true); + }); + + it('keeps main input pointed at /btw while the panel is open', async () => { + let resolveBtwPrompt: (() => void) | undefined; + const session = makeSession({ + prompt: vi.fn( + () => + new Promise((resolve) => { + resolveBtwPrompt = resolve; + }), + ), + }); + const { driver, harness } = await makeDriver(session); + + await openBtwPanel(driver, session, 'slow side question'); + + expect(harness.interactiveAgentId).toBe('main'); + driver.handleUserInput('follow-up while btw prompt is pending'); + driver.handleUserInput('another follow-up while btw prompt is pending'); + + expect(session.prompt).toHaveBeenCalledTimes(1); + expect(driver.state.queuedMessages).toEqual([]); + expect(driver.state.editor.getText()).toBe('another follow-up while btw prompt is pending'); + expect(stripSgr(renderTranscript(driver))).not.toContain( + 'Wait for /btw to finish before sending another question.', + ); + expect( + countOccurrences( + stripSgr(renderBtwPanel(driver)), + 'Wait for /btw to finish before sending another question.', + ), + ).toBe(2); + + driver.sessionEventHandler.handleEvent( + { + type: 'turn.ended', + agentId: 'agent-btw', + sessionId: 'ses-1', + turnId: 0, + reason: 'completed', + } as Event, + () => {}, + ); + + expect(stripSgr(renderBtwPanel(driver))).not.toContain( + 'Wait for /btw to finish before sending another question.', + ); + + resolveBtwPrompt?.(); + }); + + it('replaces a running /btw panel when another /btw command is submitted', async () => { + const session = makeSession({ + startBtw: vi.fn() + .mockResolvedValueOnce('agent-btw-1') + .mockResolvedValueOnce('agent-btw-2'), + }); + const { driver } = await makeDriver(session); + await openBtwPanel(driver, session, 'first question'); + + const firstPanel = getMountedBtwPanel(driver); + expect(firstPanel.isRunning()).toBe(true); + + driver.handleUserInput('/btw second question'); + + await vi.waitFor(() => { + expect(session.startBtw).toHaveBeenCalledTimes(2); + }); + await vi.waitFor(() => { + expect(session.prompt).toHaveBeenCalledWith('second question'); + }); + + const secondPanel = getMountedBtwPanel(driver); + expect(secondPanel).not.toBe(firstPanel); + expect(session.cancel).toHaveBeenCalledTimes(1); + expect(session.prompt).toHaveBeenCalledTimes(2); + + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw-1', + sessionId: 'ses-1', + turnId: 0, + delta: 'answer from old side agent', + } as Event, + () => {}, + ); + driver.sessionEventHandler.handleEvent( + { + type: 'assistant.delta', + agentId: 'agent-btw-2', + sessionId: 'ses-1', + turnId: 1, + delta: 'answer from new side agent', + } as Event, + () => {}, + ); + + const renderedPanel = stripSgr(renderBtwPanel(driver)); + expect(renderedPanel).not.toContain('answer from old side agent'); + expect(renderedPanel).toContain('answer from new side agent'); + }); + + it('does not run /btw without a question or selected model', async () => { + const { driver, session } = await makeDriver(); + + driver.handleUserInput('/btw'); + expect(session.startBtw).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('Usage: /btw '); + + driver.state.appState.model = ''; + driver.handleUserInput('/btw 现在在做什么?'); + + expect(session.startBtw).not.toHaveBeenCalled(); + expect(stripSgr(renderTranscript(driver))).toContain('LLM not set'); + }); + it('queues Ctrl-S input instead of steering while /init is running', async () => { let resolveInit: (() => void) | undefined; const session = makeSession({ @@ -2273,7 +2896,7 @@ describe('KimiTUI message flow', () => { const transcript = stripSgr(renderTranscript(driver)); expect(transcript).toContain('t7'); - expect(transcript).not.toContain('ctrl+o to expand'); + expect(transcript).not.toContain('ctrl+o expand'); }); it('renders hook results without XML tags', async () => { diff --git a/docs/en/guides/interaction.md b/docs/en/guides/interaction.md index 39552b86..5df274fb 100644 --- a/docs/en/guides/interaction.md +++ b/docs/en/guides/interaction.md @@ -14,7 +14,7 @@ Anything starting with `/` is recognized as a slash command, covering session ma Type `/` to open the command completion menu, which also includes commands from [Agent Skills](../customization/skills.md). If a skill name collides with a built-in command, use the full `/skill:` form. Press `Esc` to dismiss. -Some commands are only available while the agent is idle; interrupt the current turn first if the agent is streaming. Mode-switch and query commands such as `/yolo`, `/plan`, and `/help` are always available. +Some commands are only available while the agent is idle; interrupt the current turn first if the agent is streaming. Mode-switch and query commands such as `/yolo`, `/plan`, `/help`, and `/btw` are always available. Type `@` to trigger file-path completion. Selecting an entry inserts the relative path, and the agent can read the file directly. Dot-prefixed directories are hidden by default; write `@.github/` to include them explicitly. diff --git a/docs/en/reference/slash-commands.md b/docs/en/reference/slash-commands.md index 9b8c2e8d..be9952b5 100644 --- a/docs/en/reference/slash-commands.md +++ b/docs/en/reference/slash-commands.md @@ -108,6 +108,7 @@ Prompt mode exits with code `0` when the goal completes, `3` when it blocks, and | Command | Alias | Description | Always available | | --- | --- | --- | --- | | `/help` | `/h`, `/?` | Show keyboard shortcuts and all available commands. | Yes | +| `/btw ` | — | Open a side-channel conversation in a forked subagent without steering the current main agent turn. | Yes | | `/usage` | — | Show token usage, context consumption, and quota information. | Yes | | `/status` | — | Show the current session runtime status, including version, model, working directory, and permission mode. | Yes | | `/mcp` | — | List the MCP servers in the current session and their connection status. | Yes | diff --git a/docs/zh/guides/interaction.md b/docs/zh/guides/interaction.md index 26bd6e5a..d71c3837 100644 --- a/docs/zh/guides/interaction.md +++ b/docs/zh/guides/interaction.md @@ -14,7 +14,7 @@ Kimi Code CLI 以交互式 TUI 运行在终端中,核心是一个输入框、 输入 `/` 后会弹出命令补全菜单,也包括来自 [Agent Skills](../customization/skills.md) 的命令。若 skill 与内置命令重名,需要用完整形式 `/skill:` 调用。按 `Esc` 关闭菜单。 -部分命令仅在 Agent 空闲时可用,流式输出中需先中断才能调用。`/yolo`、`/plan`、`/help` 等模式切换类命令则始终可用。 +部分命令仅在 Agent 空闲时可用,流式输出中需先中断才能调用。`/yolo`、`/plan`、`/help`、`/btw` 等模式切换和查询类命令则始终可用。 键入 `@` 触发文件路径补全,选中后插入相对路径,Agent 可直接读取对应文件。以点开头的目录默认隐藏,如需可显式写成 `@.github/`。 diff --git a/docs/zh/reference/slash-commands.md b/docs/zh/reference/slash-commands.md index 570feddf..2c05eb81 100644 --- a/docs/zh/reference/slash-commands.md +++ b/docs/zh/reference/slash-commands.md @@ -108,6 +108,7 @@ Prompt 模式在 goal 完成时以退出码 `0` 退出,在 blocked 时以 `3` | 命令 | 别名 | 说明 | 随时可用 | | --- | --- | --- | --- | | `/help` | `/h`、`/?` | 显示快捷键和所有可用命令。 | 是 | +| `/btw <问题>` | — | 在 fork 出的子 Agent 中打开旁路对话,不改变当前主 Agent 轮次。 | 是 | | `/usage` | — | 显示 token 用量、上下文占用以及配额信息。 | 是 | | `/status` | — | 显示当前会话运行时状态,包括版本、模型、工作目录和权限模式等。 | 是 | | `/mcp` | — | 列出当前会话中的 MCP server 及其连接状态。 | 是 | diff --git a/packages/agent-core/src/agent/context/index.ts b/packages/agent-core/src/agent/context/index.ts index 386dccd9..1beebd65 100644 --- a/packages/agent-core/src/agent/context/index.ts +++ b/packages/agent-core/src/agent/context/index.ts @@ -5,7 +5,7 @@ import { ErrorCodes, KimiError } from '../../errors'; import type { ExecutableToolResult, LoopRecordedEvent } from '../../loop'; import { estimateTokensForMessages } from '../../utils/tokens'; import type { CompactionResult } from '../compaction'; -import { project } from './projector'; +import { project, trimTrailingOpenToolExchange } from './projector'; import { USER_PROMPT_ORIGIN, type AgentContextData, @@ -175,6 +175,11 @@ export class ContextMemory { return this.project(this.history); } + useProjectedHistoryFrom(source: ContextMemory): void { + this.clear(); + this.pushHistory(...trimTrailingOpenToolExchange(source.project(source.history))); + } + appendLoopEvent(event: LoopRecordedEvent): void { this.agent.records.logRecord({ type: 'context.append_loop_event', diff --git a/packages/agent-core/src/agent/context/projector.ts b/packages/agent-core/src/agent/context/projector.ts index fc0ba2e5..e0ae9997 100644 --- a/packages/agent-core/src/agent/context/projector.ts +++ b/packages/agent-core/src/agent/context/projector.ts @@ -70,3 +70,23 @@ function stripContextMetadata(message: ContextMessage): Message { partial: message.partial, }; } + +export function trimTrailingOpenToolExchange(history: readonly Message[]): Message[] { + let lastNonToolIndex = history.length - 1; + while (lastNonToolIndex >= 0 && history[lastNonToolIndex]?.role === 'tool') { + lastNonToolIndex -= 1; + } + + const assistant = history[lastNonToolIndex]; + if (assistant === undefined) return []; + if (assistant.role !== 'assistant' || assistant.toolCalls.length === 0) return [...history]; + + const trailingToolCallIds = new Set( + history + .slice(lastNonToolIndex + 1) + .map((message) => message.toolCallId) + .filter((toolCallId): toolCallId is string => typeof toolCallId === 'string'), + ); + const closed = assistant.toolCalls.every((toolCall) => trailingToolCallIds.has(toolCall.id)); + return closed ? [...history] : history.slice(0, lastNonToolIndex); +} diff --git a/packages/agent-core/src/agent/index.ts b/packages/agent-core/src/agent/index.ts index 8ee06f26..ac0056a3 100644 --- a/packages/agent-core/src/agent/index.ts +++ b/packages/agent-core/src/agent/index.ts @@ -387,6 +387,7 @@ export class Agent { } this.skills.activate(payload); }, + startBtw: () => this.subagentHost!.startBtw(), getBackgroundOutput: (payload) => this.background.readOutput(payload.taskId, payload.tail), getContext: () => this.context.data(), getConfig: () => this.config.data(), diff --git a/packages/agent-core/src/agent/permission/index.ts b/packages/agent-core/src/agent/permission/index.ts index d73df034..4df71cc9 100644 --- a/packages/agent-core/src/agent/permission/index.ts +++ b/packages/agent-core/src/agent/permission/index.ts @@ -10,7 +10,7 @@ import type { PermissionPolicyContext, PermissionPolicyResolution, PermissionPolicyResult, - PermissionRule + PermissionRule, } from './types'; export * from './types'; @@ -26,11 +26,11 @@ interface PolicyEvaluation { } export class PermissionManager { - rules: PermissionRule[] = []; + readonly policies: PermissionPolicy[]; + readonly rules: PermissionRule[] = []; private modeOverride: PermissionMode | undefined; private readonly parent: PermissionManager | undefined; private readonly localSessionApprovalRulePatterns = new Set(); - private readonly policies: readonly PermissionPolicy[]; constructor( protected readonly agent: Agent, diff --git a/packages/agent-core/src/agent/permission/policies/deny-all.ts b/packages/agent-core/src/agent/permission/policies/deny-all.ts new file mode 100644 index 00000000..3b5bf500 --- /dev/null +++ b/packages/agent-core/src/agent/permission/policies/deny-all.ts @@ -0,0 +1,15 @@ +import type { PermissionPolicy, PermissionPolicyResult } from '../types'; + +export class DenyAllPermissionPolicy implements PermissionPolicy { + readonly name = 'deny-all'; + + constructor(private readonly message: string) {} + + evaluate(): PermissionPolicyResult { + return { + kind: 'deny', + message: this.message, + reason: { source: 'side_question' }, + }; + } +} diff --git a/packages/agent-core/src/agent/permission/policies/index.ts b/packages/agent-core/src/agent/permission/policies/index.ts index 38e0bb9d..5cc0386c 100644 --- a/packages/agent-core/src/agent/permission/policies/index.ts +++ b/packages/agent-core/src/agent/permission/policies/index.ts @@ -23,7 +23,7 @@ import { import { YoloModeApprovePermissionPolicy } from './yolo-mode-approve'; /** Permission policies run in order; the first non-undefined result wins. */ -export function createPermissionDecisionPolicies(agent: Agent): readonly PermissionPolicy[] { +export function createPermissionDecisionPolicies(agent: Agent): PermissionPolicy[] { return [ // PreToolUse hook returned a block → deny. new PreToolCallHookPermissionPolicy(agent), diff --git a/packages/agent-core/src/agent/tool/index.ts b/packages/agent-core/src/agent/tool/index.ts index ff8af558..845d2d8f 100644 --- a/packages/agent-core/src/agent/tool/index.ts +++ b/packages/agent-core/src/agent/tool/index.ts @@ -34,6 +34,7 @@ export class ToolManager { protected builtinTools: Map = new Map(); protected readonly userTools: Map = new Map(); protected readonly mcpTools: Map = new Map(); + private loopToolsOverride: readonly ExecutableTool[] | undefined; /** server name → list of qualified tool names registered for that server. */ protected readonly mcpToolsByServer: Map = new Map(); protected enabledTools: Set = new Set(); @@ -313,6 +314,10 @@ export class ToolManager { this.mcpAccessPatterns = names.filter((name) => isMcpToolName(name)); } + copyLoopToolsFrom(source: ToolManager): void { + this.loopToolsOverride = source.loopTools; + } + private isMcpToolEnabled(name: string): boolean { return this.mcpAccessPatterns.some((pattern) => picomatch.isMatch(name, pattern)); } @@ -438,6 +443,7 @@ export class ToolManager { } get loopTools(): readonly ExecutableTool[] { + if (this.loopToolsOverride !== undefined) return this.loopToolsOverride; const mcpNames = [...this.mcpTools.keys()].filter((name) => this.isMcpToolEnabled(name)); // Mutation goal tools are only offered to the model while a goal exists. const hideGoalMutationTools = (this.agent.goals?.getGoal().goal ?? null) === null; diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 814dd70d..3ea43cea 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -317,6 +317,7 @@ export interface AgentAPI { stopBackground: (payload: StopBackgroundPayload) => void; clearContext: (payload: EmptyPayload) => void; activateSkill: (payload: ActivateSkillPayload) => void; + startBtw: (payload: EmptyPayload) => string; getBackgroundOutput: (payload: GetBackgroundOutputPayload) => string; getContext: (payload: EmptyPayload) => AgentContextData; getConfig: (payload: EmptyPayload) => AgentConfigData; diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 750af181..d6d02189 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -579,6 +579,10 @@ export class KimiCore implements PromisableMethods { return this.sessionApi(sessionId).generateAgentsMd(payload); } + startBtw({ sessionId, ...payload }: SessionAgentPayload): Promise { + return this.sessionApi(sessionId).startBtw(payload); + } + createGoal({ sessionId, ...payload diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 1abe7605..cfd8adb0 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -76,6 +76,12 @@ export interface AgentMeta { readonly parentAgentId: string | null; } +export interface CreateAgentOptions { + readonly profile?: ResolvedAgentProfile; + readonly parentAgentId?: string; + readonly persistMetadata?: boolean; +} + export interface SessionMeta { createdAt: string; updatedAt: string; @@ -170,7 +176,9 @@ export class Session { } async createMain() { - const { agent } = await this.createAgent({ type: 'main' }, DEFAULT_AGENT_PROFILES['agent']); + const { agent } = await this.createAgent({ type: 'main' }, { + profile: DEFAULT_AGENT_PROFILES['agent'], + }); // The main-agent audit sink now exists; flush any goal records queued before it. this.goals.flushPendingRecords(); await this.triggerSessionStart('startup'); @@ -246,25 +254,27 @@ export class Session { async createAgent( config: Partial, - profile?: ResolvedAgentProfile, - parentAgentId?: string | undefined, + options: CreateAgentOptions = {}, ): Promise<{ readonly id: string; readonly agent: Agent }> { await this.skillsReady; const type = config.type ?? 'main'; const id = type === 'main' ? 'main' : this.nextGeneratedAgentId(); const homedir = config.homedir ?? join(this.options.homedir, 'agents', id); - const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId ?? null); - if (profile) { - await this.bootstrapAgentProfile(agent, profile); + const parentAgentId = options.parentAgentId ?? null; + const agent = this.instantiateAgent(id, homedir, type, config, parentAgentId); + if (options.profile) { + await this.bootstrapAgentProfile(agent, options.profile); } this.agents.set(id, agent); - this.metadata.agents[id] = { - homedir, - type, - parentAgentId: parentAgentId ?? null, - }; - void this.writeMetadata(); + if (options.persistMetadata !== false) { + this.metadata.agents[id] = { + homedir, + type, + parentAgentId, + }; + void this.writeMetadata(); + } return { id, agent }; } diff --git a/packages/agent-core/src/session/rpc.ts b/packages/agent-core/src/session/rpc.ts index 2e6c0a5e..609a340c 100644 --- a/packages/agent-core/src/session/rpc.ts +++ b/packages/agent-core/src/session/rpc.ts @@ -108,6 +108,10 @@ export class SessionAPIImpl implements PromisableMethods { return this.session.generateAgentsMd(); } + async startBtw({ agentId, ...payload }: AgentScopedPayload): Promise { + return this.getAgent(agentId).startBtw(payload); + } + // --- Goal lifecycle (delegates to the session goal store) ------------- createGoal(payload: CreateGoalPayload) { diff --git a/packages/agent-core/src/session/subagent-host.ts b/packages/agent-core/src/session/subagent-host.ts index cabc8e10..2ea9a436 100644 --- a/packages/agent-core/src/session/subagent-host.ts +++ b/packages/agent-core/src/session/subagent-host.ts @@ -2,6 +2,8 @@ import type { TokenUsage } from '@moonshot-ai/kosong'; import type { Agent } from '../agent'; import type { PromptOrigin } from '../agent/context'; +import { DenyAllPermissionPolicy } from '../agent/permission/policies/deny-all'; +import { InMemoryAgentRecordPersistence } from '../agent/records'; import type { LoopTurnStopReason } from '../loop'; import { DEFAULT_AGENT_PROFILES, @@ -23,6 +25,22 @@ const SUMMARY_CONTINUATION_ATTEMPTS = 1; const HOOK_TEXT_PREVIEW_LENGTH = 500; const SUBAGENT_MAX_TOKENS_ERROR = 'Subagent turn failed before completing its final summary: reason=max_tokens'; +const TOOL_CALL_DISABLED_MESSAGE = + 'Tool calls are disabled for side questions. Answer with text only.'; +const SIDE_QUESTION_SYSTEM_REMINDER = ` +This is a side-channel conversation with the user. You should answer user questions directly based on what you already know. + +IMPORTANT: +- You are a separate, lightweight instance. +- The main agent continues independently; do not reference being interrupted. +- Do not call any tools. All tool calls are disabled and will be rejected. + Even though tool definitions are visible in this request, they exist only + for technical reasons (prompt cache). You must not use them. +- Respond only with text based on what you already know from the conversation + and this side-channel conversation. +- Follow-up turns may happen in this side-channel conversation. +- If you do not know the answer, say so directly. +`; type RunSubagentOptions = { readonly parentToolCallId: string; @@ -71,8 +89,7 @@ export class SessionSubagentHost { const profile = this.resolveProfile(parent, profileName); const { id, agent } = await this.session.createAgent( { type: 'sub', generate: parent.rawGenerate }, - undefined, - this.ownerAgentId, + { parentAgentId: this.ownerAgentId }, ); const controller = new AbortController(); const unlinkAbortSignal = linkAbortSignal(options.signal, controller); @@ -167,6 +184,32 @@ export class SessionSubagentHost { }; } + async startBtw(): Promise { + const parent = this.session.agents.get(this.ownerAgentId)!; + const { id, agent: child } = await this.session.createAgent( + { + type: 'sub', + generate: parent.rawGenerate, + persistence: new InMemoryAgentRecordPersistence(), + }, + { parentAgentId: this.ownerAgentId, persistMetadata: false }, + ); + + child.config.update({ + modelAlias: parent.config.modelAlias, + thinkingLevel: parent.config.thinkingLevel, + systemPrompt: parent.config.systemPrompt, + }); + child.tools.copyLoopToolsFrom(parent.tools); + child.context.useProjectedHistoryFrom(parent.context); + child.context.appendSystemReminder(SIDE_QUESTION_SYSTEM_REMINDER.trim(), { + kind: 'system_trigger', + name: 'btw', + }); + child.permission.policies.unshift(new DenyAllPermissionPolicy(TOOL_CALL_DISABLED_MESSAGE)); + return id; + } + cancelAll(reason: unknown = userCancellationReason()): void { const foregroundChildren = Array.from(this.activeChildren).filter( ([, child]) => !child.runInBackground, diff --git a/packages/agent-core/test/harness/goal-session.test.ts b/packages/agent-core/test/harness/goal-session.test.ts index 9c906143..27517817 100644 --- a/packages/agent-core/test/harness/goal-session.test.ts +++ b/packages/agent-core/test/harness/goal-session.test.ts @@ -97,7 +97,10 @@ async function setupSession( hooks, }), ); - const { agent } = await session.createAgent({ type: 'main', generate: generate ?? scripted.generate }, goalProfile(tools)); + const { agent } = await session.createAgent( + { type: 'main', generate: generate ?? scripted.generate }, + { profile: goalProfile(tools) }, + ); agent.config.update({ modelAlias: 'mock-model', thinkingLevel: 'off' }); agent.permission.setMode('yolo'); return { session, agent, scripted }; diff --git a/packages/agent-core/test/session/init.test.ts b/packages/agent-core/test/session/init.test.ts index 3a501540..210d5102 100644 --- a/packages/agent-core/test/session/init.test.ts +++ b/packages/agent-core/test/session/init.test.ts @@ -1,17 +1,19 @@ -import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; -import { dirname, join } from 'pathe'; -import { fileURLToPath } from 'node:url'; +import { join } from 'pathe'; import { testKaos } from '../fixtures/test-kaos'; -import type { ProviderConfig } from '@moonshot-ai/kosong'; +import type { ProviderConfig, ToolCall } from '@moonshot-ai/kosong'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Agent, AgentOptions } from '../../src/agent'; +import { trimTrailingOpenToolExchange } from '../../src/agent/context/projector'; import { ProviderManager } from '../../src/session/provider-manager'; import type { ResolvedAgentProfile } from '../../src/profile'; import type { SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; import { SessionAPIImpl } from '../../src/session/rpc'; +import { estimateTokensForMessages } from '../../src/utils/tokens'; import { createScriptedGenerate } from '../agent/harness/scripted-generate'; import { recordingTelemetry, type TelemetryRecord } from '../fixtures/telemetry'; @@ -52,7 +54,7 @@ describe('Session.init', () => { }); const { agent: mainAgent } = await session.createAgent( { type: 'main', generate: scripted.generate }, - testProfile(), + { profile: testProfile() }, ); mainAgent.config.update({ modelAlias: 'mock-model', @@ -172,6 +174,290 @@ describe('Session.init', () => { }, 20000); }); +describe('AgentAPI.startBtw', () => { + it('runs a side subagent from a stable parent context snapshot without writing btw history', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + + const events: Array> = []; + const scripted = createScriptedGenerate(); + const session = new Session({ + id: 'test-btw', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(events), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + }); + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate: scripted.generate }, + { profile: testProfile() }, + ); + mainAgent.config.update({ + modelAlias: 'mock-model', + thinkingLevel: 'off', + }); + mainAgent.tools.setActiveTools(['Read']); + registerLookupNoteTool(mainAgent); + mainAgent.context.appendUserMessage([{ type: 'text', text: 'Main task: implement /btw.' }]); + mainAgent.context.appendLoopEvent({ + type: 'step.begin', + uuid: 'open-step', + turnId: 'main-turn', + step: 1, + }); + mainAgent.context.appendLoopEvent({ + type: 'tool.call', + uuid: 'open-call', + turnId: 'main-turn', + step: 1, + stepUuid: 'open-step', + toolCallId: 'call-open', + name: 'Read', + args: { path: 'src/main.ts' }, + }); + events.length = 0; + const summary = 'Main agent is implementing /btw.'; + scripted.mockNextResponse({ type: 'text', text: summary }); + + try { + const api = new SessionAPIImpl(session); + const agentId = await api.startBtw({ agentId: 'main' }); + expect(agentId).toBe('agent-0'); + expect(scripted.calls).toHaveLength(0); + expect(session.metadata.agents[agentId]).toBeUndefined(); + const childAgent = session.agents.get(agentId); + if (childAgent === undefined) throw new Error('Expected /btw child agent'); + const inheritedHistory = trimTrailingOpenToolExchange( + mainAgent.context.project(mainAgent.context.history), + ); + expect(childAgent.context.history.slice(0, inheritedHistory.length)).toEqual(inheritedHistory); + expect(childAgent.context.tokenCount).toBe(0); + expect(childAgent.context.tokenCountWithPending).toBeGreaterThanOrEqual( + estimateTokensForMessages(inheritedHistory), + ); + + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'What are you working on right now?' }], + }); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + agentId: 'agent-0', + reason: 'completed', + }), + ); + }); + expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.started', + agentId: 'agent-0', + origin: { kind: 'user' }, + }), + ); + expect(scripted.calls).toHaveLength(1); + expect(scripted.calls[0]?.systemPrompt).toBe(''); + expect(scripted.calls[0]?.tools.map((tool) => tool.name)).toEqual([ + 'LookupNote', + 'Read', + ]); + const historyText = JSON.stringify(scripted.calls[0]?.history); + expect(historyText).toContain('Main task: implement /btw.'); + expect(historyText).toContain('This is a side-channel conversation with the user.'); + expect(historyText).toContain('All tool calls are disabled and will be rejected.'); + expect(historyText).toContain('What are you working on right now?'); + expect(historyText).not.toContain('call-open'); + expect(JSON.stringify(mainAgent.context.history)).not.toContain( + 'What are you working on right now?', + ); + expect(JSON.stringify(session.agents.get('agent-0')?.context.history)).toContain( + 'What are you working on right now?', + ); + scripted.mockNextResponse({ type: 'text', text: 'Follow-up answer from the same side agent.' }); + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'Can you say that another way?' }], + }); + await vi.waitFor(() => { + expect(scripted.calls).toHaveLength(2); + }); + const followUpHistoryText = JSON.stringify(scripted.calls[1]?.history); + expect(followUpHistoryText).toContain('What are you working on right now?'); + expect(followUpHistoryText).toContain('Can you say that another way?'); + await expect(access(join(sessionDir, 'agents', 'agent-0', 'wire.jsonl'))).rejects.toThrow(); + } finally { + await session.close(); + } + }); + + it('declares parent tools but rejects side-question tool calls before a second text turn', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + + const events: Array> = []; + const scripted = createScriptedGenerate(); + const session = new Session({ + id: 'test-btw-deny-tools', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(events), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + }); + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate: scripted.generate }, + { profile: testProfile() }, + ); + mainAgent.config.update({ + modelAlias: 'mock-model', + thinkingLevel: 'off', + }); + mainAgent.tools.setActiveTools(['Read']); + registerLookupNoteTool(mainAgent); + mainAgent.context.appendUserMessage([{ type: 'text', text: 'Main task context.' }]); + events.length = 0; + + scripted.mockNextResponse(lookupNoteCall()); + scripted.mockNextResponse({ + type: 'text', + text: 'Main agent is implementing /btw based on the existing context.', + }); + + try { + const api = new SessionAPIImpl(session); + const agentId = await api.startBtw({ agentId: 'main' }); + expect(agentId).toBe('agent-0'); + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'What are you working on right now?' }], + }); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + agentId: 'agent-0', + reason: 'completed', + }), + ); + }); + expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]); + expect(scripted.calls).toHaveLength(2); + expect(scripted.calls[0]?.systemPrompt).toBe(''); + expect(scripted.calls[1]?.systemPrompt).toBe(''); + expect(scripted.calls[0]?.tools.map((tool) => tool.name)).toEqual([ + 'LookupNote', + 'Read', + ]); + expect(scripted.calls[1]?.tools.map((tool) => tool.name)).toEqual([ + 'LookupNote', + 'Read', + ]); + expect(JSON.stringify(scripted.calls[1]?.history)).toContain( + 'Tool calls are disabled for side questions. Answer with text only.', + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'tool.result', + agentId: 'agent-0', + toolCallId: 'call_lookup_note', + isError: true, + output: 'Tool calls are disabled for side questions. Answer with text only.', + }), + ); + expect(JSON.stringify(mainAgent.context.history)).not.toContain( + 'What are you working on right now?', + ); + } finally { + await session.close(); + } + }); + + it('cancels a btw turn through the returned agent id', async () => { + const workDir = await makeTempDir(); + const sessionDir = await makeTempDir(); + + const events: Array> = []; + const generate: NonNullable = vi.fn( + async (_chat, _systemPrompt, _tools, _history, _callbacks, options) => { + const signal = options?.signal; + if (signal === undefined) { + throw new Error('Expected generate signal'); + } + return new Promise((_resolve, reject) => { + if (signal.aborted) { + reject(signal.reason); + return; + } + signal.addEventListener( + 'abort', + () => { + reject(signal.reason); + }, + { once: true }, + ); + }); + }, + ); + const session = new Session({ + id: 'test-btw-cancel', + kaos: testKaos.withCwd(workDir), + homedir: sessionDir, + rpc: createSessionRpc(events), + skills: { explicitDirs: [join(workDir, 'missing-skills')] }, + providerManager: testProviderManager(), + }); + const { agent: mainAgent } = await session.createAgent( + { type: 'main', generate }, + { profile: testProfile() }, + ); + mainAgent.config.update({ + modelAlias: 'mock-model', + thinkingLevel: 'off', + }); + events.length = 0; + + try { + const api = new SessionAPIImpl(session); + const agentId = await api.startBtw({ agentId: 'main' }); + expect(agentId).toBe('agent-0'); + await api.prompt({ + agentId, + input: [{ type: 'text', text: 'Where are things right now?' }], + }); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.started', + agentId: 'agent-0', + origin: { kind: 'user' }, + }), + ); + }); + + await api.cancel({ agentId }); + + await vi.waitFor(() => { + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.ended', + agentId: 'agent-0', + reason: 'cancelled', + }), + ); + }); + expect(events.filter((event) => String(event['type']).startsWith('subagent.'))).toEqual([]); + } finally { + await session.close(); + } + }); +}); + async function makeTempDir(): Promise { const dir = await mkdtemp(join(tmpdir(), 'kimi-core-init-')); tempDirs.push(dir); @@ -206,6 +492,30 @@ function testProfile(): ResolvedAgentProfile { }; } +function registerLookupNoteTool(agent: Agent): void { + agent.tools.registerUserTool({ + name: 'LookupNote', + description: 'Look up a note from the host application.', + parameters: { + type: 'object', + properties: { + query: { type: 'string' }, + }, + required: ['query'], + additionalProperties: false, + }, + }); +} + +function lookupNoteCall(): ToolCall { + return { + type: 'function', + id: 'call_lookup_note', + name: 'LookupNote', + arguments: JSON.stringify({ query: 'status' }), + }; +} + function createSessionRpc(events: Array>): SDKSessionRPC { return { emitEvent: vi.fn(async (event) => { diff --git a/packages/agent-core/test/session/subagent-host.test.ts b/packages/agent-core/test/session/subagent-host.test.ts index 2098dc7e..16bdf416 100644 --- a/packages/agent-core/test/session/subagent-host.test.ts +++ b/packages/agent-core/test/session/subagent-host.test.ts @@ -141,13 +141,11 @@ describe('SessionSubagentHost', () => { const parent = testAgent({ telemetry: { track: telemetryTrack } }); parent.configure(); await parent.rpc.setPermission({ mode: 'yolo' }); - parent.agent.permission.rules = [ - { - decision: 'allow', - scope: 'session-runtime', - pattern: 'Read', - }, - ]; + parent.agent.permission.rules.splice(0, parent.agent.permission.rules.length, { + decision: 'allow', + scope: 'session-runtime', + pattern: 'Read', + }); parent.newEvents(); const child = testAgent({ @@ -344,6 +342,30 @@ describe('SessionSubagentHost', () => { expect(createAgent).not.toHaveBeenCalled(); }); + it('rejects unavailable subagent profiles even when a same-named fork label exists', async () => { + const parent = testAgent(); + parent.configure(); + const createAgent = vi.fn(); + const host = new SessionSubagentHost( + { + agents: new Map([['main', parent.agent]]), + createAgent, + } as never, + 'main', + ); + + await expect( + host.spawn('btw', { + parentToolCallId: 'call_agent', + prompt: 'Answer a side question', + description: 'Side question', + runInBackground: false, + signal, + }), + ).rejects.toThrow('Subagent profile "btw" was not found'); + expect(createAgent).not.toHaveBeenCalled(); + }); + it('cancels the child turn when the caller signal aborts', async () => { const parent = testAgent(); parent.configure(); @@ -705,6 +727,7 @@ describe('SessionSubagentHost', () => { type: 'text', text: 'Resumed the subagent from its earlier context and carried the task through to completion, then reported a full and detailed technical summary so the parent agent can continue without repeating prior work.', }); + vi.mocked(collectGitContext).mockReset().mockResolvedValue(''); const session = fakeSession(parent.agent, child.agent, { 'agent-0': { @@ -915,7 +938,7 @@ describe('Session.createAgent', () => { initializeMainAgent: false, }); - const created = await session.createAgent({ type: 'main' }, contextProfile()); + const created = await session.createAgent({ type: 'main' }, { profile: contextProfile() }); expect(created.agent.config.systemPrompt).toContain('cwd=/remote/project'); expect(created.agent.config.systemPrompt).toContain('listing=└── README.md'); @@ -980,7 +1003,7 @@ describe('Session.createAgent', () => { initializeMainAgent: false, }); - const created = await session.createAgent({ type: 'main' }, contextProfile()); + const created = await session.createAgent({ type: 'main' }, { profile: contextProfile() }); expect(created.agent.config.systemPrompt).toContain('cwd=/repo/packages/app'); expect(created.agent.config.systemPrompt).toContain('listing=├── src/'); @@ -1027,14 +1050,17 @@ describe('Session.createAgent', () => { }); // Create a parent agent — it should start at the session workDir. - const parent = await session.createAgent({ type: 'main' }, contextProfile()); + const parent = await session.createAgent({ type: 'main' }, { profile: contextProfile() }); expect(parent.agent.config.systemPrompt).toContain(`cwd=${sessionWorkDir}`); // Move the parent agent to a different cwd (e.g. after a config.update replay). parent.agent.config.update({ cwd: parentWorkDir }); // Create a subagent from the moved parent. - const child = await session.createAgent({ type: 'sub' }, contextProfile(), parent.id); + const child = await session.createAgent( + { type: 'sub' }, + { profile: contextProfile(), parentAgentId: parent.id }, + ); // The subagent should inherit the parent's current cwd, not the session default. expect(child.agent.config.systemPrompt).toContain(`cwd=${parentWorkDir}`); @@ -1082,7 +1108,7 @@ describe('Session.createAgent', () => { const main = await session.createAgent({ type: 'main' }); expect(main.agent.mcp).toBe(session.mcp); - const sub = await session.createAgent({ type: 'sub' }, undefined, main.id); + const sub = await session.createAgent({ type: 'sub' }, { parentAgentId: main.id }); expect(sub.agent.mcp).toBe(session.mcp); }); }); @@ -1110,17 +1136,19 @@ function fakeSession( createAgent: vi.fn( async ( config: Parameters[0], - profile?: ResolvedAgentProfile, - parentAgentId?: string, + options: Parameters[1] = {}, ) => { agents.set('agent-0', child); - metadataAgents['agent-0'] = { - homedir: '/tmp/kimi-session/agents/agent-0', - type: config.type ?? 'main', - parentAgentId: parentAgentId ?? null, - }; - if (profile !== undefined) { - child.useProfile(profile); + const parentAgentId = options.parentAgentId ?? null; + if (options.persistMetadata !== false) { + metadataAgents['agent-0'] = { + homedir: '/tmp/kimi-session/agents/agent-0', + type: config.type ?? 'main', + parentAgentId, + }; + } + if (options.profile !== undefined) { + child.useProfile(options.profile); } return { id: 'agent-0', agent: child }; }, diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index ef2563ab..3b0f4de2 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -172,19 +172,21 @@ export abstract class SDKRpcClientBase { } async prompt(input: SessionPromptRpcInput): Promise { + const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); return rpc.prompt({ sessionId: input.sessionId, - agentId: this.interactiveAgentId, + agentId, input: input.input, }); } async steer(input: SessionPromptRpcInput): Promise { + const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); return rpc.steer({ sessionId: input.sessionId, - agentId: this.interactiveAgentId, + agentId, input: input.input, }); } @@ -194,11 +196,21 @@ export abstract class SDKRpcClientBase { return rpc.generateAgentsMd({ sessionId: input.sessionId }); } + async startBtw(input: SessionIdRpcInput): Promise { + const agentId = this.interactiveAgentId; + const rpc = await this.getRpc(); + return rpc.startBtw({ + sessionId: input.sessionId, + agentId, + }); + } + async cancel(input: SessionIdRpcInput): Promise { + const agentId = this.interactiveAgentId; const rpc = await this.getRpc(); return rpc.cancel({ sessionId: input.sessionId, - agentId: this.interactiveAgentId, + agentId, }); } diff --git a/packages/node-sdk/src/session.ts b/packages/node-sdk/src/session.ts index be224af6..ea3d63db 100644 --- a/packages/node-sdk/src/session.ts +++ b/packages/node-sdk/src/session.ts @@ -104,6 +104,11 @@ export class Session { await this.rpc.generateAgentsMd({ sessionId: this.id }); } + async startBtw(): Promise { + this.ensureOpen(); + return this.rpc.startBtw({ sessionId: this.id }); + } + async cancel(): Promise { this.ensureOpen(); await this.rpc.cancel({ sessionId: this.id }); diff --git a/packages/node-sdk/test/session-prompt-events.test.ts b/packages/node-sdk/test/session-prompt-events.test.ts index 5cf4167b..dd8f5e1e 100644 --- a/packages/node-sdk/test/session-prompt-events.test.ts +++ b/packages/node-sdk/test/session-prompt-events.test.ts @@ -309,6 +309,86 @@ describe('Session.prompt events', () => { } }); + it('starts btw through RPC as a forked subagent without prompt metadata updates', async () => { + const homeDir = await makeTempDir(); + const workDir = await makeTempDir(); + const harness = createKimiHarness({ + identity: TEST_IDENTITY, + homeDir, + }); + + try { + await configureFakeProvider(harness); + const session = await harness.createSession({ id: 'ses_btw_rpc', workDir }); + const events: Event[] = []; + const unsubscribe = session.onEvent((event) => { + events.push(event); + }); + + let done = waitForEvent(session, (event) => event.type === 'turn.ended'); + await session.prompt('main task context'); + await done; + + fakeProviderState.responseText = 'The main agent is working from the existing context.'; + events.length = 0; + done = waitForEvent( + session, + (event) => event.type === 'turn.ended' && event.agentId !== 'main', + ); + + const agentId = await session.startBtw(); + harness.interactiveAgentId = agentId; + await session.prompt('What are you working on right now?'); + await done; + unsubscribe(); + + const started = events.find( + (event) => + event.type === 'turn.started' && + event.agentId === agentId && + event.origin.kind === 'user', + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'turn.started', + sessionId: session.id, + agentId, + origin: { kind: 'user' }, + }), + ); + expect(started?.agentId).not.toBe('main'); + expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.spawned' })); + expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.completed' })); + expect(events).not.toContainEqual(expect.objectContaining({ type: 'subagent.failed' })); + expect(events).not.toContainEqual( + expect.objectContaining({ + type: 'session.meta.updated', + }), + ); + expect(fakeProviderState.calls[1]?.systemPrompt).toBe( + fakeProviderState.calls[0]?.systemPrompt, + ); + const btwHistoryText = JSON.stringify(fakeProviderState.calls[1]?.history); + expect(btwHistoryText).toContain('main task context'); + expect(btwHistoryText).toContain('What are you working on right now?'); + + const statePath = join(session.summary!.sessionDir, 'state.json'); + const state = JSON.parse(await readFile(statePath, 'utf-8')) as Record; + expect(state['lastPrompt']).toBe('main task context'); + expect(state['agents']).toMatchObject({ main: expect.any(Object) }); + expect(state['agents']).not.toHaveProperty(agentId); + + await harness.closeSession(session.id); + const resumed = await harness.resumeSession({ id: session.id }); + const resumeState = resumed.getResumeState(); + expect(resumeState?.agents).toMatchObject({ main: expect.any(Object) }); + expect(resumeState?.agents).not.toHaveProperty(agentId); + expect(resumeState?.sessionMetadata.agents).not.toHaveProperty(agentId); + } finally { + await harness.close(); + } + }); + it('rejects empty prompt input', async () => { const homeDir = await makeTempDir(); const workDir = await makeTempDir(); diff --git a/packages/node-sdk/test/session-prompt-input.test.ts b/packages/node-sdk/test/session-prompt-input.test.ts index 1874910e..6a6cf0f7 100644 --- a/packages/node-sdk/test/session-prompt-input.test.ts +++ b/packages/node-sdk/test/session-prompt-input.test.ts @@ -24,4 +24,18 @@ describe('Session.prompt input normalization', () => { input, }); }); + + it('starts btw and returns the forked agent id', async () => { + const startBtw = vi.fn(async () => 'agent-btw'); + const session = new Session({ + id: 'ses_btw_start', + workDir: '/tmp/work', + rpc: { startBtw } as unknown as SDKRpcClientBase, + }); + + await expect(session.startBtw()).resolves.toBe('agent-btw'); + expect(startBtw).toHaveBeenCalledWith({ + sessionId: 'ses_btw_start', + }); + }); });