From e86d7a360cf450d4aac8086ce3cd2a45fa11ed2d Mon Sep 17 00:00:00 2001 From: tt-a1i <53142663+tt-a1i@users.noreply.github.com> Date: Sat, 20 Jun 2026 11:11:58 +0800 Subject: [PATCH] feat(hooks): suppress user prompt hook display --- .changeset/hook-suppress-tui-display.md | 5 + .../src/tui/controllers/session-replay.ts | 1 + .../kimi-code/test/tui/message-replay.test.ts | 20 +++ docs/en/customization/hooks.md | 5 +- docs/zh/customization/hooks.md | 5 +- .../agent-core/src/agent/context/types.ts | 1 + packages/agent-core/src/agent/turn/index.ts | 41 ++++--- packages/agent-core/src/config/schema.ts | 1 + packages/agent-core/src/config/toml.ts | 4 + .../agent-core/src/session/hooks/engine.ts | 5 +- .../agent-core/src/session/hooks/types.ts | 2 + .../src/session/hooks/user-prompt.ts | 52 ++++++++ packages/agent-core/test/agent/turn.test.ts | 115 ++++++++++++++++++ .../agent-core/test/config/configs.test.ts | 10 +- .../protocol/src/__tests__/events.test.ts | 22 ++++ packages/protocol/src/events.ts | 2 + 16 files changed, 267 insertions(+), 24 deletions(-) create mode 100644 .changeset/hook-suppress-tui-display.md diff --git a/.changeset/hook-suppress-tui-display.md b/.changeset/hook-suppress-tui-display.md new file mode 100644 index 000000000..e99d64822 --- /dev/null +++ b/.changeset/hook-suppress-tui-display.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add a per-hook suppress_tui_display option for UserPromptSubmit context injection. diff --git a/apps/kimi-code/src/tui/controllers/session-replay.ts b/apps/kimi-code/src/tui/controllers/session-replay.ts index 4a13fe373..6c456fdb1 100644 --- a/apps/kimi-code/src/tui/controllers/session-replay.ts +++ b/apps/kimi-code/src/tui/controllers/session-replay.ts @@ -440,6 +440,7 @@ export class SessionReplayRenderer { private renderHookResult(context: ReplayRenderContext, message: ContextMessage): void { if (message.origin?.kind !== 'hook_result') return; + if (message.origin.suppressTuiDisplay === true) return; this.flushAssistant(context); this.host.appendTranscriptEntry( replayEntry( diff --git a/apps/kimi-code/test/tui/message-replay.test.ts b/apps/kimi-code/test/tui/message-replay.test.ts index f54bac27b..b9a684f26 100644 --- a/apps/kimi-code/test/tui/message-replay.test.ts +++ b/apps/kimi-code/test/tui/message-replay.test.ts @@ -990,6 +990,26 @@ describe('KimiTUI resume message replay', () => { expect(transcript).toContain('hook response 2'); }); + it('skips replayed hook results suppressed from TUI display', async () => { + const hookResult = + '\nhidden hook response\n'; + const driver = await replayIntoDriver([ + message('user', [{ type: 'text', text: 'prompt' }]), + message('user', [{ type: 'text', text: hookResult }], { + origin: { + kind: 'hook_result', + event: 'UserPromptSubmit', + suppressTuiDisplay: true, + }, + }), + ]); + + const transcript = driver.state.transcriptContainer.render(120).join('\n'); + + expect(transcript).not.toContain('UserPromptSubmit hook'); + expect(transcript).not.toContain('hidden hook response'); + }); + it('renders replayed compaction records as completed compaction blocks', async () => { const driver = await replayIntoDriver([ message('user', [{ type: 'text', text: 'prompt before compaction' }]), diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 1f966e341..f53a35e24 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -47,8 +47,9 @@ All hook rules are written in the `[[hooks]]` array in `~/.kimi-code/config.toml | `matcher` | `string` | No | A regular expression to filter event targets; if omitted, matches all | | `command` | `string` | Yes | The shell command to run when triggered | | `timeout` | `integer` | No | Timeout in seconds, range 1–600; defaults to 30 seconds | +| `suppress_tui_display` | `boolean` | No | For `UserPromptSubmit` allow-output, inject the hook result into model context without showing it in the terminal transcript | -`[[hooks]]` only allows these four fields; extra fields will cause the config file to fail to load. +`[[hooks]]` only allows these five fields; extra fields will cause the config file to fail to load. **When multiple rules match the same event**, all matching hooks run in parallel; multiple rules with identical `command` values run only once. @@ -98,7 +99,7 @@ Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return | Event | Matcher matches | Supports blocking? | Description | | --- | --- | --- | --- | -| `UserPromptSubmit` | The text submitted by the user | ✓ | Triggered when the user sends a message; returned text is appended to context; if blocked, the model is not called for this turn | +| `UserPromptSubmit` | The text submitted by the user | ✓ | Triggered when the user sends a message; returned text is appended to context; if blocked, the model is not called for this turn. Set `suppress_tui_display = true` on a hook to hide allow-output from the terminal while still injecting it into context | | `PreToolUse` | Tool name | ✓ | Triggered before a tool call (before permission checks); the tool will not execute if blocked | | `Stop` | Empty string | ✓ | Triggered when the model is about to end the current turn; if blocked, a message can be appended to let the model continue | | `PostToolUse` | Tool name | — | Triggered after a tool executes successfully (observation only) | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index a506923b9..18c7abb05 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -47,8 +47,9 @@ command = "terminal-notifier -title Kimi -message 'Task done'" | `matcher` | `string` | 否 | 用正则表达式(一种字符串匹配语法)过滤事件目标;不填则匹配全部 | | `command` | `string` | 是 | 触发时要运行的 Shell 命令 | | `timeout` | `integer` | 否 | 超时秒数,范围 1–600;默认 30 秒 | +| `suppress_tui_display` | `boolean` | 否 | 对 `UserPromptSubmit` 的 allow 输出生效:把 hook 结果注入模型上下文,但不显示在终端 transcript 里 | -`[[hooks]]` 只允许这四个字段,多写会导致配置文件加载失败。 +`[[hooks]]` 只允许这五个字段,多写会导致配置文件加载失败。 **同一事件匹配多条规则时**,所有命中的 hook 并行运行;`command` 完全相同的多条规则只运行一次。 @@ -98,7 +99,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上 | 事件 | Matcher 匹配的是 | 会触发阻断? | 说明 | | --- | --- | --- | --- | -| `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型 | +| `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型。可以在 hook 上设置 `suppress_tui_display = true`,隐藏终端中的 allow 输出,同时仍注入上下文 | | `PreToolUse` | 工具名 | ✓ | 工具调用前触发(权限检查前);阻断后工具不会执行 | | `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;阻断后可追加一条消息让模型继续 | | `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) | diff --git a/packages/agent-core/src/agent/context/types.ts b/packages/agent-core/src/agent/context/types.ts index d0a78b975..044b4c5da 100644 --- a/packages/agent-core/src/agent/context/types.ts +++ b/packages/agent-core/src/agent/context/types.ts @@ -62,6 +62,7 @@ export interface HookResultOrigin { readonly kind: 'hook_result'; readonly event: string; readonly blocked?: boolean; + readonly suppressTuiDisplay?: boolean; } export interface RetryOrigin { diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index 7fff37876..ee7afcb8c 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -37,7 +37,10 @@ import type { AgentEvent, TurnEndedEvent } from '../../rpc'; import type { TelemetryPropertyValue } from '../../telemetry'; import { abortable, isUserCancellation, userCancellationReason } from '../../utils/abort'; import { USER_PROMPT_ORIGIN, type PromptOrigin } from '../context'; -import { renderUserPromptHookBlockResult, renderUserPromptHookResult } from '../../session/hooks'; +import { + renderUserPromptHookBlockResult, + renderUserPromptHookResultChunks, +} from '../../session/hooks'; import { canonicalTelemetryArgs, isPlainRecord } from './canonical-args'; import { ToolCallDeduplicator } from './tool-dedup'; @@ -45,7 +48,7 @@ interface ActiveTurn { readonly turnId: number; readonly controller: AbortController; readonly promise: Promise; - readonly firstRequest: ControlledPromise; + readonly firstRequest: ControlledPromise; } interface BufferedSteer { @@ -594,19 +597,21 @@ export class TurnFlow { }; } - const hookResult = renderUserPromptHookResult(promptHookResults); - if (hookResult === undefined) return undefined; - - this.agent.context.appendUserMessage([{ type: 'text', text: hookResult.text }], { - kind: 'hook_result', - event: 'UserPromptSubmit', - }); - this.agent.emitEvent({ - type: 'hook.result', - turnId, - hookEvent: hookResult.event, - content: hookResult.message, - }); + for (const hookResult of renderUserPromptHookResultChunks(promptHookResults)) { + const suppressTuiDisplay = hookResult.suppressTuiDisplay === true; + this.agent.context.appendUserMessage([{ type: 'text', text: hookResult.text }], { + kind: 'hook_result', + event: 'UserPromptSubmit', + ...(suppressTuiDisplay ? { suppressTuiDisplay: true } : {}), + }); + if (suppressTuiDisplay) continue; + this.agent.emitEvent({ + type: 'hook.result', + turnId, + hookEvent: hookResult.event, + content: hookResult.message, + }); + } return undefined; } @@ -794,6 +799,12 @@ export class TurnFlow { active.firstRequest.resolve(); return; } + case 'step.begin': + case 'step.retrying': + case 'tool.progress': + case 'tool.result': + case 'turn.interrupted': + return; default: return; } diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 9b3d11cf0..fd88af45f 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -114,6 +114,7 @@ export const HookDefSchema = z matcher: z.string().optional(), command: z.string().min(1), timeout: z.number().int().min(1).max(600).optional(), + suppressTuiDisplay: z.boolean().optional(), }) .strict(); diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index 172e97cfc..3ee334db0 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -312,6 +312,10 @@ export function transformTomlData(data: Record): Record + isPlainObject(entry) ? transformPlainObject(entry) : entry, + ); } else if (targetKey === 'experimental' && isPlainObject(value)) { result[targetKey] = cloneRecord(value); } else if (!isPlainObject(value)) { diff --git a/packages/agent-core/src/session/hooks/engine.ts b/packages/agent-core/src/session/hooks/engine.ts index 832ecd620..aa2e1e6a5 100644 --- a/packages/agent-core/src/session/hooks/engine.ts +++ b/packages/agent-core/src/session/hooks/engine.ts @@ -87,7 +87,10 @@ export class HookEngine { timeout: hook.timeout ?? DEFAULT_HOOK_TIMEOUT_SECONDS, cwd: this.options.cwd === '' ? undefined : this.options.cwd, signal: args.signal, - }), + }).then((result) => ({ + ...result, + suppressTuiDisplay: hook.suppressTuiDisplay === true, + })), ), ); const { action, reason } = aggregateResults(event, results); diff --git a/packages/agent-core/src/session/hooks/types.ts b/packages/agent-core/src/session/hooks/types.ts index cf05ca747..75eee021f 100644 --- a/packages/agent-core/src/session/hooks/types.ts +++ b/packages/agent-core/src/session/hooks/types.ts @@ -26,6 +26,7 @@ export interface HookDef { readonly matcher?: string; readonly command: string; readonly timeout?: number; + readonly suppressTuiDisplay?: boolean; } export interface HookResult { @@ -37,6 +38,7 @@ export interface HookResult { readonly exitCode?: number; readonly timedOut?: boolean; readonly structuredOutput?: boolean; + readonly suppressTuiDisplay?: boolean; } export interface HookBlockDecision { diff --git a/packages/agent-core/src/session/hooks/user-prompt.ts b/packages/agent-core/src/session/hooks/user-prompt.ts index 1b81c9f61..e3e7c764a 100644 --- a/packages/agent-core/src/session/hooks/user-prompt.ts +++ b/packages/agent-core/src/session/hooks/user-prompt.ts @@ -8,14 +8,21 @@ export interface RenderedHookResult { readonly event: string; readonly message: string; readonly text: string; + readonly suppressTuiDisplay?: boolean; +} + +export interface RenderUserPromptHookResultOptions { + readonly suppressTuiDisplay?: boolean; } export function renderUserPromptHookResult( results: readonly HookResult[] | undefined, + options: RenderUserPromptHookResultOptions = {}, ): RenderedHookResult | undefined { const messages = results ?.filter((result) => result.action !== 'block') + ?.filter((result) => matchesDisplayFilter(result, options.suppressTuiDisplay)) ?.map(userPromptHookMessage) .filter(isNonEmptyString) ?? []; @@ -28,6 +35,33 @@ export function renderUserPromptHookResult( }; } +export function renderUserPromptHookResultChunks( + results: readonly HookResult[] | undefined, +): readonly RenderedHookResult[] { + const rendered: RenderedHookResult[] = []; + let current: { suppressTuiDisplay: boolean; messages: string[] } | undefined; + + for (const result of results ?? []) { + if (result.action === 'block') continue; + const message = userPromptHookMessage(result); + if (message === undefined) continue; + const suppressTuiDisplay = result.suppressTuiDisplay === true; + + if (current === undefined || current.suppressTuiDisplay !== suppressTuiDisplay) { + if (current !== undefined) { + rendered.push(renderMessages(current.messages, current.suppressTuiDisplay)); + } + current = { suppressTuiDisplay, messages: [] }; + } + current.messages.push(message); + } + + if (current !== undefined) { + rendered.push(renderMessages(current.messages, current.suppressTuiDisplay)); + } + return rendered; +} + export function renderUserPromptHookBlockResult( results: readonly HookResult[] | undefined, ): RenderedHookResult | undefined { @@ -64,3 +98,21 @@ function userPromptHookMessage(result: HookResult): string | undefined { function isNonEmptyString(value: string | undefined): value is string { return value !== undefined && value.length > 0; } + +function renderMessages(messages: readonly string[], suppressTuiDisplay = false): RenderedHookResult { + const displayMessage = messages.join('\n\n'); + return { + event: 'UserPromptSubmit', + message: displayMessage, + text: messages.map((message) => renderHookResult('UserPromptSubmit', message)).join('\n'), + suppressTuiDisplay: suppressTuiDisplay ? true : undefined, + }; +} + +function matchesDisplayFilter( + result: HookResult, + suppressTuiDisplay: boolean | undefined, +): boolean { + if (suppressTuiDisplay === undefined) return true; + return (result.suppressTuiDisplay === true) === suppressTuiDisplay; +} diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index e39094fb4..90b82755c 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -509,6 +509,120 @@ describe('Agent turn flow', () => { ]); }); + it('injects suppressed UserPromptSubmit hook output without emitting a visible hook result', async () => { + const hookEngine = new HookEngine([ + { + event: 'UserPromptSubmit', + matcher: 'hooked input', + command: "echo 'hidden hook response'", + suppressTuiDisplay: true, + }, + ]); + const ctx = testAgent({ hookEngine }); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'model saw hidden hook context' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hooked input' }] }); + const events = await ctx.untilTurnEnd(); + + const hookResult = + '\nhidden hook response\n'; + expect(ctx.llmCalls).toHaveLength(1); + expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` + system: + tools: [] + messages: + user: text "hooked input" + user: text "\\nhidden hook response\\n" + `); + expect(events).not.toContainEqual( + expect.objectContaining({ + event: 'hook.result', + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ + event: 'assistant.delta', + args: expect.objectContaining({ delta: 'model saw hidden hook context' }), + }), + ); + expect(ctx.agent.context.data().history).toEqual([ + { + role: 'user', + content: [{ type: 'text', text: 'hooked input' }], + toolCalls: [], + origin: { kind: 'user' }, + }, + { + role: 'user', + content: [{ type: 'text', text: hookResult }], + toolCalls: [], + origin: { + kind: 'hook_result', + event: 'UserPromptSubmit', + suppressTuiDisplay: true, + }, + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'model saw hidden hook context' }], + toolCalls: [], + }, + ]); + }); + + it('preserves UserPromptSubmit hook output order when only some results are hidden', async () => { + const hookEngine = new HookEngine([ + { + event: 'UserPromptSubmit', + matcher: 'hooked input', + command: "echo 'visible hook before'", + }, + { + event: 'UserPromptSubmit', + matcher: 'hooked input', + command: "echo 'hidden hook middle'", + suppressTuiDisplay: true, + }, + { + event: 'UserPromptSubmit', + matcher: 'hooked input', + command: "echo 'visible hook after'", + }, + ]); + const ctx = testAgent({ hookEngine }); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'model saw ordered hook context' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hooked input' }] }); + const events = await ctx.untilTurnEnd(); + + expect(ctx.llmCalls).toHaveLength(1); + expect(ctx.lastLlmInput()).toMatchInlineSnapshot(` + system: + tools: [] + messages: + user: text "hooked input" + user: text "\\nvisible hook before\\n" + user: text "\\nhidden hook middle\\n" + user: text "\\nvisible hook after\\n" + `); + + const hookResultContents = (events as Array<{ event?: string; args?: { content?: unknown } }>) + .filter((event) => event.event === 'hook.result') + .map((event) => event.args?.content); + expect(hookResultContents).toEqual(['visible hook before', 'visible hook after']); + + const hookOrigins = ctx.agent.context.data().history + .filter((message) => message.origin?.kind === 'hook_result') + .map((message) => message.origin); + expect(hookOrigins).toEqual([ + { kind: 'hook_result', event: 'UserPromptSubmit' }, + { kind: 'hook_result', event: 'UserPromptSubmit', suppressTuiDisplay: true }, + { kind: 'hook_result', event: 'UserPromptSubmit' }, + ]); + }); + it('projects structured UserPromptSubmit stdout', async () => { const hookEngine = new HookEngine([ { @@ -578,6 +692,7 @@ describe('Agent turn flow', () => { event: 'UserPromptSubmit', matcher: 'bad words', command: "echo 'no profanity' >&2; exit 2", + suppressTuiDisplay: true, }, ]); const ctx = testAgent({ hookEngine }); diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index 091eee384..404ce2ba7 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -423,20 +423,22 @@ pattern = "Bash(rm *" const config = parseConfigString( ` [[hooks]] -event = "PreToolUse" -matcher = "Shell" +event = "UserPromptSubmit" +matcher = "prompt" command = "echo hi" timeout = 5 +suppress_tui_display = true `, 'hooks.toml', ); expect(config.hooks).toEqual([ { - event: 'PreToolUse', - matcher: 'Shell', + event: 'UserPromptSubmit', + matcher: 'prompt', command: 'echo hi', timeout: 5, + suppressTuiDisplay: true, }, ]); }); diff --git a/packages/protocol/src/__tests__/events.test.ts b/packages/protocol/src/__tests__/events.test.ts index b53d80215..18dbc5912 100644 --- a/packages/protocol/src/__tests__/events.test.ts +++ b/packages/protocol/src/__tests__/events.test.ts @@ -103,6 +103,28 @@ describe('events / display re-exports', () => { expect(parsed.sessionId).toBe('sess_1'); }); + it('preserves hook-result display suppression in prompt origins', () => { + const parsed = eventSchema.parse({ + type: 'turn.started', + agentId: 'agent_1', + sessionId: 'sess_1', + turnId: 1, + origin: { + kind: 'hook_result', + event: 'UserPromptSubmit', + suppressTuiDisplay: true, + }, + }); + + expect(parsed.type).toBe('turn.started'); + if (parsed.type !== 'turn.started') throw new Error('expected turn.started'); + expect(parsed.origin).toEqual({ + kind: 'hook_result', + event: 'UserPromptSubmit', + suppressTuiDisplay: true, + }); + }); + it('validates prompt.submitted events', () => { const parsed = eventSchema.parse({ type: 'prompt.submitted', diff --git a/packages/protocol/src/events.ts b/packages/protocol/src/events.ts index 49224b6f6..47bcdec1d 100644 --- a/packages/protocol/src/events.ts +++ b/packages/protocol/src/events.ts @@ -94,6 +94,7 @@ export interface HookResultOrigin { readonly kind: 'hook_result'; readonly event: string; readonly blocked?: boolean; + readonly suppressTuiDisplay?: boolean; } export interface RetryOrigin { @@ -718,6 +719,7 @@ export const hookResultOriginSchema = z.object({ kind: z.literal('hook_result'), event: z.string(), blocked: z.boolean().optional(), + suppressTuiDisplay: z.boolean().optional(), }) satisfies z.ZodType; export const retryOriginSchema = z.object({