From 471f92ff0f0daab0056e8957d619fa7e9fc19b06 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Fri, 31 Jul 2026 03:27:37 +0000 Subject: [PATCH 1/2] feat(hooks): include final assistant message in Stop payload --- .changeset/bright-hooks-report.md | 5 ++++ docs/en/customization/hooks.md | 2 +- docs/zh/customization/hooks.md | 2 +- packages/agent-core/src/agent/turn/index.ts | 10 ++++++- packages/agent-core/test/agent/turn.test.ts | 31 +++++++++++++++++++++ 5 files changed, 47 insertions(+), 3 deletions(-) create mode 100644 .changeset/bright-hooks-report.md diff --git a/.changeset/bright-hooks-report.md b/.changeset/bright-hooks-report.md new file mode 100644 index 0000000000..e22c780bdd --- /dev/null +++ b/.changeset/bright-hooks-report.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": patch +--- + +Include the final assistant message in Stop hook payloads. diff --git a/docs/en/customization/hooks.md b/docs/en/customization/hooks.md index 92229db000..e0c0579cd6 100644 --- a/docs/en/customization/hooks.md +++ b/docs/en/customization/hooks.md @@ -100,7 +100,7 @@ Only **blockable events** (`PreToolUse`, `Stop`, `UserPromptSubmit`) have return | --- | --- | --- | --- | | `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 | | `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 | +| `Stop` | Empty string | ✓ | Triggered when the model is about to end the current turn; the payload includes the final assistant text as `last_assistant_message`; if blocked, a message can be appended to let the model continue | | `PostToolUse` | Tool name | — | Triggered after a tool executes successfully (observation only) | | `PostToolUseFailure` | Tool name | — | Triggered after a tool fails or is blocked (observation only) | | `PermissionRequest` | Tool name | — | Triggered just before waiting for user approval (observation only) | diff --git a/docs/zh/customization/hooks.md b/docs/zh/customization/hooks.md index a88f166589..13aa65a450 100644 --- a/docs/zh/customization/hooks.md +++ b/docs/zh/customization/hooks.md @@ -100,7 +100,7 @@ Hook 命令的工作目录是当前会话的项目目录。非 Windows 平台上 | --- | --- | --- | --- | | `UserPromptSubmit` | 用户提交的文本内容 | ✓ | 用户发送消息时触发;返回文本会附加到上下文;若阻断,本轮不调用模型 | | `PreToolUse` | 工具名 | ✓ | 工具调用前触发(权限检查前);阻断后工具不会执行 | -| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;阻断后可追加一条消息让模型继续 | +| `Stop` | 空字符串 | ✓ | 模型准备结束本轮时触发;payload 通过 `last_assistant_message` 提供最后一条助手文本;阻断后可追加一条消息让模型继续 | | `PostToolUse` | 工具名 | — | 工具成功执行后触发(观察用) | | `PostToolUseFailure` | 工具名 | — | 工具失败或被阻断后触发(观察用) | | `PermissionRequest` | 工具名 | — | 即将等待用户审批前触发(观察用) | diff --git a/packages/agent-core/src/agent/turn/index.ts b/packages/agent-core/src/agent/turn/index.ts index ccdeed9399..3435bff841 100644 --- a/packages/agent-core/src/agent/turn/index.ts +++ b/packages/agent-core/src/agent/turn/index.ts @@ -8,6 +8,7 @@ import { APIProviderQuotaExhaustedError, APIStatusError, APITimeoutError, + extractText, inputTotal, isContextOverflowStatusError, type ContentPart, @@ -942,9 +943,16 @@ export class TurnFlow { // 3. The external Stop hook gets exactly one continuation; the cap // is intentionally separate from (and does not cap) goal mode. if (!stopHookContinuationUsed) { + const lastAssistant = this.agent.context.history.findLast( + (entry) => entry.role === 'assistant', + ); const stopBlock = await this.agent.hooks?.triggerBlock('Stop', { signal, - inputData: { stopHookActive: stopHookContinuationUsed }, + inputData: { + stopHookActive: stopHookContinuationUsed, + last_assistant_message: + lastAssistant === undefined ? '' : extractText(lastAssistant), + }, }); signal.throwIfAborted(); if (stopBlock !== undefined) { diff --git a/packages/agent-core/test/agent/turn.test.ts b/packages/agent-core/test/agent/turn.test.ts index 08b29fc194..3f0250a256 100644 --- a/packages/agent-core/test/agent/turn.test.ts +++ b/packages/agent-core/test/agent/turn.test.ts @@ -1466,6 +1466,37 @@ describe('Agent turn flow', () => { expect(JSON.stringify(ctx.agent.context.data().history)).toContain('Second answer.'); }); + it('includes the final assistant message in the Stop hook payload', async () => { + const script = [ + "let input='';", + "process.stdin.on('data',chunk=>input+=chunk);", + "process.stdin.on('end',()=>{", + 'const payload=JSON.parse(input);', + "process.stdout.write(JSON.stringify({hookSpecificOutput:{permissionDecision:'deny',permissionDecisionReason:payload.last_assistant_message}}));", + '});', + ].join(''); + const hookEngine = new HookEngine([ + { + event: 'Stop', + command: `node -e ${JSON.stringify(script)}`, + }, + ]); + const ctx = testAgent({ hookEngine }); + ctx.configure(); + ctx.mockNextResponse({ type: 'text', text: 'Final answer for the hook.' }); + ctx.mockNextResponse({ type: 'text', text: 'Continued after the hook.' }); + + await ctx.rpc.prompt({ input: [{ type: 'text', text: 'hello' }] }); + await ctx.untilTurnEnd(); + + expect(ctx.agent.context.data().history).toContainEqual({ + role: 'user', + content: [{ type: 'text', text: 'Final answer for the hook.' }], + toolCalls: [], + origin: { kind: 'system_trigger', name: 'stop_hook' }, + }); + }); + it('cancels while waiting for a Stop hook', async () => { const dir = mkdtempSync(join(tmpdir(), 'kimi-stop-hook-')); const marker = join(dir, 'started'); From ee99610ba051eac9806c66fda96a88c79ed2b7a6 Mon Sep 17 00:00:00 2001 From: Ben Younes Date: Fri, 31 Jul 2026 06:21:23 +0000 Subject: [PATCH 2/2] fix(hooks): include assistant text in v2 Stop payload (#2443) --- .../agent-core-v2/docs/state-manifest.d.ts | 2 +- .../externalHooks/externalHooksService.ts | 7 ++++- .../externalHooksRunner/integration.test.ts | 26 ++++++++++++++++--- 3 files changed, 30 insertions(+), 5 deletions(-) diff --git a/packages/agent-core-v2/docs/state-manifest.d.ts b/packages/agent-core-v2/docs/state-manifest.d.ts index fed980fbf1..4ea59d3d31 100644 --- a/packages/agent-core-v2/docs/state-manifest.d.ts +++ b/packages/agent-core-v2/docs/state-manifest.d.ts @@ -1004,7 +1004,7 @@ export interface AgentStateSnapshot { 'llmRequester.lastConfigLogSignature': string | undefined; 'llmRequester.mediaDegradedTurns': Set; 'llmRequester.mediaStrippedTurns': Map; 'llmRequester.turnConfigs': Map { ctx.signal.throwIfAborted(); if (this.stopHookContinuationUsed) return undefined; + const lastAssistant = this.context.get().findLast((entry) => entry.role === 'assistant'); const block = await this.runner.triggerBlock('Stop', { signal: ctx.signal, sessionId: this.sessionContext.sessionId, - inputData: { stopHookActive: false }, + inputData: { + stopHookActive: false, + last_assistant_message: lastAssistant === undefined ? '' : extractText(lastAssistant), + }, }); ctx.signal.throwIfAborted(); return block?.reason; diff --git a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts index bf76062689..f4078f82a6 100644 --- a/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts +++ b/packages/agent-core-v2/test/app/externalHooksRunner/integration.test.ts @@ -281,6 +281,12 @@ describe('IExternalHooksRunnerService integration', () => { ix.set(IAgentExternalHooksService, new SyncDescriptor(AgentExternalHooksService)); ix.get(IAgentExternalHooksService); const eventBus = ix.get(IEventBus); + const finalAssistantMessage = 'release is ready'; + context.append({ + role: 'assistant', + content: [{ type: 'text', text: finalAssistantMessage }], + toolCalls: [], + }); const signal = new AbortController().signal; const filtered: AfterStepContext = { @@ -290,7 +296,7 @@ describe('IExternalHooksRunnerService integration', () => { await loop.hooks.onDidFinishStep.run(filtered); expect(loop.hasPendingRequests()).toBe(false); expect(stopInputs).toEqual([]); - expect(context.messages).toEqual([]); + expect(context.messages).toHaveLength(1); const first = makeAfterStep(signal); await loop.hooks.onDidFinishStep.run(first); @@ -307,7 +313,12 @@ describe('IExternalHooksRunnerService integration', () => { const second = makeAfterStep(signal); await loop.hooks.onDidFinishStep.run(second); expect(loop.hasPendingRequests()).toBe(false); - expect(stopInputs).toEqual([{ stopHookActive: false }]); + expect(stopInputs).toEqual([ + { + stopHookActive: false, + last_assistant_message: finalAssistantMessage, + }, + ]); eventBus.publish({ type: 'turn.ended', @@ -327,7 +338,16 @@ describe('IExternalHooksRunnerService integration', () => { }), ); expect(loop.drainNextBatch(context)).toBeDefined(); - expect(stopInputs).toEqual([{ stopHookActive: false }, { stopHookActive: false }]); + expect(stopInputs).toEqual([ + { + stopHookActive: false, + last_assistant_message: finalAssistantMessage, + }, + { + stopHookActive: false, + last_assistant_message: finalAssistantMessage, + }, + ]); } finally { ix?.dispose(); disposables.dispose();