From c193fbd80d712e5c0325d654c6bc6f16202355f0 Mon Sep 17 00:00:00 2001 From: Kaiyi Date: Fri, 4 Sep 2026 16:00:47 +0800 Subject: [PATCH] feat(kimi-code): NotifyUser tool and mid-turn update panel Add an experimental NotifyUser tool (flag notify_user, off by default) that agent-core-v2 offers only when the host declares the update_panel UI capability at bootstrap; the TUI declares it through the SDK harness options. The system prompt's guidance to send updates is injected only when the tool is active for the agent. The TUI renders the turn's updates in a panel above the editor (stacked, 8-row window, Ctrl+N pages back) that the next turn clears, and keeps a one-line card in the transcript. --- .changeset/notify-user-panel.md | 5 + apps/kimi-code/src/cli/run-shell.ts | 5 +- apps/kimi-code/src/constant/app.ts | 5 +- .../src/tui/components/chrome/notify-panel.ts | 181 ++++++++++++++++ .../tui/components/editor/custom-editor.ts | 8 + .../src/tui/components/messages/tool-call.ts | 48 ++++- apps/kimi-code/src/tui/constant/rendering.ts | 2 + .../src/tui/controllers/editor-keyboard.ts | 9 + .../tui/controllers/session-event-handler.ts | 4 + .../src/tui/controllers/session-replay.ts | 10 + .../src/tui/controllers/streaming-ui.ts | 86 +++++++- apps/kimi-code/src/tui/kimi-tui.ts | 10 + apps/kimi-code/src/tui/tui-state.ts | 10 +- .../components/editor/custom-editor.test.ts | 12 ++ .../tui/components/messages/tool-call.test.ts | 63 ++++++ .../components/panels/notify-panel.test.ts | 161 +++++++++++++++ ...sion-event-handler-background-task.test.ts | 2 + .../session-event-handler-compaction.test.ts | 2 + .../session-event-handler-goal-queue.test.ts | 2 + .../session-event-handler-notify.test.ts | 123 +++++++++++ ...ssion-event-handler-plugin-updates.test.ts | 2 + .../session-event-handler-step-retry.test.ts | 2 + .../controllers/streaming-ui-notify.test.ts | 193 ++++++++++++++++++ .../test/tui/create-tui-state.test.ts | 3 + .../test/tui/kimi-tui-message-flow.test.ts | 187 +++++++++++++++++ .../test/tui/kimi-tui-startup.test.ts | 3 +- docs/en/reference/keyboard.md | 1 + docs/en/reference/tools.md | 3 + docs/zh/reference/keyboard.md | 1 + docs/zh/reference/tools.md | 3 + .../policies/default-tool-approve.ts | 1 + .../src/agent/profile/profileService.ts | 9 + .../src/agent/tools/mainAgentOnly.ts | 2 + .../agentProfileCatalog.ts | 1 + .../app/agentProfileCatalog/profile-shared.ts | 4 + .../src/app/agentProfileCatalog/system.md | 2 +- .../src/app/bootstrap/bootstrap.ts | 5 + .../agent-core-v2/src/features/notify/flag.ts | 16 ++ .../src/features/notify/notifyFeature.ts | 24 +++ .../features/notify/notifyUserAvailability.ts | 11 + .../notify/tools/notify-user/notify-user.md | 16 ++ .../notify/tools/notify-user/notify-user.ts | 24 +++ .../tools/notify-user/notifyUserTool.ts | 38 ++++ packages/agent-core-v2/src/index.ts | 4 + .../agentLifecycle/profile/profiles.ts | 1 + .../policies/default-tool-approve.test.ts | 1 + .../profile-shared.test.ts | 10 + .../features/notify/tools/notify-user.test.ts | 125 ++++++++++++ .../agentLifecycle/agentLifecycle.test.ts | 5 + packages/node-sdk/src/sdk-rpc-client-v2.ts | 4 + packages/node-sdk/src/types.ts | 12 ++ packages/node-sdk/test/v1-v2-parity.test.ts | 7 +- 52 files changed, 1456 insertions(+), 12 deletions(-) create mode 100644 .changeset/notify-user-panel.md create mode 100644 apps/kimi-code/src/tui/components/chrome/notify-panel.ts create mode 100644 apps/kimi-code/test/tui/components/panels/notify-panel.test.ts create mode 100644 apps/kimi-code/test/tui/controllers/session-event-handler-notify.test.ts create mode 100644 apps/kimi-code/test/tui/controllers/streaming-ui-notify.test.ts create mode 100644 packages/agent-core-v2/src/features/notify/flag.ts create mode 100644 packages/agent-core-v2/src/features/notify/notifyFeature.ts create mode 100644 packages/agent-core-v2/src/features/notify/notifyUserAvailability.ts create mode 100644 packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.md create mode 100644 packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.ts create mode 100644 packages/agent-core-v2/src/features/notify/tools/notify-user/notifyUserTool.ts create mode 100644 packages/agent-core-v2/test/features/notify/tools/notify-user.test.ts diff --git a/.changeset/notify-user-panel.md b/.changeset/notify-user-panel.md new file mode 100644 index 00000000000..7b5129d27e0 --- /dev/null +++ b/.changeset/notify-user-panel.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add the experimental `NotifyUser` tool so the model can show you short progress updates while it is still working. The updates of the current turn stack up in an `Update` panel above the input box; press `Ctrl+N` to page back through earlier ones, and the panel closes when the next turn starts. TUI only; enable it with `KIMI_CODE_EXPERIMENTAL_NOTIFY_USER=1` or `[experimental] notify_user = true` in `config.toml`. diff --git a/apps/kimi-code/src/cli/run-shell.ts b/apps/kimi-code/src/cli/run-shell.ts index 4845c47a479..9669da57f5a 100644 --- a/apps/kimi-code/src/cli/run-shell.ts +++ b/apps/kimi-code/src/cli/run-shell.ts @@ -18,7 +18,7 @@ import { withTelemetryContext, } from '@moonshot-ai/kimi-telemetry'; -import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE } from '#/constant/app'; +import { CLI_SHUTDOWN_TIMEOUT_MS, CLI_UI_MODE, TUI_HOST_UI_CAPABILITIES } from '#/constant/app'; import { detectPendingMigration, resolveLegacySourceHome, sameLegacyPath } from '#/migration/index'; import type { TuiConfig } from '#/tui/config'; import { loadTuiConfig, TuiConfigParseError } from '#/tui/config'; @@ -68,6 +68,9 @@ export async function runShell( homeDir: telemetryBootstrap.homeDir, identity: createKimiCodeHostIdentity(version), skillDirs: opts.skillsDirs, + // The TUI renders the mid-turn update panel; declaring it here is what + // makes the engine offer NotifyUser to this process and to no other host. + uiCapabilities: TUI_HOST_UI_CAPABILITIES, telemetry: telemetryClient, onOAuthRefresh: (outcome) => { if (outcome.success) { diff --git a/apps/kimi-code/src/constant/app.ts b/apps/kimi-code/src/constant/app.ts index c5f7bf52b09..3b6c3de9eb3 100644 --- a/apps/kimi-code/src/constant/app.ts +++ b/apps/kimi-code/src/constant/app.ts @@ -1,4 +1,4 @@ -import { ErrorCodes } from '@moonshot-ai/kimi-code-sdk'; +import { ErrorCodes, type HostUiCapability } from '@moonshot-ai/kimi-code-sdk'; import { currentKimiProfile } from '#/utils/region'; @@ -9,6 +9,9 @@ export const PROCESS_NAME = 'kimi-code'; // Used in telemetry app names and HTTP User-Agent headers. export const CLI_USER_AGENT_PRODUCT = 'kimi-code-cli'; export const CLI_UI_MODE = 'shell'; +// UI surfaces the TUI renders; declared to the engine at bootstrap so features that need a +// host-side surface (the NotifyUser update panel) are offered to this process only. +export const TUI_HOST_UI_CAPABILITIES: readonly HostUiCapability[] = ['update_panel']; // Telemetry ui_mode for the `kimi web` host. Same product // as the CLI (CLI_USER_AGENT_PRODUCT); the surface is distinguished by ui_mode. export const WEB_UI_MODE = 'web'; diff --git a/apps/kimi-code/src/tui/components/chrome/notify-panel.ts b/apps/kimi-code/src/tui/components/chrome/notify-panel.ts new file mode 100644 index 00000000000..4c9ae56ebaf --- /dev/null +++ b/apps/kimi-code/src/tui/components/chrome/notify-panel.ts @@ -0,0 +1,181 @@ +/** + * NotifyPanel — the model's mid-turn updates, shown right above the input + * area (below the Todo panel). + * + * Fed by `NotifyUser` tool calls: every call is one entry, and the entries + * of the current turn stack chronologically, newest at the bottom, each + * rendered as Markdown behind a marker (`◆` newest, `◇` earlier). The body + * is a window of {@link NOTIFY_PANEL_MAX_BODY_LINES} rows that follows the + * tail, so the latest updates are always in view; `Ctrl+N` pages up through + * earlier rows and wraps back to the tail, and a new update snaps the view + * back to the tail. The host clears the panel when the next turn starts, so + * it never mixes turns; a finished turn only dims the title. + */ + +import type { Component } from '@moonshot-ai/pi-tui'; +import { Markdown, truncateToWidth } from '@moonshot-ai/pi-tui'; +import chalk from 'chalk'; + +import { NOTIFY_PANEL_MAX_BODY_LINES } from '#/tui/constant/rendering'; +import { currentTheme } from '#/tui/theme'; +import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; + +const BODY_INDENT = ' '; +/** `◆ ` in front of an entry's first row; continuation rows get the same width of spaces. */ +const MARKER_INDENT = ' '; +const PAGE_KEY_HINT = 'ctrl+n earlier'; + +interface NotifyEntry { + readonly id: string; + text: string; +} + +export class NotifyPanelComponent implements Component { + private readonly entries: NotifyEntry[] = []; + /** First body row in view; `null` follows the tail. */ + private scrollTop: number | null = null; + private ended = false; + /** Total stacked body rows from the last render; drives paging. */ + private lastTotalRows = 0; + + /** + * Add or update an entry. A repeated `id` updates the entry in place (the + * same tool call streaming its `message`); a new id appends and snaps the + * view back to the tail. + */ + upsert(id: string, text: string): void { + const existing = this.entries.find((entry) => entry.id === id); + if (existing !== undefined) { + existing.text = text; + return; + } + this.entries.push({ id, text }); + this.scrollTop = null; + this.ended = false; + } + + clear(): void { + this.entries.length = 0; + this.scrollTop = null; + this.ended = false; + this.lastTotalRows = 0; + } + + /** + * Drop one entry — a call that was denied, failed, or never completed. The + * view snaps back to the tail. Returns false when the id is unknown. + */ + remove(id: string): boolean { + const index = this.entries.findIndex((entry) => entry.id === id); + if (index === -1) return false; + this.entries.splice(index, 1); + this.scrollTop = null; + if (this.entries.length === 0) this.lastTotalRows = 0; + return true; + } + + isEmpty(): boolean { + return this.entries.length === 0; + } + + getEntries(): readonly { readonly id: string; readonly text: string }[] { + return this.entries.map((entry) => ({ id: entry.id, text: entry.text })); + } + + /** The turn that produced these updates has ended; keep them, dim the title. */ + setEnded(ended: boolean): void { + this.ended = ended; + } + + /** True when the stacked rows overflow the window, so Ctrl+N has somewhere to go. */ + hasMorePages(): boolean { + return this.lastTotalRows > NOTIFY_PANEL_MAX_BODY_LINES; + } + + /** + * Page up one window through earlier rows; from the top, wrap back to the + * tail. Returns false when everything already fits so the key can fall + * through. + */ + nextPage(): boolean { + if (!this.hasMorePages()) return false; + const cap = NOTIFY_PANEL_MAX_BODY_LINES; + const tailStart = this.lastTotalRows - cap; + const current = this.scrollTop ?? tailStart; + if (current <= 0) { + this.scrollTop = null; + return true; + } + this.scrollTop = Math.max(0, current - cap); + return true; + } + + invalidate(): void {} + + render(width: number): string[] { + if (this.entries.length === 0) return []; + const c = currentTheme.palette; + const rows = this.renderRows(width); + this.lastTotalRows = rows.length; + + const cap = NOTIFY_PANEL_MAX_BODY_LINES; + const tailStart = Math.max(0, rows.length - cap); + let start = this.scrollTop ?? tailStart; + if (start > tailStart) { + start = tailStart; + this.scrollTop = null; + } + const shown = rows.slice(start, start + cap); + const later = rows.length - (start + shown.length); + + const lines: string[] = [chalk.hex(c.border)('─'.repeat(width)), this.renderTitle()]; + if (start > 0) { + lines.push(chalk.hex(c.textDim)(`${BODY_INDENT}… ${String(start)} earlier lines`)); + } + lines.push(...shown); + if (later > 0) { + lines.push(chalk.hex(c.textDim)(`${BODY_INDENT}… ${String(later)} later lines`)); + } + return lines.map((line) => truncateToWidth(line, width)); + } + + /** Every entry's Markdown rows, stacked in order, each behind its marker. */ + private renderRows(width: number): string[] { + const c = currentTheme.palette; + const markdownWidth = Math.max(1, width - BODY_INDENT.length - MARKER_INDENT.length); + const rows: string[] = []; + for (const [index, entry] of this.entries.entries()) { + const newest = index === this.entries.length - 1; + const marker = newest ? chalk.hex(c.primary)('◆') : chalk.hex(c.textDim)('◇'); + const body = new Markdown( + entry.text.trim(), + 0, + 0, + createMarkdownTheme(), + undefined, + createMarkdownOptions(), + ).render(markdownWidth); + for (const [i, row] of body.entries()) { + rows.push(i === 0 ? `${BODY_INDENT}${marker} ${row}` : `${BODY_INDENT}${MARKER_INDENT}${row}`); + } + } + return rows; + } + + private renderTitle(): string { + const c = currentTheme.palette; + const marker = this.ended ? '◇' : '◆'; + const label = + this.entries.length > 1 ? `Updates (${String(this.entries.length)})` : 'Update'; + const title = `${BODY_INDENT}${marker} ${label}`; + const styledTitle = this.ended + ? chalk.hex(c.textDim).bold(title) + : chalk.hex(c.primary).bold(title); + const hints: string[] = []; + if (this.hasMorePages()) hints.push(PAGE_KEY_HINT); + if (this.ended) hints.push('turn ended · next message clears'); + const hint = hints.length > 0 ? chalk.hex(c.textDim)(` · ${hints.join(' · ')}`) : ''; + return `${styledTitle}${hint}`; + } +} 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 9c8aa8b9f49..d8721551123 100644 --- a/apps/kimi-code/src/tui/components/editor/custom-editor.ts +++ b/apps/kimi-code/src/tui/components/editor/custom-editor.ts @@ -135,6 +135,8 @@ export class CustomEditor extends Editor { public onCtrlB?: () => boolean; /** Return `true` to consume Ctrl+T (the todo list had overflow to toggle); return `false`/`undefined` to fall through to the editor default. */ public onToggleTodoExpand?: () => boolean; + /** Return `true` to consume Ctrl+N (the update panel had another page); return `false`/`undefined` to fall through to the editor default. */ + public onCycleNotifyPage?: () => boolean; public onUndo?: () => void; public onTextPaste?: () => void; /** @@ -483,6 +485,12 @@ export class CustomEditor extends Editor { if (this.onToggleTodoExpand?.() === true) return; } + if (matchesKey(normalized, Key.ctrl('n'))) { + // Only consume the key when the update panel has another page to show; + // otherwise fall through to the editor default. + if (this.onCycleNotifyPage?.() === true) return; + } + if (matchesKey(normalized, 'shift+tab')) { this.onShiftTab?.(); return; diff --git a/apps/kimi-code/src/tui/components/messages/tool-call.ts b/apps/kimi-code/src/tui/components/messages/tool-call.ts index f7e0f72ad10..824cb4e5c0a 100644 --- a/apps/kimi-code/src/tui/components/messages/tool-call.ts +++ b/apps/kimi-code/src/tui/components/messages/tool-call.ts @@ -5,7 +5,7 @@ import { isAbsolute, relative, sep } from 'node:path'; -import { Container, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; +import { Container, Markdown, Spacer, Text, truncateToWidth, visibleWidth } from '@moonshot-ai/pi-tui'; import type { Component, TUI } from '@moonshot-ai/pi-tui'; import { highlightLines, langFromPath } from '#/tui/components/media/code-highlight'; import { renderDiffLinesClustered } from '#/tui/components/media/diff-preview'; @@ -26,6 +26,7 @@ import { createMarkdownTheme } from '#/tui/theme/pi-tui-theme'; import type { ToolCallBlockData, ToolResultBlockData } from '#/tui/types'; import type { TokenUsage } from '@moonshot-ai/kimi-code-sdk'; import { appendStreamingArgsPreview } from '#/tui/utils/event-payload'; +import { createMarkdownOptions } from '#/tui/utils/markdown-options'; import { decodeMcpToolName } from '#/tui/utils/mcp-tool-name'; import { isRenderCacheEnabled } from '#/tui/utils/render-cache'; import { formatTokenCount } from '#/utils/usage/usage-format'; @@ -293,7 +294,7 @@ function unescapeJsonString(s: string): string { * real newline we can highlight. Returns `undefined` if the field hasn't * started streaming yet. */ -function extractPartialStringField(text: string, key: string): string | undefined { +export function extractPartialStringField(text: string, key: string): string | undefined { const opener = new RegExp(`"${key}"\\s*:\\s*"`); const match = opener.exec(text); if (match === null) return undefined; @@ -459,6 +460,7 @@ export function extractKeyArgumentDetail( // Prefer the short `description` so the header preview never spills a // multi-line `prompt` into the TUI chrome. Agent: ['description', 'prompt'], + NotifyUser: ['message'], }; // Glob: concatenate multiple args into a single summary so the header @@ -1527,6 +1529,31 @@ export class ToolCallComponent extends Container { return `${bullet}${currentTheme.boldFg(tone, label)}`; } + if (toolCall.name === 'NotifyUser') { + // The update itself lives in the panel above the input box; the card + // is the durable trace in the transcript, so the header carries the + // first line and ctrl+o shows the whole message. + if (isTruncated) { + // max_tokens cut the arguments short: the call never ran and the + // panel entry was dropped, so the card must not read as in flight. + return `${bullet}${currentTheme.boldFg('error', 'Update cut off')}${currentTheme.dim(' (arguments truncated by max_tokens)')}`; + } + const label = isFinished + ? isError + ? 'Could not send you an update' + : 'Sent you an update' + : 'Sending you an update'; + const tone = isError ? 'error' : 'primary'; + const preview = extractKeyArgumentDetail(toolCall.name, toolCall.args, this.workspaceDir); + const head = `${bullet}${currentTheme.boldFg(tone, label)}`; + if (preview === null) return head; + return { + head: `${head}${currentTheme.dim(' (')}`, + flex: { text: preview.text, style: dimHeaderStyle, keep: 'head' }, + tail: currentTheme.dim(')'), + }; + } + if (toolCall.name === 'Bash') { // The collapsed card is this header plus one outcome row, so the header // carries the command's first line; the full command and its output only @@ -2045,6 +2072,17 @@ export class ToolCallComponent extends Container { ); return; } + if (name === 'NotifyUser') { + // Collapsed: header only (the panel shows the live text). Expanded: + // the full message as Markdown, indented under the header. + if (!this.expanded) return; + const message = str(this.toolCall.args['message']).trim(); + if (message.length === 0) return; + this.addChild( + new Markdown(message, 2, 0, this.markdownTheme, undefined, createMarkdownOptions()), + ); + return; + } if (this.result === undefined && this.toolCall.streamingArguments !== undefined) { this.buildStreamingPreview(this.toolCall.streamingArguments); return; @@ -2282,6 +2320,12 @@ export class ToolCallComponent extends Container { return; } + // NotifyUser: the message is the call's argument (rendered by + // buildCallPreview when expanded); the acknowledgement output is noise. + if (this.toolCall.name === 'NotifyUser' && !result.is_error) { + return; + } + if ( this.toolCall.name === 'AskUserQuestion' && this.toolCall.args['background'] !== true && diff --git a/apps/kimi-code/src/tui/constant/rendering.ts b/apps/kimi-code/src/tui/constant/rendering.ts index 8b20e9a95bc..98831b3ca08 100644 --- a/apps/kimi-code/src/tui/constant/rendering.ts +++ b/apps/kimi-code/src/tui/constant/rendering.ts @@ -22,6 +22,8 @@ export const RESULT_PREVIEW_LINES = 3; export const SHELL_OUTPUT_PREVIEW_LINES = 10; export const THINKING_PREVIEW_LINES = 2; export const COMMAND_PREVIEW_LINES = 10; +// Body rows the mid-turn update panel (NotifyUser) shows per page. +export const NOTIFY_PANEL_MAX_BODY_LINES = 8; // Cap on the step-retry detail line under the waiting spinner, so huge // provider error bodies (occasionally whole HTML error pages) can't flood diff --git a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts index 669065bda78..c6506316ffe 100644 --- a/apps/kimi-code/src/tui/controllers/editor-keyboard.ts +++ b/apps/kimi-code/src/tui/controllers/editor-keyboard.ts @@ -73,6 +73,8 @@ export interface EditorKeyboardHost { updateQueueDisplay(): void; toggleToolOutputExpansion(): void; toggleTodoPanelExpansion(): void; + /** Returns `true` when the update panel had another page to show. */ + cycleNotifyPanelPage(): boolean; detachCurrentForegroundTask(): void; cancelRunningShellCommand(): void; hideSessionPicker(): void; @@ -314,6 +316,13 @@ export class EditorKeyboardController { return true; }; + editor.onCycleNotifyPage = (): boolean => { + if (!host.cycleNotifyPanelPage()) return false; + this.clearPendingExit(); + host.track('shortcut_notify_page'); + return true; + }; + editor.onCtrlS = () => { if ( host.state.appState.streamingPhase === 'idle' || 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 7c35e98fa20..4680f0fda8f 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -331,6 +331,9 @@ export class SessionEventHandler { } this.clearAgentSwarmProgress(); this.host.streamingUI.resetToolUi(); + // A new turn closes the previous turn's update panel; the first + // NotifyUser call of this turn reopens it. + this.host.streamingUI.clearNotifyPanel(); this.host.streamingUI.setStep(0); this.host.patchLivePane({ mode: 'waiting', @@ -383,6 +386,7 @@ export class SessionEventHandler { this.host.streamingUI.setTodoList([]); } this.host.streamingUI.resetToolUi(); + this.host.streamingUI.markNotifyPanelEnded(); this.host.streamingUI.finalizeTurn(sendQueued); this.host.recordSessionActivity(); this.renderPendingModelBlockedFallback(); diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 542cd6b3a8b..2d1ad926c41 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -330,6 +330,10 @@ export class SessionReplayRenderer { const origin = backgroundOrigin(message); if (origin !== undefined) { this.flushAssistant(context); + // A task notification opens a new turn live (`turn.started` with a + // background-task origin), which closes the update panel; replay folds + // it into the previous turn for grouping but must still close the panel. + this.host.streamingUI.clearNotifyPanel(); this.renderBackgroundTaskNotification(context, origin); return; } @@ -378,6 +382,8 @@ export class SessionReplayRenderer { return; } if (message.origin?.kind === 'cron_job') { + // Same as above: a cron fire starts a turn live and closes the panel. + this.host.streamingUI.clearNotifyPanel(); this.renderCronJob(context, message); return; } @@ -490,6 +496,10 @@ export class SessionReplayRenderer { context.turnIndex += 1; context.stepIndex = 0; context.currentTurnId = `replay:${String(context.turnIndex)}`; + // Mirror the live `turn.started` path: a new turn closes the previous + // turn's update panel, so resume never shows updates from an older turn + // when the latest one had none. + this.host.streamingUI.clearNotifyPanel(); this.applyStepContext(context); } diff --git a/apps/kimi-code/src/tui/controllers/streaming-ui.ts b/apps/kimi-code/src/tui/controllers/streaming-ui.ts index 96c19c6a39f..882b5ccb5c2 100644 --- a/apps/kimi-code/src/tui/controllers/streaming-ui.ts +++ b/apps/kimi-code/src/tui/controllers/streaming-ui.ts @@ -6,7 +6,7 @@ import { currentWorkingTip } from '../components/chrome/working-tips'; import { CompactionComponent } from '../components/dialogs/compaction'; import { ReadGroupComponent } from '../components/messages/read-group'; import { ThinkingComponent } from '../components/messages/thinking'; -import { ToolCallComponent } from '../components/messages/tool-call'; +import { extractPartialStringField, ToolCallComponent } from '../components/messages/tool-call'; import { STREAMING_UI_FLUSH_MS } from '../constant/streaming'; import { hasDispose } from '../utils/component-capabilities'; import { appendStreamingArgsPreview, parseStreamingArgs } from '../utils/event-payload'; @@ -63,6 +63,8 @@ export class StreamingUIController { { name?: string; argumentsText: string; startedAtMs: number } >(); private _pendingToolComponents = new Map(); + /** Turn whose NotifyUser updates the panel currently shows. */ + private _notifyPanelTurnId: string | undefined = undefined; private _pendingAgentGroup: { readonly turnId: string | undefined; readonly step: number; @@ -314,6 +316,7 @@ export class StreamingUIController { const existingComponent = this._pendingToolComponents.get(toolCall.id); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); + this.syncNotifyPanel(toolCall); } else if (existing === undefined) { this.finalizeLiveTextBuffers('tool'); if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { @@ -376,6 +379,9 @@ export class StreamingUIController { if (component !== undefined) { component.updateToolCall(toolCall); } + // The half-streamed update never reached the user; do not leave it + // in the panel as if it had. + if (toolCall.name === 'NotifyUser') this.dropNotifyEntry(toolCall.id); count += 1; } this._streamingToolCallArguments.clear(); @@ -394,6 +400,9 @@ export class StreamingUIController { this._currentStep = 0; this._streamingToolCallArguments.clear(); this.pendingToolCallFlushIds.clear(); + // Replayed updates belong to a finished turn: keep them for the user to + // read on resume, but dimmed like any ended turn. + this.markNotifyPanelEnded(); this.host.state.ui.requestRender(); } @@ -409,7 +418,11 @@ export class StreamingUIController { } disposeAndClearPendingToolComponents(): void { - for (const component of this._pendingToolComponents.values()) { + for (const [toolCallId, component] of this._pendingToolComponents) { + // A NotifyUser call still pending here never got a result — the step + // was interrupted or the turn ended around it — so its optimistic panel + // entry was never delivered and must not survive as a finished update. + if (component.toolCallView.name === 'NotifyUser') this.dropNotifyEntry(toolCallId); if (hasDispose(component)) component.dispose(); } this._pendingToolComponents.clear(); @@ -660,6 +673,7 @@ export class StreamingUIController { onToolCallStart(toolCall: ToolCallBlockData): void { if (toolCall.name === 'AskUserQuestion') return; + this.syncNotifyPanel(toolCall); const { state } = this.host; const tc = new ToolCallComponent( @@ -698,6 +712,12 @@ export class StreamingUIController { const { state } = this.host; const matchedCall = this._activeToolCalls.get(toolCallId); const tc = this._pendingToolComponents.get(toolCallId); + // A denied or failed NotifyUser call never reached the user: the + // executor emits tool.call.started before the permission veto, so the + // message was mounted optimistically and must come back out. + if (result.is_error === true && (matchedCall?.name ?? tc?.toolCallView.name) === 'NotifyUser') { + this.dropNotifyEntry(toolCallId); + } if (tc) { tc.setResult(result); this._pendingToolComponents.delete(toolCallId); @@ -730,6 +750,55 @@ export class StreamingUIController { state.ui.requestRender(); } + /** Close the update panel (next user turn, session reset, `/clear`). */ + clearNotifyPanel(): void { + const { state } = this.host; + this._notifyPanelTurnId = undefined; + if (state.notifyPanel.isEmpty() && state.notifyPanelContainer.children.length === 0) return; + state.notifyPanel.clear(); + state.notifyPanelContainer.clear(); + state.ui.requestRender(); + } + + /** The turn ended: keep the updates on screen for the reply, dim the title. */ + markNotifyPanelEnded(): void { + const { state } = this.host; + if (state.notifyPanel.isEmpty()) return; + state.notifyPanel.setEnded(true); + state.ui.requestRender(); + } + + /** Take one call's update out of the panel; unmount the panel when that was the last one. */ + private dropNotifyEntry(toolCallId: string): void { + const { state } = this.host; + if (!state.notifyPanel.remove(toolCallId)) return; + if (state.notifyPanel.isEmpty()) state.notifyPanelContainer.clear(); + state.ui.requestRender(); + } + + /** + * Mirror a `NotifyUser` call into the update panel. Runs on every tool + * call update, so the panel follows the `message` argument while it is + * still streaming and settles on the final args. A call from a different + * turn than the panel's current one (replay, or a turn whose start event + * was not seen) replaces the panel wholesale — the panel never mixes turns. + */ + private syncNotifyPanel(toolCall: ToolCallBlockData): void { + if (toolCall.name !== 'NotifyUser') return; + const message = notifyMessageOf(toolCall); + if (message === undefined || message.trim().length === 0) return; + const { state } = this.host; + if (this._notifyPanelTurnId !== toolCall.turnId) { + state.notifyPanel.clear(); + this._notifyPanelTurnId = toolCall.turnId; + } + state.notifyPanel.upsert(toolCall.id, message); + if (state.notifyPanelContainer.children.length === 0) { + state.notifyPanelContainer.addChild(state.notifyPanel); + } + state.ui.requestRender(); + } + beginCompaction(instruction?: string): void { const { state } = this.host; if (this._activeCompactionBlock !== undefined) { @@ -786,6 +855,7 @@ export class StreamingUIController { const existingComponent = this._pendingToolComponents.get(id); if (existingComponent !== undefined) { existingComponent.updateToolCall(toolCall); + this.syncNotifyPanel(toolCall); } else if (toolCall.name !== 'Agent' && toolCall.name !== 'AgentSwarm') { this.onToolCallStart(toolCall); } @@ -908,3 +978,15 @@ export class StreamingUIController { return group; } } + +/** + * The `message` a NotifyUser call carries: the final args once they landed, + * or the partially streamed string while the arguments are still arriving. + */ +function notifyMessageOf(toolCall: ToolCallBlockData): string | undefined { + if (toolCall.streamingArguments !== undefined) { + return extractPartialStringField(toolCall.streamingArguments, 'message'); + } + const message = toolCall.args['message']; + return typeof message === 'string' ? message : undefined; +} diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index eec3c49f282..9876ebf1b68 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -1127,6 +1127,7 @@ export class KimiTUI { ui.addChild(this.state.transcriptContainer); ui.addChild(this.state.activityContainer); ui.addChild(this.state.todoPanelContainer); + ui.addChild(this.state.notifyPanelContainer); ui.addChild(this.state.queueContainer); ui.addChild(this.state.btwPanelContainer); ui.addChild(this.state.surveyContainer); @@ -1167,6 +1168,7 @@ export class KimiTUI { main.addChild(this.state.transcriptContainer); main.addChild(this.state.activityContainer); main.addChild(this.state.todoPanelContainer); + main.addChild(this.state.notifyPanelContainer); main.addChild(this.state.queueContainer); main.addChild(this.state.btwPanelContainer); main.addChild(this.state.surveyContainer); @@ -2615,6 +2617,7 @@ export class KimiTUI { this.btwPanelController.clear(); this.state.footer.setBackgroundCounts({ bashTasks: 0, agentTasks: 0 }); this.streamingUI.setTodoList([]); + this.streamingUI.clearNotifyPanel(); this.streamingUI.setTurnId(undefined); this.setAppState({ mcpServersSummary: null }); this.streamingUI.setStep(0); @@ -2957,6 +2960,7 @@ export class KimiTUI { this.clearTerminalInlineImages(); this.state.todoPanel.clear(); this.state.todoPanelContainer.clear(); + this.streamingUI.clearNotifyPanel(); const stagingFileIds = this.imageStore.clear(); this.staging.deleteStaged(stagingFileIds); this.renderWelcome(); @@ -3488,6 +3492,12 @@ export class KimiTUI { this.state.ui.requestRender(); } + cycleNotifyPanelPage(): boolean { + if (!this.state.notifyPanel.nextPage()) return false; + this.state.ui.requestRender(); + return true; + } + private async detachRunningShellCommand(): Promise { // Only one `!` command runs at a time (input is queued while busy). const next = this.shellOutputStreams.entries().next(); diff --git a/apps/kimi-code/src/tui/tui-state.ts b/apps/kimi-code/src/tui/tui-state.ts index 6be0aa788e2..e4f4e8f14ca 100644 --- a/apps/kimi-code/src/tui/tui-state.ts +++ b/apps/kimi-code/src/tui/tui-state.ts @@ -13,6 +13,7 @@ import { openUrl } from '#/utils/open-url'; import { FooterComponent } from './components/chrome/footer';import { GutterContainer } from './components/chrome/gutter-container'; import type { MoonLoader, SpinnerStyle } from './components/chrome/moon-loader'; +import { NotifyPanelComponent } from './components/chrome/notify-panel'; import { TodoPanelComponent } from './components/chrome/todo-panel'; import type { SessionRow } from './components/dialogs/session-picker'; import { CustomEditor } from './components/editor/custom-editor'; @@ -39,12 +40,14 @@ export interface TUIState { activityContainer: Container; todoPanelContainer: Container; todoPanel: TodoPanelComponent; + notifyPanelContainer: Container; + notifyPanel: NotifyPanelComponent; queueContainer: Container; btwPanelContainer: Container; surveyContainer: Container; editorContainer: Container; /** - * Fullscreen mode only: the bottom dock (activity/todo/queue/btw/editor + + * Fullscreen mode only: the bottom dock (activity/todo/notify/queue/btw/editor + * footer) stacked under the transcript ScrollView. Undefined in regular * mode, where all chrome is a direct child of the root container. */ @@ -122,6 +125,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { const activityContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const todoPanel = new TodoPanelComponent(); + const notifyPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); + const notifyPanel = new NotifyPanelComponent(); const queueContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const btwPanelContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); const surveyContainer = new GutterContainer(CHROME_GUTTER, CHROME_GUTTER); @@ -151,6 +156,7 @@ export function createTUIState(options: KimiTUIOptions): TUIState { dockContainer = new VStack(); dockContainer.addChild(activityContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(todoPanelContainer, { shrink: 1, minSize: 0 }); + dockContainer.addChild(notifyPanelContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(queueContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(btwPanelContainer, { shrink: 1, minSize: 0 }); dockContainer.addChild(surveyContainer, { shrink: 0, minSize: 0 }); @@ -168,6 +174,8 @@ export function createTUIState(options: KimiTUIOptions): TUIState { activityContainer, todoPanelContainer, todoPanel, + notifyPanelContainer, + notifyPanel, queueContainer, btwPanelContainer, surveyContainer, diff --git a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts index 6b68d83e197..6ef1948d7ac 100644 --- a/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts +++ b/apps/kimi-code/test/tui/components/editor/custom-editor.test.ts @@ -665,6 +665,18 @@ describe('CustomEditor shortcut telemetry hooks', () => { expect(onToggleTodoExpand).toHaveBeenCalledOnce(); }); + + it('invokes onCycleNotifyPage on Ctrl+N and leaves the text alone when consumed', () => { + const editor = makeEditor(); + const onCycleNotifyPage = vi.fn().mockReturnValue(true); + editor.onCycleNotifyPage = onCycleNotifyPage; + editor.setText('draft'); + + editor.handleInput('\u000e'); + + expect(onCycleNotifyPage).toHaveBeenCalledOnce(); + expect(editor.getText()).toBe('draft'); + }); }); describe('CustomEditor bash mode border label', () => { diff --git a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts index 3691fed23b5..b582b8b0028 100644 --- a/apps/kimi-code/test/tui/components/messages/tool-call.test.ts +++ b/apps/kimi-code/test/tui/components/messages/tool-call.test.ts @@ -315,6 +315,54 @@ describe('ToolCallComponent', () => { }); }); + describe('NotifyUser card', () => { + const message = 'Login module is clean.\n\nThe bug must be in **session expiry**.'; + + it('collapses to a header with the first line and expands to the full message', () => { + const component = new ToolCallComponent( + { id: 'call_notify', name: 'NotifyUser', args: { message } }, + { tool_call_id: 'call_notify', output: 'Update shown to the user.', is_error: false }, + ); + + const collapsed = component.render(100).map(strip).filter((line) => line.trim().length > 0); + expect(collapsed).toHaveLength(1); + expect(collapsed[0]).toContain('Sent you an update'); + expect(collapsed[0]).toContain('Login module is clean.'); + expect(collapsed[0]).not.toContain('session expiry'); + expect(collapsed[0]).not.toContain('Update shown'); + + component.setExpanded(true); + const expanded = strip(component.render(100).join('\n')); + expect(expanded).toContain('session expiry'); + expect(expanded).not.toContain('Update shown'); + }); + + it('labels the in-flight call as sending', () => { + const component = new ToolCallComponent( + { id: 'call_notify_live', name: 'NotifyUser', args: {}, streamingArguments: '{"mess' }, + undefined, + ); + expect(strip(component.render(100).join('\n'))).toContain('Sending you an update'); + }); + + it('marks a call whose arguments were cut off by max_tokens', () => { + const component = new ToolCallComponent( + { + id: 'call_notify_cut', + name: 'NotifyUser', + args: {}, + streamingArguments: '{"message": "half an upd', + truncated: true, + }, + undefined, + ); + const out = strip(component.render(100).join('\n')); + expect(out).toContain('Update cut off'); + expect(out).not.toContain('Sending you an update'); + expect(out).toContain('call never executed'); + }); + }); + describe('collapsed header width', () => { it('truncates a long Bash header to the terminal width instead of wrapping', () => { const command = `pnpm exec vitest run ${'test/very/long/path/'.repeat(6)}spec.test.ts --reporter=verbose`; @@ -332,6 +380,21 @@ describe('ToolCallComponent', () => { } }); + it('truncates a long NotifyUser preview the same way', () => { + const component = new ToolCallComponent( + { + id: 'call_notify_narrow', + name: 'NotifyUser', + args: { message: `Plan: ${'inspect the parser, '.repeat(8)}then run the suite.` }, + }, + { tool_call_id: 'call_notify_narrow', output: 'Update shown to the user.', is_error: false }, + ); + const rows = component.render(50).map(strip).filter((line) => line.trim().length > 0); + expect(rows).toHaveLength(1); + expect(visibleWidth(rows[0]!)).toBeLessThanOrEqual(50); + expect(rows[0]).toContain('Sent you an update'); + }); + it('hands back the same header array while the header is unchanged', () => { const component = new ToolCallComponent( { id: 'call_bash_cached', name: 'Bash', args: { command: 'ls' } }, diff --git a/apps/kimi-code/test/tui/components/panels/notify-panel.test.ts b/apps/kimi-code/test/tui/components/panels/notify-panel.test.ts new file mode 100644 index 00000000000..ce306b2d8b7 --- /dev/null +++ b/apps/kimi-code/test/tui/components/panels/notify-panel.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from 'vitest'; + +import { NotifyPanelComponent } from '#/tui/components/chrome/notify-panel'; +import { NOTIFY_PANEL_MAX_BODY_LINES } from '#/tui/constant/rendering'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function render(panel: NotifyPanelComponent, width = 80): string[] { + return panel.render(width).map(strip); +} + +/** Body rows only: everything after the separator and the title. */ +function body(panel: NotifyPanelComponent, width = 80): string[] { + return render(panel, width).slice(2); +} + +function listRows(count: number, prefix = 'row'): string { + return Array.from({ length: count }, (_, i) => `- ${prefix} ${String(i + 1)}`).join('\n'); +} + +describe('NotifyPanelComponent', () => { + it('returns no lines when empty (so the layout slot collapses)', () => { + const panel = new NotifyPanelComponent(); + expect(panel.render(80)).toEqual([]); + expect(panel.isEmpty()).toBe(true); + expect(panel.hasMorePages()).toBe(false); + expect(panel.nextPage()).toBe(false); + }); + + it('renders a separator, an Update title, and the message as markdown behind a marker', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', 'Login module is clean; the bug is in **session expiry**.'); + const lines = render(panel); + expect(lines[0]).toMatch(/^─+$/); + expect(lines[1]).toContain('◆ Update'); + expect(lines[1]).not.toContain('Updates'); + expect(lines[1]).not.toContain('ctrl+n'); + expect(lines[2]).toContain('◆ Login module is clean; the bug is in session expiry.'); + expect(panel.hasMorePages()).toBe(false); + }); + + it('updates an entry in place while its message streams', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', 'Reading the'); + panel.upsert('tc-1', 'Reading the parser first.'); + expect(panel.getEntries()).toEqual([{ id: 'tc-1', text: 'Reading the parser first.' }]); + expect(body(panel).join('\n')).toContain('Reading the parser first.'); + }); + + it('stacks the updates of a turn, newest last, with the newest marked solid', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', 'plan: three parallel probes'); + panel.upsert('tc-2', 'core side done'); + panel.upsert('tc-3', 'running the full test suite now, ~3 minutes'); + + const lines = render(panel); + expect(lines[1]).toContain('◆ Updates (3)'); + expect(lines[1]).not.toContain('ctrl+n'); + expect(body(panel)).toEqual([ + expect.stringContaining('◇ plan: three parallel probes'), + expect.stringContaining('◇ core side done'), + expect.stringContaining('◆ running the full test suite now, ~3 minutes'), + ]); + }); + + it('indents continuation rows of a multi-line update under its marker', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', 'first paragraph\n\nsecond paragraph'); + const rows = body(panel); + expect(rows[0]).toMatch(/^ ◆ first paragraph/); + expect(rows.at(-1)).toMatch(/^ {4}second paragraph/); + }); + + it('follows the tail when the stack overflows, pages up with wrap-around', () => { + const panel = new NotifyPanelComponent(); + const total = NOTIFY_PANEL_MAX_BODY_LINES + 3; + panel.upsert('tc-1', listRows(total)); + + // Tail window: the last `cap` rows, with an "earlier" hint above. + let lines = render(panel); + expect(lines[1]).toContain('ctrl+n earlier'); + expect(lines[2]).toContain('… 3 earlier lines'); + expect(lines.slice(3).join('\n')).toContain(`row ${String(total)}`); + expect(lines.slice(3).join('\n')).not.toContain('row 1\n'); + expect(lines.at(-1)).not.toContain('later lines'); + expect(panel.hasMorePages()).toBe(true); + + // Page up: the first rows, with a "later" hint below. + expect(panel.nextPage()).toBe(true); + lines = render(panel); + expect(lines[2]).toContain('◆ • row 1'); + expect(lines.join('\n')).not.toContain('earlier lines'); + expect(lines.at(-1)).toContain('… 3 later lines'); + + // From the top, wrap back to the tail. + expect(panel.nextPage()).toBe(true); + lines = render(panel); + expect(lines[2]).toContain('… 3 earlier lines'); + expect(lines.slice(3).join('\n')).toContain(`row ${String(total)}`); + }); + + it('snaps back to the tail when a new update arrives while paged up', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', listRows(NOTIFY_PANEL_MAX_BODY_LINES + 2)); + render(panel); + expect(panel.nextPage()).toBe(true); + expect(render(panel).at(-1)).toContain('later lines'); + + panel.upsert('tc-2', 'fresh update'); + const lines = render(panel); + expect(lines.at(-1)).toContain('◆ fresh update'); + expect(lines.join('\n')).not.toContain('later lines'); + }); + + it('dims the title and notes the ended turn, and clears wholesale', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', 'done with phase one'); + panel.setEnded(true); + expect(render(panel)[1]).toContain('◇ Update · turn ended · next message clears'); + expect(render(panel)[2]).toContain('◆ done with phase one'); + + panel.clear(); + expect(panel.isEmpty()).toBe(true); + expect(panel.render(80)).toEqual([]); + + panel.upsert('tc-2', 'fresh turn'); + expect(render(panel)[1]).toContain('◆ Update'); + expect(render(panel)[1]).not.toContain('turn ended'); + }); + + it('removes one entry and snaps the view back to the tail', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', 'first update'); + panel.upsert('tc-2', 'denied update'); + panel.upsert('tc-3', 'third update'); + + expect(panel.remove('tc-2')).toBe(true); + expect(panel.remove('tc-2')).toBe(false); + expect(panel.getEntries().map((entry) => entry.id)).toEqual(['tc-1', 'tc-3']); + expect(render(panel)[1]).toContain('Updates (2)'); + expect(body(panel).join('\n')).not.toContain('denied update'); + + panel.remove('tc-1'); + panel.remove('tc-3'); + expect(panel.isEmpty()).toBe(true); + expect(panel.render(80)).toEqual([]); + }); + + it('never renders wider than the requested width', () => { + const panel = new NotifyPanelComponent(); + panel.upsert('tc-1', `${'word '.repeat(60)}\n\n- a very long bullet ${'x'.repeat(120)}`); + panel.upsert('tc-2', listRows(12, 'later')); + for (const width of [24, 40, 80]) { + for (const line of panel.render(width)) { + expect(strip(line).length).toBeLessThanOrEqual(width); + } + } + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts index 0c5588e46b4..edc13ea1f71 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-background-task.test.ts @@ -21,6 +21,8 @@ function makeStreamingUIStub() { flushNow: vi.fn(), setTodoList: vi.fn(), resetToolUi: vi.fn(), + clearNotifyPanel: vi.fn(), + markNotifyPanelEnded: vi.fn(), finalizeTurn: vi.fn(), }; } diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts index 1fa9e857fdb..75f3146c105 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-compaction.test.ts @@ -28,6 +28,8 @@ function makeHost() { setTurnId: vi.fn(), flushNow: vi.fn(), resetToolUi: vi.fn(), + clearNotifyPanel: vi.fn(), + markNotifyPanelEnded: vi.fn(), finalizeTurn: vi.fn(), hasActiveTurn: vi.fn(() => false), hasThinkingDraft: vi.fn(() => false), diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts index d519c4b3d9a..8ab9cfd541f 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-goal-queue.test.ts @@ -68,6 +68,8 @@ function makeHost(options: { createGoalRejects?: boolean } = {}) { setTurnId: vi.fn(), flushNow: vi.fn(), resetToolUi: vi.fn(), + clearNotifyPanel: vi.fn(), + markNotifyPanelEnded: vi.fn(), finalizeTurn: vi.fn(), hasActiveTurn: vi.fn(() => false), hasThinkingDraft: vi.fn(() => false), diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-notify.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-notify.test.ts new file mode 100644 index 00000000000..d166848685f --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-notify.test.ts @@ -0,0 +1,123 @@ +import type { Event } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it, vi } from 'vitest'; + +import { SessionEventHandler } from '#/tui/controllers/session-event-handler'; +import { getBuiltInPalette } from '#/tui/theme'; + +function makeHost() { + const host = { + state: { + appState: { + sessionId: 's1', + streamingPhase: 'idle', + isCompacting: false, + model: 'kimi-model', + permissionMode: 'auto', + stepRetry: null, + }, + queuedMessages: [], + queuedMessageDispatchPending: false, + theme: { palette: getBuiltInPalette('dark') }, + toolOutputExpanded: false, + todoPanel: { getTodos: vi.fn(() => []) }, + transcriptContainer: { addChild: vi.fn() }, + ui: { requestRender: vi.fn() }, + }, + session: { id: 's1' }, + aborted: false, + sessionEventUnsubscribe: undefined, + streamingUI: { + setTurnId: vi.fn(), + setStep: vi.fn(), + flushNow: vi.fn(), + resetToolUi: vi.fn(), + clearNotifyPanel: vi.fn(), + markNotifyPanelEnded: vi.fn(), + finalizeTurn: vi.fn(), + finalizeLiveTextBuffers: vi.fn(), + completeToolResult: vi.fn(), + getTurnContext: vi.fn(() => ({ turnId: '1', step: 0 })), + }, + requireSession: vi.fn(), + setAppState: vi.fn((patch: Record) => + Object.assign(host.state.appState, patch), + ), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + updateActivityPane: vi.fn(), + updateQueueDisplay: vi.fn(), + showError: vi.fn(), + showStatus: vi.fn(), + showNotice: vi.fn(), + track: vi.fn(), + recordSessionActivity: vi.fn(), + noteStepUsage: vi.fn(), + noteCompactionFinished: vi.fn(), + mountEditorReplacement: vi.fn(), + restoreEditor: vi.fn(), + restoreInputText: vi.fn(), + appendTranscriptEntry: vi.fn(), + sendNormalUserInput: vi.fn(), + sendQueuedMessage: vi.fn(), + shiftQueuedMessage: vi.fn(), + btwPanelController: { routeEvent: vi.fn(() => false) }, + tasksBrowserController: {}, + }; + return { host: host as any }; +} + +function turnStarted(origin: Record): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'turn.started', + turnId: 1, + origin, + } as unknown as Event; +} + +function turnEnded(): Event { + return { + sessionId: 's1', + agentId: 'main', + type: 'turn.ended', + turnId: 1, + reason: 'completed', + } as unknown as Event; +} + +describe('SessionEventHandler — update panel lifecycle', () => { + it('closes the panel when a user turn starts', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStarted({ kind: 'user' }), vi.fn()); + + expect(host.streamingUI.clearNotifyPanel).toHaveBeenCalledOnce(); + expect(host.streamingUI.markNotifyPanelEnded).not.toHaveBeenCalled(); + }); + + it('closes the panel on a cron-fired turn too, so it reopens fresh', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent( + turnStarted({ kind: 'cron_job', jobId: 'j1', cron: '* * * * *', recurring: true }), + vi.fn(), + ); + + expect(host.streamingUI.clearNotifyPanel).toHaveBeenCalledOnce(); + }); + + it('keeps the panel but marks it ended when the turn ends', () => { + const { host } = makeHost(); + const handler = new SessionEventHandler(host); + + handler.handleEvent(turnStarted({ kind: 'user' }), vi.fn()); + host.streamingUI.clearNotifyPanel.mockClear(); + handler.handleEvent(turnEnded(), vi.fn()); + + expect(host.streamingUI.markNotifyPanelEnded).toHaveBeenCalledOnce(); + expect(host.streamingUI.clearNotifyPanel).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts index e23726fe27f..7d55a6e247d 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-plugin-updates.test.ts @@ -11,6 +11,8 @@ function makeHost() { setTurnId: vi.fn(), flushNow: vi.fn(), resetToolUi: vi.fn(), + clearNotifyPanel: vi.fn(), + markNotifyPanelEnded: vi.fn(), setStep: vi.fn(), finalizeTurn: vi.fn(), getTurnContext: vi.fn(() => ({ turnId: 1, step: 0 })), diff --git a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts index a60aa55c636..8ca1e124835 100644 --- a/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts +++ b/apps/kimi-code/test/tui/controllers/session-event-handler-step-retry.test.ts @@ -30,6 +30,8 @@ function makeHost() { setStep: vi.fn(), flushNow: vi.fn(), resetToolUi: vi.fn(), + clearNotifyPanel: vi.fn(), + markNotifyPanelEnded: vi.fn(), finalizeTurn: vi.fn(), finalizeLiveTextBuffers: vi.fn(), completeToolResult: vi.fn(), diff --git a/apps/kimi-code/test/tui/controllers/streaming-ui-notify.test.ts b/apps/kimi-code/test/tui/controllers/streaming-ui-notify.test.ts new file mode 100644 index 00000000000..e1a8d5db1ed --- /dev/null +++ b/apps/kimi-code/test/tui/controllers/streaming-ui-notify.test.ts @@ -0,0 +1,193 @@ +import { Container } from '@moonshot-ai/pi-tui'; +import { describe, expect, it, vi } from 'vitest'; + +import { NotifyPanelComponent } from '#/tui/components/chrome/notify-panel'; +import { StreamingUIController, type StreamingUIHost } from '#/tui/controllers/streaming-ui'; +import type { ToolCallBlockData } from '#/tui/types'; + +function strip(text: string): string { + return text.replaceAll(/\u001B\[[0-9;]*m/g, ''); +} + +function makeHarness() { + const notifyPanel = new NotifyPanelComponent(); + const notifyPanelContainer = new Container(); + const transcriptContainer = new Container(); + const requestRender = vi.fn(); + const host = { + state: { + notifyPanel, + notifyPanelContainer, + transcriptContainer, + toolOutputExpanded: false, + ui: { requestRender }, + appState: { workDir: '/tmp/work', streamingPhase: 'waiting' }, + }, + session: undefined, + setAppState: vi.fn(), + patchLivePane: vi.fn(), + resetLivePane: vi.fn(), + updateActivityPane: vi.fn(), + updateQueueDisplay: vi.fn(), + requireSession: vi.fn(), + deferUserMessages: false, + shiftQueuedMessage: vi.fn(() => undefined), + pushTranscriptEntry: vi.fn(), + mergeCurrentTurnSteps: vi.fn(), + mergeCompletedTurnAssistants: vi.fn(), + } as unknown as StreamingUIHost; + const controller = new StreamingUIController(host); + return { controller, notifyPanel, notifyPanelContainer, transcriptContainer, requestRender }; +} + +function notifyCall(id: string, message: string, turnId = 't1'): ToolCallBlockData { + return { id, name: 'NotifyUser', args: { message }, turnId }; +} + +function panelText(panel: NotifyPanelComponent): string { + return panel.render(100).map(strip).join('\n'); +} + +describe('StreamingUIController — NotifyUser update panel', () => { + it('mounts the panel with the message and still adds the transcript card', () => { + const { controller, notifyPanel, notifyPanelContainer, transcriptContainer } = makeHarness(); + controller.setTurnId('t1'); + + controller.onToolCallStart(notifyCall('tc-1', 'Reading the parser first.')); + + expect(notifyPanelContainer.children).toEqual([notifyPanel]); + expect(panelText(notifyPanel)).toContain('Reading the parser first.'); + expect(transcriptContainer.children).toHaveLength(1); + }); + + it('follows the message while the arguments stream, then settles on the final args', () => { + const { controller, notifyPanel } = makeHarness(); + controller.setTurnId('t1'); + + controller.accumulateToolCallDelta('tc-1', 'NotifyUser', '{"message": "Login module is cl'); + controller.flushNow(); + expect(panelText(notifyPanel)).toContain('Login module is cl'); + + controller.accumulateToolCallDelta('tc-1', undefined, 'ean; the bug is in session expiry."}'); + controller.flushNow(); + expect(panelText(notifyPanel)).toContain('Login module is clean; the bug is in session expiry.'); + + controller.registerToolCall( + notifyCall('tc-1', 'Login module is clean; the bug is in session expiry.'), + ); + expect(notifyPanel.getEntries()).toEqual([ + { id: 'tc-1', text: 'Login module is clean; the bug is in session expiry.' }, + ]); + }); + + it('ignores every other tool', () => { + const { controller, notifyPanel, notifyPanelContainer } = makeHarness(); + controller.setTurnId('t1'); + + controller.onToolCallStart({ id: 'tc-1', name: 'Bash', args: { command: 'ls' }, turnId: 't1' }); + + expect(notifyPanel.isEmpty()).toBe(true); + expect(notifyPanelContainer.children).toEqual([]); + }); + + it('replaces the panel wholesale when a call from another turn arrives', () => { + const { controller, notifyPanel } = makeHarness(); + + controller.onToolCallStart(notifyCall('tc-1', 'first turn', 'replay:1')); + controller.onToolCallStart(notifyCall('tc-2', 'second turn', 'replay:2')); + + expect(notifyPanel.getEntries()).toEqual([{ id: 'tc-2', text: 'second turn' }]); + + // Resume finished: the replayed updates stay readable but read as ended. + controller.cleanupAfterReplay(new Set(['tc-1', 'tc-2'])); + expect(panelText(notifyPanel)).toContain('turn ended'); + }); + + it('clears on demand and dims once the turn ends', () => { + const { controller, notifyPanel, notifyPanelContainer, requestRender } = makeHarness(); + controller.setTurnId('t1'); + controller.onToolCallStart(notifyCall('tc-1', 'phase one done')); + + controller.markNotifyPanelEnded(); + expect(panelText(notifyPanel)).toContain('turn ended'); + + requestRender.mockClear(); + controller.clearNotifyPanel(); + expect(notifyPanel.isEmpty()).toBe(true); + expect(notifyPanelContainer.children).toEqual([]); + expect(requestRender).toHaveBeenCalled(); + + requestRender.mockClear(); + controller.clearNotifyPanel(); + expect(requestRender).not.toHaveBeenCalled(); + }); + + it('takes a denied or failed call back out of the panel', () => { + const { controller, notifyPanel, notifyPanelContainer } = makeHarness(); + controller.setTurnId('t1'); + controller.registerToolCall(notifyCall('tc-1', 'kept update')); + controller.registerToolCall(notifyCall('tc-2', 'denied update')); + + controller.completeToolResult('tc-2', { + tool_call_id: 'tc-2', + output: 'Permission denied', + is_error: true, + }); + expect(notifyPanel.getEntries().map((entry) => entry.id)).toEqual(['tc-1']); + expect(notifyPanelContainer.children).toEqual([notifyPanel]); + + controller.completeToolResult('tc-1', { + tool_call_id: 'tc-1', + output: 'Permission denied', + is_error: true, + }); + expect(notifyPanel.isEmpty()).toBe(true); + expect(notifyPanelContainer.children).toEqual([]); + }); + + it('keeps a successfully delivered update when its result lands', () => { + const { controller, notifyPanel } = makeHarness(); + controller.setTurnId('t1'); + controller.registerToolCall(notifyCall('tc-1', 'kept update')); + + controller.completeToolResult('tc-1', { + tool_call_id: 'tc-1', + output: 'Update shown to the user.', + is_error: false, + }); + expect(notifyPanel.getEntries()).toEqual([{ id: 'tc-1', text: 'kept update' }]); + }); + + it('drops a half-streamed update when max_tokens truncates the call', () => { + const { controller, notifyPanel, notifyPanelContainer } = makeHarness(); + controller.setTurnId('t1'); + controller.setStep(2); + controller.accumulateToolCallDelta('tc-1', 'NotifyUser', '{"message": "half an upd'); + controller.flushNow(); + expect(panelText(notifyPanel)).toContain('half an upd'); + + expect(controller.markStepTruncated('t1', 2)).toBe(1); + expect(notifyPanel.isEmpty()).toBe(true); + expect(notifyPanelContainer.children).toEqual([]); + }); + + it('drops an update whose call never got a result when the step is interrupted', () => { + const { controller, notifyPanel, notifyPanelContainer } = makeHarness(); + controller.setTurnId('t1'); + controller.registerToolCall(notifyCall('tc-1', 'delivered update')); + controller.completeToolResult('tc-1', { + tool_call_id: 'tc-1', + output: 'Update shown to the user.', + is_error: false, + }); + controller.registerToolCall(notifyCall('tc-2', 'interrupted update')); + + // Esc / a failed step: the tool UI resets before any result for tc-2. + controller.resetToolUi(); + controller.markNotifyPanelEnded(); + + expect(notifyPanel.getEntries()).toEqual([{ id: 'tc-1', text: 'delivered update' }]); + expect(notifyPanelContainer.children).toEqual([notifyPanel]); + expect(panelText(notifyPanel)).toContain('turn ended'); + }); +}); diff --git a/apps/kimi-code/test/tui/create-tui-state.test.ts b/apps/kimi-code/test/tui/create-tui-state.test.ts index 9738e7e5969..0ea9f6c3d33 100644 --- a/apps/kimi-code/test/tui/create-tui-state.test.ts +++ b/apps/kimi-code/test/tui/create-tui-state.test.ts @@ -63,6 +63,8 @@ describe('createTUIState', () => { expect(state.editor).toBeDefined(); expect(state.footer).toBeDefined(); expect(state.todoPanel).toBeDefined(); + expect(state.notifyPanelContainer).toBeDefined(); + expect(state.notifyPanel).toBeDefined(); expect(state.theme.palette).toBeDefined(); // App state is cloned from initialAppState, not reused by reference. @@ -128,6 +130,7 @@ describe('createTUIState', () => { expect(dock?.children).toEqual([ state.activityContainer, state.todoPanelContainer, + state.notifyPanelContainer, state.queueContainer, state.btwPanelContainer, state.surveyContainer, 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 aaa76f4302d..99cec818395 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 @@ -1072,6 +1072,193 @@ describe('KimiTUI message flow', () => { expect(turns[2]!.entries[1]!.content).toBe('please /commit'); }); + it('does not resurrect an earlier turn\'s NotifyUser updates when the latest turn had none', async () => { + const session = makeSession({ id: 'ses-notify-replay' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, {}, startupInput); + (session.getResumeState as ReturnType).mockReturnValue({ + sessionMetadata: {}, + agents: { + main: { + config: { modelCapabilities: { max_context_tokens: 100 }, modelAlias: 'k2' }, + plan: null, + permission: { mode: 'manual' }, + swarmMode: false, + context: { history: [], tokenCount: 0 }, + background: [], + toolStore: {}, + replay: [ + { + type: 'message', + time: 1, + message: { + role: 'user', + content: [{ type: 'text', text: 'first question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'message', + time: 2, + message: { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'tc-notify-1', + name: 'NotifyUser', + arguments: JSON.stringify({ message: 'first-turn update' }), + }, + ], + }, + }, + { + type: 'message', + time: 3, + message: { + role: 'tool', + toolCallId: 'tc-notify-1', + content: [{ type: 'text', text: 'Update shown to the user.' }], + toolCalls: [], + }, + }, + { + type: 'message', + time: 4, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'first answer' }], + toolCalls: [], + }, + }, + { + type: 'message', + time: 5, + message: { + role: 'user', + content: [{ type: 'text', text: 'second question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'message', + time: 6, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'second answer' }], + toolCalls: [], + }, + }, + ], + }, + }, + }); + + const replayed = await driver.sessionReplay.hydrateFromReplay(session as unknown as Session); + expect(replayed).toBe(true); + + // The first turn's update was mounted while replaying that turn, and the + // second turn's start closed it again — exactly like the live path. + expect(driver.state.notifyPanel.isEmpty()).toBe(true); + expect(driver.state.notifyPanelContainer.children).toHaveLength(0); + }); + + it('closes an earlier turn\'s update panel when a replayed cron fire starts the next turn', async () => { + const session = makeSession({ id: 'ses-notify-cron' }); + const startupInput: KimiTUIStartupInput = { + ...makeStartupInput(), + engineV2: true, + cliOptions: { ...makeStartupInput().cliOptions, model: 'k2' }, + }; + const { driver } = await makeDriver(session, {}, startupInput); + (session.getResumeState as ReturnType).mockReturnValue({ + sessionMetadata: {}, + agents: { + main: { + config: { modelCapabilities: { max_context_tokens: 100 }, modelAlias: 'k2' }, + plan: null, + permission: { mode: 'manual' }, + swarmMode: false, + context: { history: [], tokenCount: 0 }, + background: [], + toolStore: {}, + replay: [ + { + type: 'message', + time: 1, + message: { + role: 'user', + content: [{ type: 'text', text: 'first question' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + }, + { + type: 'message', + time: 2, + message: { + role: 'assistant', + content: [], + toolCalls: [ + { + type: 'function', + id: 'tc-notify-cron', + name: 'NotifyUser', + arguments: JSON.stringify({ message: 'update from the prompt turn' }), + }, + ], + }, + }, + { + type: 'message', + time: 3, + message: { + role: 'tool', + toolCallId: 'tc-notify-cron', + content: [{ type: 'text', text: 'Update shown to the user.' }], + toolCalls: [], + }, + }, + { + type: 'message', + time: 4, + message: { + role: 'user', + content: [{ type: 'text', text: 'check the build' }], + toolCalls: [], + origin: { kind: 'cron_job', jobId: 'job-1', cron: '*/5 * * * *', recurring: true }, + }, + }, + { + type: 'message', + time: 5, + message: { + role: 'assistant', + content: [{ type: 'text', text: 'build is green' }], + toolCalls: [], + }, + }, + ], + }, + }, + }); + + const replayed = await driver.sessionReplay.hydrateFromReplay(session as unknown as Session); + expect(replayed).toBe(true); + + // Live, the cron fire's turn.started closes the panel; replay folds the + // cron turn into the previous one for grouping but must close it too. + expect(driver.state.notifyPanel.isEmpty()).toBe(true); + expect(driver.state.notifyPanelContainer.children).toHaveLength(0); + }); + it('keeps hook results recorded before the oldest retained bundle within the replay limit', async () => { const session = makeSession({ id: 'ses-lazy' }); const startupInput: KimiTUIStartupInput = { diff --git a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts index 4f9a7b14e96..634d6aacfe5 100644 --- a/apps/kimi-code/test/tui/kimi-tui-startup.test.ts +++ b/apps/kimi-code/test/tui/kimi-tui-startup.test.ts @@ -337,7 +337,8 @@ describe('KimiTUI startup', () => { await expect(driver.init()).resolves.toBe(false); (driver as unknown as { mountFooter(): void }).mountFooter(); - expect(driver.state.dockContainer?.children).toHaveLength(7); + // Dock = 7 chrome containers + footer wrap, below the transcript viewport. + expect(driver.state.dockContainer?.children).toHaveLength(8); }); it('shows a session-less notice on v2 startup', async () => { diff --git a/docs/en/reference/keyboard.md b/docs/en/reference/keyboard.md index e534a548fae..b218f2ed5fe 100644 --- a/docs/en/reference/keyboard.md +++ b/docs/en/reference/keyboard.md @@ -15,6 +15,7 @@ The following keys are always available in the input box: | `Ctrl-C` | Interrupt the current streaming output, or clear the input box | | `Ctrl-D` | Exit Kimi Code CLI when the input box is empty | | `Ctrl-T` | Expand or collapse the todo list when it is truncated | +| `Ctrl-N` | Page back through earlier updates in the `Update` panel when they outgrow it | Pressing `Ctrl-C` **during streaming** cancels immediately — no second confirmation needed. diff --git a/docs/en/reference/tools.md b/docs/en/reference/tools.md index 2c6ef388e38..f2730f1beee 100644 --- a/docs/en/reference/tools.md +++ b/docs/en/reference/tools.md @@ -87,6 +87,7 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill | `Agent` | Auto-allow | Spawn a sub-Agent to execute a subtask | | `AgentSwarm` | Auto-allow in swarm mode; otherwise requires approval | Launch item-based subagents or resume existing subagents | | `AskUserQuestion` | Auto-allow | Ask the user a question to gather structured input | +| `NotifyUser` | Auto-allow | Show the user a short progress update mid-turn | | `Skill` | Auto-allow | Invoke a registered inline Skill | **`Agent`** delegates a subtask to a sub-Agent. Required parameters: `prompt` (complete task description) and `description` (a 3–5 word short summary). Optional parameters: `subagent_type` (defaults to `coder`), `resume` (ID of an existing Agent to resume; mutually exclusive with `subagent_type`), `run_in_background` (defaults to false), and `model` (available when a [subagent model pool](../configuration/config-files.md#subagent-model-pool) is configured — either a `[secondary_model.models]` table or a lone `default_model`: a pool alias, or `"primary"` for the model the caller itself is running; ignored when resuming). Without it, the subagent binds the pool's `default_model`; without a configured pool, subagents always inherit the caller's model. Agent tasks time out after 2 hours by default; the limit is configurable via `[subagent] timeout_ms` in `config.toml` (`0` = no timeout, or the `KIMI_SUBAGENT_TIMEOUT_MS` env var), and defaults to no timeout in print mode (`kimi -p`). In foreground mode the parent Agent waits for the sub-Agent to complete before continuing; in background mode a task ID is returned immediately and the result is automatically delivered back to the main Agent via a synthetic User message when done. When several foreground `Agent` calls run in the same step, the TUI groups them and shows each subagent's running, waiting, completed, or failed status with elapsed time. See [Agent & Sub-Agents](../customization/agents.md) for details. @@ -95,6 +96,8 @@ Collaboration tools handle inter-Agent coordination, user interaction, and Skill **`AskUserQuestion`** asks the user a structured multiple-choice question — useful for disambiguation or option selection. The `questions` parameter accepts 1–4 questions; each question requires `question` (ending with `?`), `options` (2–4 choices, each with a `label` and `description`), and optional `header` (max 12 characters) and `multi_select` (defaults to false). An "Other" option is appended automatically. Setting `background` to true starts a background question task and returns a task ID immediately; the question stays open after the turn ends, and the answer is delivered to the Agent as a notification once the user responds. When the host does not support interactive questioning, a failure message is returned and the Agent should ask the user directly in a text reply instead. +**`NotifyUser`** shows the user a short progress update while the turn is still running. The single `message` parameter is light Markdown in the user's language. In the TUI the updates of the current turn stack up in an `Update` panel above the input box, newest at the bottom; when they outgrow the panel, press `Ctrl-N` to page back through the earlier ones. The panel is cleared when the next turn starts, so the Agent restates anything the user must keep in its final reply. Only the main Agent can call it. The tool is experimental and off by default: enable it with `KIMI_CODE_EXPERIMENTAL_NOTIFY_USER=1` or `[experimental] notify_user = true` in `config.toml`. Only the TUI offers it; print mode, the web UI, and other hosts never expose it to the model. + **`Skill`** allows the Agent to actively invoke a registered inline-type Skill. Accepts `skill` (the Skill name) and optional `args` (additional argument text). Only `type = "inline"` Skills can be called via this tool; Skills with `disableModelInvocation: true` are rejected. Maximum nesting depth is 3 levels. See [Agent Skills](../customization/skills.md) for details. ## Background Tasks diff --git a/docs/zh/reference/keyboard.md b/docs/zh/reference/keyboard.md index 3e95dad0e7a..6aa01f4225b 100644 --- a/docs/zh/reference/keyboard.md +++ b/docs/zh/reference/keyboard.md @@ -15,6 +15,7 @@ Kimi Code CLI 的 TUI 交互模式支持一套键盘快捷键。键位按使用 | `Ctrl-C` | 中断当前流式输出,或清空输入框 | | `Ctrl-D` | 在输入框为空时退出 Kimi Code CLI | | `Ctrl-T` | 待办列表被截断时,展开或折叠完整列表 | +| `Ctrl-N` | `Update` 面板内容超出时,往前翻看更早的更新 | **流式输出期间**按 `Ctrl-C` 会立即取消,无需二次确认。 diff --git a/docs/zh/reference/tools.md b/docs/zh/reference/tools.md index aa524ae0181..88040d4f18f 100644 --- a/docs/zh/reference/tools.md +++ b/docs/zh/reference/tools.md @@ -87,6 +87,7 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 | `Agent` | 自动放行 | 派生 subagent 执行子任务 | | `AgentSwarm` | swarm mode 中自动放行,否则需审批 | 启动基于 item 的 subagent,或恢复已有 subagent | | `AskUserQuestion` | 自动放行 | 向用户提问以获取结构化输入 | +| `NotifyUser` | 自动放行 | 在轮次进行中向用户展示一条简短的进展更新 | | `Skill` | 自动放行 | 调用已注册的 inline Skill | **`Agent`** 将子任务委托给 subagent 执行。必填参数:`prompt`(完整任务描述)和 `description`(3–5 个词的简短说明)。可选参数:`subagent_type`(默认 `coder`)、`resume`(恢复已有 Agent 的 ID,与 `subagent_type` 互斥)、`run_in_background`(默认 false)和 `model`(在配置 [subagent 模型池](../configuration/config-files.md#subagent-模型池) 后可用——`[secondary_model.models]` 表或仅一行 `default_model`:池中别名,或 `"primary"` 表示调用方自己运行的模型;resume 时无效)。未传入时 subagent 绑定池的 `default_model`;未配置模型池时,subagent 一律继承调用方模型。Agent 任务默认 2 小时超时,可通过 `config.toml` 的 `[subagent] timeout_ms`(`0` = 无超时,或 `KIMI_SUBAGENT_TIMEOUT_MS` 环境变量)配置,且在 print 模式(`kimi -p`)下默认无超时。前台模式下父 Agent 等待 subagent 完成再继续;后台模式立即返回任务 ID,完成时通过合成 User 消息自动回到 main agent。多个前台 `Agent` 调用在同一步运行时,TUI 会合并展示,并为每个 subagent 显示运行、等待、完成或失败状态以及已耗时长。subagent 体系细节见 [Agent 与 subagent](../customization/agents.md)。 @@ -95,6 +96,8 @@ Plan 模式是一种受约束的工作状态:进入后 `Write` 与 `Edit` 只 **`AskUserQuestion`** 以结构化多选题的形式向用户提问,适用于需要消歧或选择方案的场景。`questions` 参数接受 1–4 道题,每道题需提供 `question`(以 `?` 结尾)、`options`(2–4 个选项,每项含 `label` 和 `description`)以及可选的 `header`(最多 12 字符)和 `multi_select`(默认 false)。系统自动附加"其他"选项。`background` 为 true 时启动后台问题任务并立即返回任务 ID;问题在本轮结束后仍保持待答,用户作答后答案会以通知形式直接送回 Agent。宿主未实现交互式提问能力时返回失败提示,Agent 应改为在文本回复中直接提问。 +**`NotifyUser`** 在轮次仍在进行时向用户展示一条简短的进展更新。唯一参数 `message` 是用户语言的轻量 Markdown。在 TUI 中,当前轮次的更新会按时间顺序堆叠在输入框上方的 `Update` 面板里,最新的在最下面;内容超出面板时,按 `Ctrl-N` 往前翻看更早的更新。下一轮次开始时面板会被清空,因此 Agent 会在最终回复中重述用户需要保留的内容。只有 main agent 可以调用。该工具是实验特性,默认关闭:设置 `KIMI_CODE_EXPERIMENTAL_NOTIFY_USER=1`,或在 `config.toml` 中配置 `[experimental] notify_user = true` 即可启用。只有 TUI 会提供这个工具;print 模式、Web UI 和其他宿主不会把它暴露给模型。 + **`Skill`** 允许 Agent 主动调用已注册的 inline 类型 Skill。接受 `skill`(Skill 名称)和可选的 `args`(附加参数文本)。只有 `type = "inline"` 的 Skill 能通过此工具调用;`disableModelInvocation: true` 的 Skill 会被拒绝。嵌套调用深度上限 3 层。Skill 体系细节见 [Agent Skills](../customization/skills.md)。 ## 后台任务 diff --git a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts index a2b79a22bd7..d826a7f4eb7 100644 --- a/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts +++ b/packages/agent-core-v2/src/agent/permissionPolicy/policies/default-tool-approve.ts @@ -20,6 +20,7 @@ const DEFAULT_APPROVE_TOOLS = new Set([ 'Agent', 'AgentSwarm', 'AskUserQuestion', + 'NotifyUser', 'Skill', 'EnterPlanMode', 'ExitPlanMode', diff --git a/packages/agent-core-v2/src/agent/profile/profileService.ts b/packages/agent-core-v2/src/agent/profile/profileService.ts index 8ca3eddfb74..aac0146e288 100644 --- a/packages/agent-core-v2/src/agent/profile/profileService.ts +++ b/packages/agent-core-v2/src/agent/profile/profileService.ts @@ -24,6 +24,7 @@ import { IBuiltinAgentProfileLoader } from '#/app/agentProfileCatalog/builtinAge import { ErrorCodes, Error2 } from "#/errors"; import { IAgentIdentity } from '#/app/agentIdentity/agentIdentity'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IFlagService } from '#/app/flag/flag'; import { IConfigService } from '#/app/config/config'; import type { LoopControl } from '#/agent/loop/configSection'; import { IAgentRuntimeService } from '#/agent/runtimeBinding/agentRuntime'; @@ -63,6 +64,9 @@ import { IAgentProfileService, ProfileError, ProfileErrors } from './profile'; import { TOOLS_SECTION, type ToolsConfig } from '#/agent/toolPolicy/configSection'; import { isToolActiveComposed, findInactiveToolPatterns, literalToolNames, type InactiveToolPattern } from '#/agent/toolPolicy/evaluate'; import { IAgentToolRegistryService } from '#/agent/toolRegistry/toolRegistry'; +import { notifyUserAvailable } from '#/features/notify/notifyUserAvailability'; +import { NOTIFY_USER_TOOL_NAME } from '#/features/notify/tools/notify-user/notify-user'; +import { MAIN_AGENT_ID } from '#/session/agentLifecycle/agentLifecycle'; import { getAgentToolContributions } from '#/agent/toolRegistry/toolContribution'; import { profileActiveToolsKey, @@ -149,6 +153,7 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ @IAgentRuntimeService private readonly runtime: IAgentRuntimeService, @ISessionContext private readonly sessionContext: ISessionContext, @IBootstrapService private readonly bootstrap: IBootstrapService, + @IFlagService private readonly flags: IFlagService, @ISessionWorkspaceContext private readonly workspace: ISessionWorkspaceContext, @ISessionAgentProfileCatalog private readonly catalog: ISessionAgentProfileCatalog, @ISessionSkillCatalog private readonly skillCatalog: ISessionSkillCatalog, @@ -837,6 +842,10 @@ export class AgentProfileService extends Disposable implements IAgentProfileServ skillActive: this.isToolActiveForProfile(profile, 'Skill'), productName: (await this.identity.resolved()).displayName, replyStyleGuide: this.bootstrap.args.replyStyleGuide, + notifyUserActive: + notifyUserAvailable(this.flags, this.bootstrap) && + this.scopeContext.agentId === MAIN_AGENT_ID && + this.isToolActiveForProfile(profile, NOTIFY_USER_TOOL_NAME), }; } diff --git a/packages/agent-core-v2/src/agent/tools/mainAgentOnly.ts b/packages/agent-core-v2/src/agent/tools/mainAgentOnly.ts index 77dccbe4d1b..c0527bc49c7 100644 --- a/packages/agent-core-v2/src/agent/tools/mainAgentOnly.ts +++ b/packages/agent-core-v2/src/agent/tools/mainAgentOnly.ts @@ -6,6 +6,8 @@ export const CRON_MAIN_AGENT_ONLY = 'Cron tools are only supported by the main a export const GOAL_MAIN_AGENT_ONLY = 'Goal tools are only supported by the main agent.'; +export const NOTIFY_USER_MAIN_AGENT_ONLY = 'NotifyUser is only supported by the main agent.'; + export function mainAgentOnlyExecution( scopeContext: IAgentScopeContext, output: string, diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts index 5dc153ace05..aef8a3a4ebe 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/agentProfileCatalog.ts @@ -22,6 +22,7 @@ export interface AgentProfileContext { readonly pluginSections?: string; readonly productName?: string; readonly replyStyleGuide?: string; + readonly notifyUserActive?: boolean; readonly [key: string]: unknown; } diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts index 4dfa3df8f6a..716ff872538 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/profile-shared.ts @@ -109,6 +109,9 @@ export const DEFAULT_PRODUCT_NAME = 'Kimi Code CLI'; export const DEFAULT_REPLY_STYLE_GUIDE = "Your text replies render as Markdown in the user's terminal. Keep structure light and shallow — deep nesting, large tables, and heavy headings read poorly there. Cite code locations as `path/to/file.ts:42` so the user can navigate to them. Do not use emoji unless the user does first or asks for it."; +export const NOTIFY_USER_GUIDANCE = + 'The `NotifyUser` tool is your channel to the user while you work — use it early and often. The user cannot see your reasoning or your tool calls, only these updates, so send one as soon as you have a plan for a non-trivial task, whenever a phase concludes, before any long-running step, the moment you find something the user should know, and when you are stuck. Err on the side of sending: a user who knows what you are doing can correct you early, while silence reads as being lost.'; + const ADDITIONAL_DIRS_SECTION_PROSE = 'The following directories have been added to the workspace. You can read, write, search, and glob files in these directories as part of your workspace scope.'; @@ -135,6 +138,7 @@ export function systemPromptVars( role_additional: '', product_name: context.productName ?? DEFAULT_PRODUCT_NAME, reply_style_guide: context.replyStyleGuide ?? DEFAULT_REPLY_STYLE_GUIDE, + notify_user_guidance: context.notifyUserActive === true ? ` ${NOTIFY_USER_GUIDANCE}` : '', os: context.osKind ?? '', windows_notes: context.osKind === 'Windows' ? `\n\n${WINDOWS_NOTES}\n\n` : '', shell: shellName.length > 0 ? `${shellName} (\`${shellPath}\`)` : '', diff --git a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md index 8d8c65075ae..fba00f8239f 100644 --- a/packages/agent-core-v2/src/app/agentProfileCatalog/system.md +++ b/packages/agent-core-v2/src/app/agentProfileCatalog/system.md @@ -10,7 +10,7 @@ Match the user's language. ${reply_style_guide} -Text between tool calls may not be shown to the user, so keep it to brief status notes. Everything the user needs from this turn — answers, findings, deliverables — must appear in your final message, which should stand on its own. +Text between tool calls may not be shown to the user, so keep it to brief status notes.${notify_user_guidance} Everything the user needs from this turn — answers, findings, deliverables — must appear in your final message, which should stand on its own. In your final answer, focus on the most important information. Use structure — headings, lists, tables — only when the content calls for it, and keep explanations as brief as the subject allows. Prefer plain language over jargon: spell out terms the reader may not know. diff --git a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts index 809daa5eaac..3e969174f86 100644 --- a/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts +++ b/packages/agent-core-v2/src/app/bootstrap/bootstrap.ts @@ -15,6 +15,8 @@ import { FileStorageService } from '#/persistence/backends/node-fs/fileStorageSe import { FileSkillDiscovery } from '#/features/skill/catalog/fileSkillDiscovery'; import { ISkillDiscovery } from '#/features/skill/catalog/skillDiscovery'; +export type HostUiCapability = 'update_panel'; + export interface HostArgs { readonly agentFiles?: readonly string[]; readonly skillDirs?: readonly string[]; @@ -22,6 +24,7 @@ export interface HostArgs { readonly displayName?: string; readonly replyStyleGuide?: string; readonly nonInteractive?: boolean; + readonly uiCapabilities?: readonly HostUiCapability[]; } export interface HostArgsInput { @@ -31,6 +34,7 @@ export interface HostArgsInput { readonly displayName?: string; readonly replyStyleGuide?: string; readonly nonInteractive?: boolean; + readonly uiCapabilities?: readonly HostUiCapability[]; } export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { @@ -41,6 +45,7 @@ export function resolveHostArgs(input: HostArgsInput | undefined): HostArgs { displayName: input?.displayName, replyStyleGuide: input?.replyStyleGuide, nonInteractive: input?.nonInteractive, + uiCapabilities: input?.uiCapabilities, }; } diff --git a/packages/agent-core-v2/src/features/notify/flag.ts b/packages/agent-core-v2/src/features/notify/flag.ts new file mode 100644 index 00000000000..9161a3fcfd1 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/flag.ts @@ -0,0 +1,16 @@ +import { type FlagDefinitionInput, registerFlagDefinition } from '#/app/flag/flagRegistry'; + +export const NOTIFY_USER_FLAG_ID = 'notify_user'; +export const NOTIFY_USER_FLAG_ENV = 'KIMI_CODE_EXPERIMENTAL_NOTIFY_USER'; + +export const notifyUserFlag: FlagDefinitionInput = { + id: NOTIFY_USER_FLAG_ID, + title: 'NotifyUser tool', + description: + 'Give the model the NotifyUser tool so it can show the user short progress updates while a turn is still running. Only hosts that render the update panel (the TUI) offer the tool.', + env: NOTIFY_USER_FLAG_ENV, + default: false, + surface: 'core', +}; + +registerFlagDefinition(notifyUserFlag); diff --git a/packages/agent-core-v2/src/features/notify/notifyFeature.ts b/packages/agent-core-v2/src/features/notify/notifyFeature.ts new file mode 100644 index 00000000000..6e6724dffcf --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/notifyFeature.ts @@ -0,0 +1,24 @@ +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IFlagService } from '#/app/flag/flag'; +import { Feature } from '#/features/feature'; +import { registerFeature } from '#/features/featureRegistry'; + +import { notifyUserAvailable } from './notifyUserAvailability'; +import { INotifyUserTool, NOTIFY_USER_TOOL_NAME } from './tools/notify-user/notify-user'; +import { NotifyUserTool } from './tools/notify-user/notifyUserTool'; + +export class NotifyFeature extends Feature { + static override readonly name = 'notify'; + + constructor() { + super(); + this.contributeTool(INotifyUserTool, NotifyUserTool, { + name: NOTIFY_USER_TOOL_NAME, + domain: 'notify', + when: (accessor) => + notifyUserAvailable(accessor.get(IFlagService), accessor.get(IBootstrapService)), + }); + } +} + +registerFeature(NotifyFeature); diff --git a/packages/agent-core-v2/src/features/notify/notifyUserAvailability.ts b/packages/agent-core-v2/src/features/notify/notifyUserAvailability.ts new file mode 100644 index 00000000000..8084ecc62bc --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/notifyUserAvailability.ts @@ -0,0 +1,11 @@ +import type { HostUiCapability, IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { IFlagService } from '#/app/flag/flag'; + +import { NOTIFY_USER_FLAG_ID } from './flag'; + +export const NOTIFY_USER_UI_CAPABILITY: HostUiCapability = 'update_panel'; + +export function notifyUserAvailable(flags: IFlagService, bootstrap: IBootstrapService): boolean { + if (!flags.enabled(NOTIFY_USER_FLAG_ID)) return false; + return (bootstrap.args.uiCapabilities ?? []).includes(NOTIFY_USER_UI_CAPABILITY); +} diff --git a/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.md b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.md new file mode 100644 index 00000000000..c57ce319c93 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.md @@ -0,0 +1,16 @@ +Show the user a short update while you keep working, without ending the turn. This is your only channel to the user mid-turn: they cannot see your reasoning or your tool calls, and plain text between tool calls is easy to miss. The updates stack up in a panel right above the user's input box, so a steady stream of them is what keeps the user oriented — use this tool proactively and often, at every natural milestone, not only when something goes wrong. + +**When to use:** +1. As soon as you understand a non-trivial task: restate it in one line and give your plan, so the user can redirect you before you start. +2. Whenever a phase finishes or the picture changes: report the conclusion, not the process — "the login module is clean; the bug must be in session expiry", not "I read three files". +3. Before any long-running step (a full build, a test suite, a dependency install): say what is about to run and roughly how long it takes, so silence afterwards reads as expected. +4. The moment you find something the user should know: the root cause, a flaw in the request itself, a surprise that changes the plan. +5. When you are stuck: say what you tried, why it failed, and what you will try next. + +On a multi-phase task, send an update at every phase boundary rather than one summary at the end; if you have gone a dozen tool calls without one, you are overdue. When in doubt, send it — the user would rather see one update too many than wonder what you are doing. + +**How to use:** +- A sentence or two of light Markdown in the user's language: conclusions and next steps, not a replay of individual tool calls. +- Send it in the same response as your next tool calls — batched it costs nothing, alone it costs a whole round trip. +- The panel is cleared when the user sends their next message, so anything they must keep — answers, findings, deliverables — has to appear again in your final reply. +- Do not use it to ask a question (use AskUserQuestion) or to deliver the final answer (end the turn with a text reply instead). diff --git a/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.ts b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.ts new file mode 100644 index 00000000000..a433bef1229 --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/tools/notify-user/notify-user.ts @@ -0,0 +1,24 @@ +import { z } from 'zod'; + +import { createDecorator } from '#/_base/di/instantiation'; +import { type AgentTool } from '#/tool/toolContract'; + +export const NOTIFY_USER_TOOL_NAME = 'NotifyUser' as const; + +export interface NotifyUserInput { + message: string; +} + +export const NotifyUserInputSchema: z.ZodType = z.object({ + message: z + .string() + .min(1) + .describe( + "The update to show the user: a sentence or two of light Markdown in the user's language.", + ), +}); + +export interface INotifyUserTool extends AgentTool { + readonly _serviceBrand: undefined; +} +export const INotifyUserTool = createDecorator('notifyUserTool'); diff --git a/packages/agent-core-v2/src/features/notify/tools/notify-user/notifyUserTool.ts b/packages/agent-core-v2/src/features/notify/tools/notify-user/notifyUserTool.ts new file mode 100644 index 00000000000..35ad41ef73e --- /dev/null +++ b/packages/agent-core-v2/src/features/notify/tools/notify-user/notifyUserTool.ts @@ -0,0 +1,38 @@ +import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { mainAgentOnlyExecution, NOTIFY_USER_MAIN_AGENT_ONLY } from '#/agent/tools/mainAgentOnly'; +import { toInputJsonSchema } from '#/tool/input-schema'; +import { ToolAccesses, type ToolExecution } from '#/tool/toolContract'; + +import { + INotifyUserTool, + NOTIFY_USER_TOOL_NAME, + NotifyUserInputSchema, + type NotifyUserInput, +} from './notify-user'; +import DESCRIPTION from './notify-user.md?raw'; + +export const NOTIFY_USER_DELIVERED_OUTPUT = 'Update shown to the user.'; +export const NOTIFY_USER_EMPTY_MESSAGE = 'message must not be empty.'; + +export class NotifyUserTool implements INotifyUserTool { + declare readonly _serviceBrand: undefined; + readonly name = NOTIFY_USER_TOOL_NAME; + readonly description: string = DESCRIPTION; + readonly parameters: Record = toInputJsonSchema(NotifyUserInputSchema); + + constructor(@IAgentScopeContext private readonly scopeContext: IAgentScopeContext) {} + + resolveExecution(args: NotifyUserInput): ToolExecution { + const denied = mainAgentOnlyExecution(this.scopeContext, NOTIFY_USER_MAIN_AGENT_ONLY); + if (denied !== undefined) return denied; + if (args.message.trim().length === 0) { + return { isError: true, output: NOTIFY_USER_EMPTY_MESSAGE }; + } + return { + description: 'Notifying the user', + accesses: ToolAccesses.none(), + approvalRule: this.name, + execute: async () => ({ isError: false, output: NOTIFY_USER_DELIVERED_OUTPUT }), + }; + } +} diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index 9879bd9103a..bac1f8c7274 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -728,6 +728,10 @@ export * from '#/features/todo/todoListReminder'; export * from '#/features/todo/todoService'; export * from '#/features/todo/tools/todo-list/todo-list'; import '#/features/todo/todoFeature'; +export * from '#/features/notify/flag'; +export * from '#/features/notify/notifyUserAvailability'; +export * from '#/features/notify/tools/notify-user/notify-user'; +import '#/features/notify/notifyFeature'; export * from '#/tool/toolContract'; export * from '#/agent/toolExecutor/toolHooks'; export * from '#/agent/toolExecutor/toolExecutor'; diff --git a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts index 58095e54f3b..f58eac0688a 100644 --- a/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts +++ b/packages/agent-core-v2/src/session/agentLifecycle/profile/profiles.ts @@ -30,6 +30,7 @@ const AGENT_TOOLS = [ 'AgentSwarm', 'FetchURL', 'AskUserQuestion', + 'NotifyUser', 'EnterPlanMode', 'ExitPlanMode', 'CreateGoal', diff --git a/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts b/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts index 7fc6683efac..717ea907cb1 100644 --- a/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts +++ b/packages/agent-core-v2/test/agent/permissionPolicy/policies/default-tool-approve.test.ts @@ -46,6 +46,7 @@ describe('DefaultToolApprovePermissionPolicyService', () => { ['ReadMediaFile', { path: '/workspace/image.png' }], ['SetTodoList', { items: [] }], ['TodoList', {}], + ['NotifyUser', { message: 'Reading the parser first.' }], ['TaskList', {}], ['TaskOutput', { task_id: 'task_1' }], ['CronList', {}], diff --git a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts index 8ebf9369072..d7d8783b917 100644 --- a/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts +++ b/packages/agent-core-v2/test/app/agentProfileCatalog/profile-shared.test.ts @@ -511,3 +511,13 @@ describe('withoutDelegatingTargets', () => { ]); }); }); + +describe('systemPromptVars notify_user_guidance', () => { + it('injects the NotifyUser guidance only when the context marks the tool active', () => { + const active = systemPromptVars({ notifyUserActive: true }, { skillActive: false }); + expect(active['notify_user_guidance']).toMatch(/^ The `NotifyUser` tool is your channel/); + + expect(systemPromptVars({ notifyUserActive: false }, { skillActive: false })['notify_user_guidance']).toBe(''); + expect(systemPromptVars({}, { skillActive: false })['notify_user_guidance']).toBe(''); + }); +}); diff --git a/packages/agent-core-v2/test/features/notify/tools/notify-user.test.ts b/packages/agent-core-v2/test/features/notify/tools/notify-user.test.ts new file mode 100644 index 00000000000..7ecc4840d3e --- /dev/null +++ b/packages/agent-core-v2/test/features/notify/tools/notify-user.test.ts @@ -0,0 +1,125 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; + +import { makeAgentScopeContext } from '#/agent/scopeContext/scopeContext'; +import { IAgentToolPolicyService } from '#/agent/toolPolicy/toolPolicy'; +import { NOTIFY_USER_MAIN_AGENT_ONLY } from '#/agent/tools/mainAgentOnly'; +import type { HostUiCapability, IBootstrapService } from '#/app/bootstrap/bootstrap'; +import type { IFlagService } from '#/app/flag/flag'; +import { NOTIFY_USER_FLAG_ENV, NOTIFY_USER_FLAG_ID, notifyUserFlag } from '#/features/notify/flag'; +import { + NOTIFY_USER_UI_CAPABILITY, + notifyUserAvailable, +} from '#/features/notify/notifyUserAvailability'; +import { + INotifyUserTool, + NOTIFY_USER_TOOL_NAME, + NotifyUserInputSchema, +} from '#/features/notify/tools/notify-user/notify-user'; +import { + NOTIFY_USER_DELIVERED_OUTPUT, + NOTIFY_USER_EMPTY_MESSAGE, + NotifyUserTool, +} from '#/features/notify/tools/notify-user/notifyUserTool'; +import { executeTool } from '../../../tools/fixtures/execute-tool'; + +import { createTestAgent, type TestAgentContext } from '../../../harness'; + +const signal = new AbortController().signal; + +describe('NotifyUserTool', () => { + let ctx: TestAgentContext; + + beforeEach(async () => { + ctx = createTestAgent(); + await ctx.restorePersisted(); + }); + + afterEach(async () => { + await ctx.dispose(); + }); + + it('has name, description, and parameters from the current schema', () => { + const tool = ctx.get(INotifyUserTool); + + expect(NOTIFY_USER_TOOL_NAME).toBe('NotifyUser'); + expect(tool.name).toBe(NOTIFY_USER_TOOL_NAME); + expect(tool.description).toContain('When to use'); + expect(NotifyUserInputSchema.safeParse({ message: 'Reading the parser first.' }).success).toBe(true); + expect(NotifyUserInputSchema.safeParse({ message: '' }).success).toBe(false); + expect(NotifyUserInputSchema.safeParse({}).success).toBe(false); + expect(tool.parameters).toMatchObject({ + type: 'object', + additionalProperties: false, + required: ['message'], + properties: { + message: { type: 'string' }, + }, + }); + }); + + it('is an experimental, off-by-default flag that the default profile allows', () => { + expect(ctx.get(IAgentToolPolicyService).isToolActive(NOTIFY_USER_TOOL_NAME)).toBe(true); + expect(notifyUserFlag.id).toBe(NOTIFY_USER_FLAG_ID); + expect(notifyUserFlag.env).toBe(NOTIFY_USER_FLAG_ENV); + expect(notifyUserFlag.default).toBe(false); + }); + + it('is offered only when the flag is on and the host renders the update panel', () => { + const flags = (enabled: boolean) => ({ enabled: () => enabled }) as unknown as IFlagService; + const host = (uiCapabilities?: readonly HostUiCapability[]) => + ({ args: { requestHeaders: {}, uiCapabilities } }) as unknown as IBootstrapService; + + expect(notifyUserAvailable(flags(true), host([NOTIFY_USER_UI_CAPABILITY]))).toBe(true); + expect(notifyUserAvailable(flags(true), host([]))).toBe(false); + expect(notifyUserAvailable(flags(true), host(undefined))).toBe(false); + expect(notifyUserAvailable(flags(false), host([NOTIFY_USER_UI_CAPABILITY]))).toBe(false); + }); + + it('acknowledges the update without touching any resource', async () => { + const tool = ctx.get(INotifyUserTool); + const execution = tool.resolveExecution({ message: 'Login module is clean; the bug is in session expiry.' }); + + expect(execution).toMatchObject({ + description: 'Notifying the user', + approvalRule: NOTIFY_USER_TOOL_NAME, + accesses: [], + }); + + const result = await executeTool(tool, { + turnId: 1, + toolCallId: 'call_1', + args: { message: 'Login module is clean; the bug is in session expiry.' }, + signal, + }); + + expect(result).toEqual({ isError: false, output: NOTIFY_USER_DELIVERED_OUTPUT }); + }); + + it('rejects a whitespace-only message before execution', async () => { + const tool = ctx.get(INotifyUserTool); + + const result = await executeTool(tool, { + turnId: 1, + toolCallId: 'call_1', + args: { message: ' \n' }, + signal, + }); + + expect(result).toEqual({ isError: true, output: NOTIFY_USER_EMPTY_MESSAGE }); + }); + + it('refuses to run on a subagent', async () => { + const tool = new NotifyUserTool( + makeAgentScopeContext({ agentId: 'agent-1', agentScope: '' }), + ); + + const result = await executeTool(tool, { + turnId: 1, + toolCallId: 'call_1', + args: { message: 'Should not be shown.' }, + signal, + }); + + expect(result).toEqual({ isError: true, output: NOTIFY_USER_MAIN_AGENT_ONLY }); + }); +}); diff --git a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts index f8895d79ce0..f5762f8071a 100644 --- a/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts +++ b/packages/agent-core-v2/test/session/agentLifecycle/agentLifecycle.test.ts @@ -65,6 +65,7 @@ import { AgentTodoService, IAgentTodoService } from '#/features/todo/todoService import '#/agent/toolDedupe/toolDedupeService'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; import { IConfigService } from '#/app/config/config'; +import { IFlagService } from '#/app/flag/flag'; import { ISessionEventBus } from '#/app/event/eventBus'; import { EventBusService } from '#/app/event/eventBusService'; import '#/app/event/eventBusService'; @@ -257,6 +258,10 @@ describe('AgentLifecycleService', () => { homeDir: '/tmp/kimi-agentLifecycle-home', cwd: '/tmp/kimi-agentLifecycle-home', } as unknown as IBootstrapService); + ix.stub(IFlagService, { + _serviceBrand: undefined, + enabled: () => false, + } as unknown as IFlagService); ix.stub(ISessionWorkspaceContext, { _serviceBrand: undefined, workDir: '/tmp/kimi-agentLifecycle-work', diff --git a/packages/node-sdk/src/sdk-rpc-client-v2.ts b/packages/node-sdk/src/sdk-rpc-client-v2.ts index db57f0e2905..ede41a6384e 100644 --- a/packages/node-sdk/src/sdk-rpc-client-v2.ts +++ b/packages/node-sdk/src/sdk-rpc-client-v2.ts @@ -185,6 +185,7 @@ import { ISessionTokenCountingService, IAgentToolPolicyService, IAgentToolRegistryService, + type HostUiCapability, IAgentTowerService, IBootstrapService, IConfigService, @@ -362,6 +363,8 @@ export interface SDKRpcClientV2Options { readonly telemetry?: TelemetryClient; readonly onOAuthRefresh?: (outcome: OAuthRefreshOutcome) => void; readonly uiMode?: string; + /** UI surfaces this host renders; forwarded as `BootstrapInput.args.uiCapabilities`. */ + readonly uiCapabilities?: readonly HostUiCapability[]; } /** @@ -461,6 +464,7 @@ export class SDKRpcClientV2 extends SDKRpcClientBase { // `--skills-dir` (v1 parity): explicit skill dirs replace default // user / project discovery for every session this client hosts. skillDirs: options.skillDirs, + uiCapabilities: options.uiCapabilities, }, }, [...logSeed(resolveLoggingConfig({ homeDir: this.homeDir, env: process.env }))], diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 5cb25841b17..08aa6fdd877 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -1,3 +1,4 @@ +import type { HostUiCapability } from '@moonshot-ai/agent-core-v2'; import type { ExportSessionManifest, ResumeSessionResult, @@ -85,6 +86,9 @@ export type { } from '@moonshot-ai/agent-core'; export type { KimiHostIdentity, OAuthRefreshOutcome }; +// Host UI capabilities are an agent-core-v2 seam (`BootstrapInput.args.uiCapabilities`); +// hosts name them through `KimiHarnessOptions.uiCapabilities`, so the type is public here. +export type { HostUiCapability }; export type { TelemetryClient, TelemetryContextPatch, TelemetryProperties }; export type { ContentPart, Role, ThinkingEffort, ToolCall } from '@moonshot-ai/kosong'; // Contributed commands are an agent-core-v2 seam; the type is re-exported @@ -169,6 +173,14 @@ export interface KimiHarnessOptions { readonly autoLoadConfig?: boolean | undefined; readonly uiMode?: string; readonly skillDirs?: readonly string[]; + /** + * UI surfaces this host can render, declared once per process and passed + * into the engine through `BootstrapInput.args.uiCapabilities`. Engine + * features gate on them at tool-table build time; nothing is persisted, so + * a session opened later by a host without the capability simply does not + * offer the dependent tool. + */ + readonly uiCapabilities?: readonly HostUiCapability[]; readonly telemetry?: TelemetryClient | undefined; readonly onOAuthRefresh?: ((outcome: OAuthRefreshOutcome) => void) | undefined; readonly sessionStartedProperties?: TelemetryProperties; diff --git a/packages/node-sdk/test/v1-v2-parity.test.ts b/packages/node-sdk/test/v1-v2-parity.test.ts index 6770f8386db..a05cd18ea82 100644 --- a/packages/node-sdk/test/v1-v2-parity.test.ts +++ b/packages/node-sdk/test/v1-v2-parity.test.ts @@ -467,9 +467,9 @@ function projectResumedAgents( * v1 additionally registers the `select_tools` meta tool v2 has no * counterpart for — both are engine design, not resume data. v2's default * profile also carries `TowerInit`/`TowerStatus`/`TowerTeardown` (the - * tower-mode control tools) and `WaitFor` (the background-task wait - * primitive); all are v2-only, so the tools are projected out of both - * rosters. A model-less + * tower-mode control tools), `WaitFor` (the background-task wait + * primitive), and `NotifyUser` (the mid-turn update panel tool); all are + * v2-only, so the tools are projected out of both rosters. A model-less * agent's roster is not compared at all (v1 initializes builtin tools * only on a profiled agent; v2 exposes them unbound). */ @@ -491,6 +491,7 @@ function projectResumedAgent(agent: ResumedAgentState, home: HomePair): unknown .filter((tool) => tool['name'] !== 'TowerStatus') .filter((tool) => tool['name'] !== 'TowerTeardown') .filter((tool) => tool['name'] !== 'WaitFor') + .filter((tool) => tool['name'] !== 'NotifyUser') .map((tool) => ({ name: tool['name'], active: tool['active'], source: tool['source'] })) .toSorted((a, b) => String(a.name).localeCompare(String(b.name))); }