From 4794361b50f58db2fd48428c93ddf4977a727dc9 Mon Sep 17 00:00:00 2001 From: Cameron Date: Wed, 28 Jan 2026 16:28:23 -0800 Subject: [PATCH] feat(hooks): capture reasoning and assistant messages in hooks (#719) --- src/cli/App.tsx | 14 ++++++++ src/cli/helpers/accumulator.ts | 19 ++++++++++ src/hooks/index.ts | 8 +++++ src/hooks/types.ts | 8 +++++ src/tests/hooks/integration.test.ts | 55 +++++++++++++++++++++++++++++ src/tools/manager.ts | 9 +++++ 6 files changed, 113 insertions(+) diff --git a/src/cli/App.tsx b/src/cli/App.tsx index 1f6e622..cf5026e 100644 --- a/src/cli/App.tsx +++ b/src/cli/App.tsx @@ -2669,6 +2669,17 @@ export default function App({ lastDequeuedMessageRef.current = null; // Clear - message was processed successfully lastSentInputRef.current = null; // Clear - no recovery needed + // Get last assistant message and reasoning for Stop hook + const lastAssistant = Array.from( + buffersRef.current.byId.values(), + ).findLast((item) => item.kind === "assistant" && "text" in item); + const assistantMessage = + lastAssistant && "text" in lastAssistant + ? lastAssistant.text + : undefined; + const precedingReasoning = buffersRef.current.lastReasoning; + buffersRef.current.lastReasoning = undefined; // Clear after use + // Run Stop hooks - if blocked/errored, continue the conversation with feedback const stopHookResult = await runStopHooks( stopReasonToHandle, @@ -2676,6 +2687,9 @@ export default function App({ Array.from(buffersRef.current.byId.values()).filter( (item) => item.kind === "tool_call", ).length, + undefined, // workingDirectory (uses default) + precedingReasoning, + assistantMessage, ); // If hook blocked (exit 2), inject stderr feedback and continue conversation diff --git a/src/cli/helpers/accumulator.ts b/src/cli/helpers/accumulator.ts index 3186b4a..8554fb4 100644 --- a/src/cli/helpers/accumulator.ts +++ b/src/cli/helpers/accumulator.ts @@ -181,6 +181,8 @@ export type Buffers = { interrupted?: boolean; // Track if stream was interrupted by user (skip stale refreshes) commitGeneration?: number; // Incremented when resuming from error to invalidate pending refreshes abortGeneration?: number; // Incremented on each interrupt to detect cancellation across async boundaries + lastReasoning?: string; // Track last reasoning content for hooks (PostToolUse, Stop) + lastAssistantMessage?: string; // Track last assistant message for hooks (PostToolUse) usage: { promptTokens: number; completionTokens: number; @@ -243,6 +245,15 @@ function markAsFinished(b: Buffers, id: string) { const updatedLine = { ...line, phase: "finished" as const }; b.byId.set(id, updatedLine); // console.log(`[MARK_FINISHED] Successfully marked ${id} as finished`); + + // Track last reasoning content for hooks (PostToolUse and Stop will include it) + if (line.kind === "reasoning" && "text" in line && line.text) { + b.lastReasoning = line.text; + } + // Track last assistant message for hooks (PostToolUse will include it) + if (line.kind === "assistant" && "text" in line && line.text) { + b.lastAssistantMessage = line.text; + } } else { // console.log(`[MARK_FINISHED] Did NOT mark ${id} as finished (conditions not met)`); } @@ -717,6 +728,12 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { // Args parsing failed } + // Get and clear preceding reasoning/message for hook + const precedingReasoning = b.lastReasoning; + const precedingAssistantMessage = b.lastAssistantMessage; + b.lastReasoning = undefined; + b.lastAssistantMessage = undefined; + runPostToolUseHooks( serverToolInfo.toolName, parsedArgs, @@ -727,6 +744,8 @@ export function onChunk(b: Buffers, chunk: LettaStreamingResponse) { toolCallId, undefined, b.agentId, + precedingReasoning, + precedingAssistantMessage, ).catch(() => {}); b.serverToolCalls.delete(toolCallId); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index e4294f5..3950443 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -72,6 +72,8 @@ export async function runPostToolUseHooks( toolCallId?: string, workingDirectory: string = process.cwd(), agentId?: string, + precedingReasoning?: string, + precedingAssistantMessage?: string, ): Promise { const hooks = await getHooksForEvent( "PostToolUse", @@ -90,6 +92,8 @@ export async function runPostToolUseHooks( tool_call_id: toolCallId, tool_result: toolResult, agent_id: agentId, + preceding_reasoning: precedingReasoning, + preceding_assistant_message: precedingAssistantMessage, }; // Run in parallel since PostToolUse cannot block @@ -202,6 +206,8 @@ export async function runStopHooks( messageCount?: number, toolCallCount?: number, workingDirectory: string = process.cwd(), + precedingReasoning?: string, + assistantMessage?: string, ): Promise { const hooks = await getHooksForEvent("Stop", undefined, workingDirectory); if (hooks.length === 0) { @@ -214,6 +220,8 @@ export async function runStopHooks( stop_reason: stopReason, message_count: messageCount, tool_call_count: toolCallCount, + preceding_reasoning: precedingReasoning, + assistant_message: assistantMessage, }; // Run sequentially - Stop can block diff --git a/src/hooks/types.ts b/src/hooks/types.ts index 1816898..54247b8 100644 --- a/src/hooks/types.ts +++ b/src/hooks/types.ts @@ -186,6 +186,10 @@ export interface PostToolUseHookInput extends HookInputBase { }; /** Agent ID (for server-side tools like memory) */ agent_id?: string; + /** Reasoning/thinking content that preceded this tool call */ + preceding_reasoning?: string; + /** Assistant message content that preceded this tool call */ + preceding_assistant_message?: string; } /** @@ -247,6 +251,10 @@ export interface StopHookInput extends HookInputBase { message_count?: number; /** Number of tool calls in the turn */ tool_call_count?: number; + /** Reasoning/thinking content that preceded the final response */ + preceding_reasoning?: string; + /** The assistant's final message content */ + assistant_message?: string; } /** diff --git a/src/tests/hooks/integration.test.ts b/src/tests/hooks/integration.test.ts index 60b15a4..1f2f89a 100644 --- a/src/tests/hooks/integration.test.ts +++ b/src/tests/hooks/integration.test.ts @@ -242,6 +242,37 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => { // Allow headroom for CI runners (especially macOS ARM) which can be slow expect(duration).toBeLessThan(400); // Parallel should be ~100ms }); + + test("includes preceding_reasoning and preceding_assistant_message when provided", async () => { + createHooksConfig({ + PostToolUse: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runPostToolUseHooks( + "Bash", + { command: "ls" }, + { status: "success", output: "file.txt" }, + "tool-123", + tempDir, + "agent-456", + "I should list the files to see what's there...", + "Let me check what files are in this directory.", + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.preceding_reasoning).toBe( + "I should list the files to see what's there...", + ); + expect(parsed.preceding_assistant_message).toBe( + "Let me check what files are in this directory.", + ); + expect(parsed.agent_id).toBe("agent-456"); + }); }); // ============================================================================ @@ -506,6 +537,30 @@ describe.skipIf(isWindows)("Hooks Integration Tests", () => { expect(parsed.message_count).toBe(10); expect(parsed.tool_call_count).toBe(7); }); + + test("includes preceding_reasoning and assistant_message when provided", async () => { + createHooksConfig({ + Stop: [ + { + matcher: "*", + hooks: [{ type: "command", command: "cat" }], + }, + ], + }); + + const result = await runStopHooks( + "end_turn", + 5, + 2, + tempDir, + "Let me think about this...", + "Here is my response.", + ); + + const parsed = JSON.parse(result.results[0]?.stdout || "{}"); + expect(parsed.preceding_reasoning).toBe("Let me think about this..."); + expect(parsed.assistant_message).toBe("Here is my response."); + }); }); // ============================================================================ diff --git a/src/tools/manager.ts b/src/tools/manager.ts index 4b2cc8e..a9440f4 100644 --- a/src/tools/manager.ts +++ b/src/tools/manager.ts @@ -955,6 +955,7 @@ export async function executeTool( ); // Run PostToolUse hooks (async, non-blocking) + // Note: preceding_reasoning/assistant_message not available here - tracked in accumulator for server tools runPostToolUseHooks( internalName, args as Record, @@ -963,6 +964,10 @@ export async function executeTool( output: getDisplayableToolReturn(flattenedResponse), }, options?.toolCallId, + undefined, // workingDirectory + undefined, // agentId + undefined, // precedingReasoning - not available in tool manager context + undefined, // precedingAssistantMessage - not available in tool manager context ).catch(() => { // Silently ignore hook errors - don't affect tool execution }); @@ -1009,6 +1014,10 @@ export async function executeTool( args as Record, { status: "error", output: errorMessage }, options?.toolCallId, + undefined, // workingDirectory + undefined, // agentId + undefined, // precedingReasoning - not available in tool manager context + undefined, // precedingAssistantMessage - not available in tool manager context ).catch(() => { // Silently ignore hook errors });