feat: add prompt based hooks (#795)
Co-authored-by: Letta <noreply@letta.com>
This commit is contained in:
@@ -3336,7 +3336,7 @@ 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
|
||||
// Get last assistant message, user message, and reasoning for Stop hook
|
||||
const lastAssistant = Array.from(
|
||||
buffersRef.current.byId.values(),
|
||||
).findLast((item) => item.kind === "assistant" && "text" in item);
|
||||
@@ -3344,6 +3344,11 @@ export default function App({
|
||||
lastAssistant && "text" in lastAssistant
|
||||
? lastAssistant.text
|
||||
: undefined;
|
||||
const firstUser = Array.from(buffersRef.current.byId.values()).find(
|
||||
(item) => item.kind === "user" && "text" in item,
|
||||
);
|
||||
const userMessage =
|
||||
firstUser && "text" in firstUser ? firstUser.text : undefined;
|
||||
const precedingReasoning = buffersRef.current.lastReasoning;
|
||||
buffersRef.current.lastReasoning = undefined; // Clear after use
|
||||
|
||||
@@ -3357,6 +3362,7 @@ export default function App({
|
||||
undefined, // workingDirectory (uses default)
|
||||
precedingReasoning,
|
||||
assistantMessage,
|
||||
userMessage,
|
||||
);
|
||||
|
||||
// If hook blocked (exit 2), inject stderr feedback and continue conversation
|
||||
@@ -3373,9 +3379,7 @@ export default function App({
|
||||
buffersRef.current.byId.set(statusId, {
|
||||
kind: "status",
|
||||
id: statusId,
|
||||
lines: [
|
||||
"Stop hook encountered blocking error, continuing loop with stderr feedback.",
|
||||
],
|
||||
lines: ["Stop hook blocked, continuing conversation."],
|
||||
});
|
||||
buffersRef.current.order.push(statusId);
|
||||
refreshDerived();
|
||||
|
||||
@@ -4,8 +4,11 @@
|
||||
import { Box, useInput } from "ink";
|
||||
import { memo, useCallback, useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
type HookCommand,
|
||||
type HookEvent,
|
||||
type HookMatcher,
|
||||
isCommandHook,
|
||||
isPromptHook,
|
||||
isToolEvent,
|
||||
type SimpleHookEvent,
|
||||
type SimpleHookMatcher,
|
||||
@@ -39,6 +42,21 @@ const BOX_BOTTOM_RIGHT = "╯";
|
||||
const BOX_HORIZONTAL = "─";
|
||||
const BOX_VERTICAL = "│";
|
||||
|
||||
/**
|
||||
* Get a display label for a hook (command or prompt).
|
||||
* For prompt hooks, returns just the prompt text (without prefix).
|
||||
*/
|
||||
function getHookDisplayLabel(hook: HookCommand | undefined): string {
|
||||
if (!hook) return "";
|
||||
if (isCommandHook(hook)) {
|
||||
return hook.command;
|
||||
}
|
||||
if (isPromptHook(hook)) {
|
||||
return `${hook.prompt.slice(0, 40)}${hook.prompt.length > 40 ? "..." : ""}`;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
interface HooksManagerProps {
|
||||
onClose: () => void;
|
||||
agentId?: string;
|
||||
@@ -533,10 +551,12 @@ export const HooksManager = memo(function HooksManager({
|
||||
const matcherPattern = isToolMatcher
|
||||
? (hook as HookMatcherWithSource).matcher || "*"
|
||||
: null;
|
||||
// Both types have hooks array
|
||||
const command = "hooks" in hook ? hook.hooks[0]?.command || "" : "";
|
||||
// Both types have hooks array - get display label for first hook
|
||||
const firstHook = "hooks" in hook ? hook.hooks[0] : undefined;
|
||||
const command = getHookDisplayLabel(firstHook);
|
||||
const truncatedCommand =
|
||||
command.length > 50 ? `${command.slice(0, 47)}...` : command;
|
||||
const isPrompt = firstHook ? isPromptHook(firstHook) : false;
|
||||
|
||||
return (
|
||||
<Text key={`${hook.source}-${index}`}>
|
||||
@@ -549,6 +569,7 @@ export const HooksManager = memo(function HooksManager({
|
||||
) : (
|
||||
<Text> </Text>
|
||||
)}
|
||||
{isPrompt && <Text color={colors.status.processing}>✦ </Text>}
|
||||
<Text dimColor>{truncatedCommand}</Text>
|
||||
</Text>
|
||||
);
|
||||
@@ -691,8 +712,10 @@ export const HooksManager = memo(function HooksManager({
|
||||
const matcherPattern = isToolMatcher
|
||||
? (hook as HookMatcherWithSource).matcher || "*"
|
||||
: null;
|
||||
// Both types have hooks array
|
||||
const command = hook && "hooks" in hook ? hook.hooks[0]?.command : "";
|
||||
// Both types have hooks array - get display label for first hook
|
||||
const firstHook = hook && "hooks" in hook ? hook.hooks[0] : undefined;
|
||||
const command = getHookDisplayLabel(firstHook);
|
||||
const isPrompt = firstHook ? isPromptHook(firstHook) : false;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
@@ -702,7 +725,16 @@ export const HooksManager = memo(function HooksManager({
|
||||
<Text> </Text>
|
||||
|
||||
{matcherPattern !== null && <Text>Matcher: {matcherPattern}</Text>}
|
||||
<Text>Command: {command}</Text>
|
||||
<Text>
|
||||
{isPrompt ? (
|
||||
<>
|
||||
Hook: <Text color={colors.status.processing}>✦ </Text>
|
||||
{command}
|
||||
</>
|
||||
) : (
|
||||
<>Command: {command}</>
|
||||
)}
|
||||
</Text>
|
||||
<Text>Source: {hook ? getSourceLabel(hook.source) : ""}</Text>
|
||||
<Text> </Text>
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ export function createContextTracker(): ContextTracker {
|
||||
return {
|
||||
lastContextTokens: 0,
|
||||
contextTokensHistory: [],
|
||||
currentTurnId: 0,
|
||||
currentTurnId: 0, // simple in-memory counter for now
|
||||
pendingCompaction: false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,17 +4,35 @@
|
||||
|
||||
import { type ChildProcess, spawn } from "node:child_process";
|
||||
import { buildShellLaunchers } from "../tools/impl/shellLaunchers";
|
||||
import { executePromptHook } from "./prompt-executor";
|
||||
import {
|
||||
type CommandHookConfig,
|
||||
type HookCommand,
|
||||
type HookExecutionResult,
|
||||
HookExitCode,
|
||||
type HookInput,
|
||||
type HookResult,
|
||||
isCommandHook,
|
||||
isPromptHook,
|
||||
} from "./types";
|
||||
|
||||
/** Default timeout for hook execution (60 seconds) */
|
||||
const DEFAULT_TIMEOUT_MS = 60000;
|
||||
|
||||
/**
|
||||
* Get a display identifier for a hook (for logging and feedback)
|
||||
*/
|
||||
function getHookIdentifier(hook: HookCommand): string {
|
||||
if (isCommandHook(hook)) {
|
||||
return hook.command;
|
||||
}
|
||||
if (isPromptHook(hook)) {
|
||||
// Use first 50 chars of prompt as identifier
|
||||
return `prompt:${hook.prompt.slice(0, 50)}${hook.prompt.length > 50 ? "..." : ""}`;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to spawn a hook command with a specific launcher
|
||||
* Returns the child process or throws an error
|
||||
@@ -50,13 +68,45 @@ function trySpawnWithLauncher(
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a single hook command with JSON input via stdin
|
||||
* Uses cross-platform shell launchers with fallback support
|
||||
* Execute a single hook with JSON input
|
||||
* Dispatches to appropriate executor based on hook type:
|
||||
* - "command": executes shell command with JSON via stdin
|
||||
* - "prompt": sends to LLM for evaluation
|
||||
*/
|
||||
export async function executeHookCommand(
|
||||
hook: HookCommand,
|
||||
input: HookInput,
|
||||
workingDirectory: string = process.cwd(),
|
||||
): Promise<HookResult> {
|
||||
// Dispatch based on hook type
|
||||
if (isPromptHook(hook)) {
|
||||
return executePromptHook(hook, input, workingDirectory);
|
||||
}
|
||||
|
||||
// Default to command hook execution
|
||||
if (isCommandHook(hook)) {
|
||||
return executeCommandHook(hook, input, workingDirectory);
|
||||
}
|
||||
|
||||
// Unknown hook type
|
||||
return {
|
||||
exitCode: HookExitCode.ERROR,
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
timedOut: false,
|
||||
durationMs: 0,
|
||||
error: `Unknown hook type: ${(hook as HookCommand).type}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a command hook with JSON input via stdin
|
||||
* Uses cross-platform shell launchers with fallback support
|
||||
*/
|
||||
export async function executeCommandHook(
|
||||
hook: CommandHookConfig,
|
||||
input: HookInput,
|
||||
workingDirectory: string = process.cwd(),
|
||||
): Promise<HookResult> {
|
||||
const startTime = Date.now();
|
||||
const timeout = hook.timeout ?? DEFAULT_TIMEOUT_MS;
|
||||
@@ -307,11 +357,10 @@ export async function executeHooks(
|
||||
}
|
||||
|
||||
// Collect feedback from stderr when hook blocks
|
||||
// Format: [command]: {stderr} per spec
|
||||
if (result.exitCode === HookExitCode.BLOCK) {
|
||||
blocked = true;
|
||||
if (result.stderr) {
|
||||
feedback.push(`[${hook.command}]: ${result.stderr}`);
|
||||
feedback.push(`[${getHookIdentifier(hook)}]: ${result.stderr}`);
|
||||
}
|
||||
// Stop processing more hooks after a block
|
||||
break;
|
||||
@@ -358,7 +407,7 @@ export async function executeHooksParallel(
|
||||
const hook = hooks[i];
|
||||
if (!result || !hook) continue;
|
||||
|
||||
// For exit 0, try to parse JSON for additionalContext (matching Claude Code behavior)
|
||||
// For exit 0, try to parse JSON for additionalContext
|
||||
if (result.exitCode === HookExitCode.ALLOW && result.stdout?.trim()) {
|
||||
try {
|
||||
const json = JSON.parse(result.stdout.trim());
|
||||
@@ -373,11 +422,11 @@ export async function executeHooksParallel(
|
||||
}
|
||||
}
|
||||
|
||||
// Format: [command]: {stderr} per spec
|
||||
// Collect feedback from stderr when hook blocks
|
||||
if (result.exitCode === HookExitCode.BLOCK) {
|
||||
blocked = true;
|
||||
if (result.stderr) {
|
||||
feedback.push(`[${hook.command}]: ${result.stderr}`);
|
||||
feedback.push(`[${getHookIdentifier(hook)}]: ${result.stderr}`);
|
||||
}
|
||||
}
|
||||
if (result.exitCode === HookExitCode.ERROR) {
|
||||
|
||||
@@ -266,6 +266,7 @@ export async function runStopHooks(
|
||||
workingDirectory: string = process.cwd(),
|
||||
precedingReasoning?: string,
|
||||
assistantMessage?: string,
|
||||
userMessage?: string,
|
||||
): Promise<HookExecutionResult> {
|
||||
const hooks = await getHooksForEvent("Stop", undefined, workingDirectory);
|
||||
if (hooks.length === 0) {
|
||||
@@ -280,6 +281,7 @@ export async function runStopHooks(
|
||||
tool_call_count: toolCallCount,
|
||||
preceding_reasoning: precedingReasoning,
|
||||
assistant_message: assistantMessage,
|
||||
user_message: userMessage,
|
||||
};
|
||||
|
||||
// Run sequentially - Stop can block
|
||||
|
||||
@@ -8,9 +8,11 @@ import {
|
||||
type HookEvent,
|
||||
type HookMatcher,
|
||||
type HooksConfig,
|
||||
isPromptHook,
|
||||
isToolEvent,
|
||||
type SimpleHookEvent,
|
||||
type SimpleHookMatcher,
|
||||
supportsPromptHooks,
|
||||
type ToolHookEvent,
|
||||
} from "./types";
|
||||
|
||||
@@ -177,6 +179,33 @@ export function matchesTool(pattern: string, toolName: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter hooks, removing prompt hooks from unsupported events with a warning
|
||||
*/
|
||||
function filterHooksForEvent(
|
||||
hooks: HookCommand[],
|
||||
event: HookEvent,
|
||||
): HookCommand[] {
|
||||
const filtered: HookCommand[] = [];
|
||||
const promptHooksSupported = supportsPromptHooks(event);
|
||||
|
||||
for (const hook of hooks) {
|
||||
if (isPromptHook(hook)) {
|
||||
if (!promptHooksSupported) {
|
||||
// Warn about unsupported prompt hook
|
||||
console.warn(
|
||||
`\x1b[33m[hooks] Warning: Prompt hooks are not supported for the ${event} event. ` +
|
||||
`Ignoring prompt hook.\x1b[0m`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
filtered.push(hook);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all hooks that match a specific event and tool name
|
||||
*/
|
||||
@@ -200,7 +229,7 @@ export function getMatchingHooks(
|
||||
hooks.push(...matcher.hooks);
|
||||
}
|
||||
}
|
||||
return hooks;
|
||||
return filterHooksForEvent(hooks, event);
|
||||
} else {
|
||||
// Simple events use SimpleHookMatcher[] - extract hooks from each matcher
|
||||
const matchers = config[event as SimpleHookEvent] as
|
||||
@@ -214,7 +243,7 @@ export function getMatchingHooks(
|
||||
for (const matcher of matchers) {
|
||||
hooks.push(...matcher.hooks);
|
||||
}
|
||||
return hooks;
|
||||
return filterHooksForEvent(hooks, event);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
260
src/hooks/prompt-executor.ts
Normal file
260
src/hooks/prompt-executor.ts
Normal file
@@ -0,0 +1,260 @@
|
||||
import { getClient } from "../agent/client";
|
||||
import { getCurrentAgentId } from "../agent/context";
|
||||
import {
|
||||
HookExitCode,
|
||||
type HookInput,
|
||||
type HookResult,
|
||||
PROMPT_ARGUMENTS_PLACEHOLDER,
|
||||
type PromptHookConfig,
|
||||
type PromptHookResponse,
|
||||
} from "./types";
|
||||
|
||||
/** Default timeout for prompt hook execution (30 seconds) */
|
||||
const DEFAULT_PROMPT_TIMEOUT_MS = 30000;
|
||||
|
||||
/**
|
||||
* System prompt for the LLM to evaluate hooks.
|
||||
* Instructs the model to return a JSON decision per Claude Code spec.
|
||||
*/
|
||||
const PROMPT_HOOK_SYSTEM = `You are a hook evaluator for a coding assistant. Your job is to evaluate whether an action should be allowed or blocked based on the provided context and criteria.
|
||||
|
||||
You will receive:
|
||||
1. Hook input JSON containing context about the action (event type, tool info, etc.)
|
||||
2. A user-defined prompt with evaluation criteria
|
||||
|
||||
You must respond with ONLY a valid JSON object (no markdown, no explanation) with the following fields:
|
||||
- "ok": true to allow the action, false to prevent it
|
||||
- "reason": Required when ok is false. Explanation for your decision.
|
||||
|
||||
Example responses:
|
||||
- To allow: {"ok": true}
|
||||
- To block: {"ok": false, "reason": "This action violates the security policy"}
|
||||
|
||||
Respond with JSON only. No markdown code blocks. No explanation outside the JSON.`;
|
||||
|
||||
/**
|
||||
* Build the prompt to send to the LLM, replacing $ARGUMENTS with hook input.
|
||||
* If $ARGUMENTS is not present in the prompt, append the input JSON.
|
||||
*/
|
||||
function buildPrompt(hookPrompt: string, input: HookInput): string {
|
||||
const inputJson = JSON.stringify(input, null, 2);
|
||||
|
||||
// If $ARGUMENTS placeholder exists, replace all occurrences
|
||||
if (hookPrompt.includes(PROMPT_ARGUMENTS_PLACEHOLDER)) {
|
||||
return hookPrompt.replaceAll(PROMPT_ARGUMENTS_PLACEHOLDER, inputJson);
|
||||
}
|
||||
|
||||
// Otherwise, append input JSON to the prompt
|
||||
return `${hookPrompt}\n\nHook input:\n${inputJson}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the LLM response as JSON, handling potential formatting issues
|
||||
*/
|
||||
function parsePromptResponse(response: string): PromptHookResponse {
|
||||
// Try to extract JSON from the response
|
||||
let jsonStr = response.trim();
|
||||
|
||||
// Handle markdown code blocks
|
||||
const jsonMatch = jsonStr.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
|
||||
if (jsonMatch) {
|
||||
jsonStr = jsonMatch[1] || jsonStr;
|
||||
}
|
||||
|
||||
// Try to find JSON object in the response (non-greedy to avoid spanning multiple objects)
|
||||
const objectMatch = jsonStr.match(/\{[\s\S]*?\}/);
|
||||
if (objectMatch) {
|
||||
jsonStr = objectMatch[0];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(jsonStr);
|
||||
|
||||
// Validate the response structure - ok must be a boolean
|
||||
if (typeof parsed?.ok !== "boolean") {
|
||||
throw new Error(
|
||||
`Invalid prompt hook response: "ok" must be a boolean, got ${typeof parsed?.ok}`,
|
||||
);
|
||||
}
|
||||
|
||||
return parsed as PromptHookResponse;
|
||||
} catch (e) {
|
||||
// Re-throw validation errors as-is
|
||||
if (e instanceof Error && e.message.startsWith("Invalid prompt hook")) {
|
||||
throw e;
|
||||
}
|
||||
// If parsing fails, treat as error
|
||||
throw new Error(`Failed to parse LLM response as JSON: ${response}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert PromptHookResponse to HookResult
|
||||
*/
|
||||
function responseToHookResult(
|
||||
response: PromptHookResponse,
|
||||
durationMs: number,
|
||||
): HookResult {
|
||||
// ok: true allows the action, ok: false (or missing) blocks it
|
||||
const shouldBlock = response.ok !== true;
|
||||
|
||||
return {
|
||||
exitCode: shouldBlock ? HookExitCode.BLOCK : HookExitCode.ALLOW,
|
||||
stdout: JSON.stringify(response),
|
||||
stderr: shouldBlock ? response.reason || "" : "",
|
||||
timedOut: false,
|
||||
durationMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract agent_id from hook input, falling back to the global agent context.
|
||||
*/
|
||||
function getAgentId(input: HookInput): string | undefined {
|
||||
// 1. Check hook input directly (most hook event types include agent_id)
|
||||
if ("agent_id" in input && input.agent_id) {
|
||||
return input.agent_id;
|
||||
}
|
||||
// 2. Fall back to the global agent context (set during session)
|
||||
try {
|
||||
return getCurrentAgentId();
|
||||
} catch {
|
||||
// Context not available
|
||||
}
|
||||
// 3. Last resort: env var (set by shell env for subprocesses)
|
||||
return process.env.LETTA_AGENT_ID;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSON schema for structured prompt hook responses.
|
||||
* Forces the LLM to return {ok: boolean, reason?: string} via tool calling.
|
||||
*/
|
||||
const PROMPT_HOOK_RESPONSE_SCHEMA = {
|
||||
properties: {
|
||||
ok: {
|
||||
type: "boolean",
|
||||
description: "true to allow the action, false to block it",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description: "Explanation for the decision. Required when ok is false.",
|
||||
},
|
||||
},
|
||||
required: ["ok"],
|
||||
};
|
||||
|
||||
/** Response shape from POST /v1/agents/{agent_id}/generate */
|
||||
interface GenerateResponse {
|
||||
content: string;
|
||||
model: string;
|
||||
usage: {
|
||||
completion_tokens: number;
|
||||
prompt_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a prompt-based hook by sending the hook input to an LLM
|
||||
* via the POST /v1/agents/{agent_id}/generate endpoint.
|
||||
*/
|
||||
export async function executePromptHook(
|
||||
hook: PromptHookConfig,
|
||||
input: HookInput,
|
||||
_workingDirectory: string = process.cwd(),
|
||||
): Promise<HookResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const agentId = getAgentId(input);
|
||||
if (!agentId) {
|
||||
throw new Error(
|
||||
"Prompt hooks require an agent_id. Ensure the hook event provides an agent_id " +
|
||||
"or set the LETTA_AGENT_ID environment variable.",
|
||||
);
|
||||
}
|
||||
|
||||
// Build the user prompt with $ARGUMENTS replaced
|
||||
const userPrompt = buildPrompt(hook.prompt, input);
|
||||
const timeout = hook.timeout ?? DEFAULT_PROMPT_TIMEOUT_MS;
|
||||
|
||||
// Call the generate endpoint (uses agent's model unless hook overrides)
|
||||
const llmResponse = await callGenerateEndpoint(
|
||||
agentId,
|
||||
PROMPT_HOOK_SYSTEM,
|
||||
userPrompt,
|
||||
hook.model,
|
||||
timeout,
|
||||
);
|
||||
|
||||
// Parse the response
|
||||
const parsedResponse = parsePromptResponse(llmResponse);
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Log hook completion (matching command hook format from executor.ts)
|
||||
const shouldBlock = parsedResponse.ok !== true;
|
||||
const exitCode = shouldBlock ? 2 : 0;
|
||||
const exitColor = shouldBlock ? "\x1b[31m" : "\x1b[32m";
|
||||
const exitLabel = `${exitColor}exit ${exitCode}\x1b[0m`;
|
||||
const promptLabel = `\x1b[38;2;140;140;249m✦\x1b[90m ${hook.prompt.slice(0, 50)}${hook.prompt.length > 50 ? "..." : ""}`;
|
||||
console.log(`\x1b[90m[hook:${input.event_type}] ${promptLabel}\x1b[0m`);
|
||||
console.log(`\x1b[90m \u23BF ${exitLabel} (${durationMs}ms)\x1b[0m`);
|
||||
// Show the JSON response as stdout
|
||||
const responseJson = JSON.stringify(parsedResponse);
|
||||
console.log(`\x1b[90m \u23BF (stdout)\x1b[0m`);
|
||||
console.log(`\x1b[90m ${responseJson}\x1b[0m`);
|
||||
|
||||
return responseToHookResult(parsedResponse, durationMs);
|
||||
} catch (error) {
|
||||
const durationMs = Date.now() - startTime;
|
||||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||||
const timedOut = errorMessage.includes("timed out");
|
||||
|
||||
const promptLabel = `\x1b[38;2;140;140;249m✦\x1b[90m ${hook.prompt.slice(0, 50)}${hook.prompt.length > 50 ? "..." : ""}`;
|
||||
console.log(`\x1b[90m[hook:${input.event_type}] ${promptLabel}\x1b[0m`);
|
||||
console.log(
|
||||
`\x1b[90m \u23BF \x1b[33mexit 1\x1b[0m (${durationMs}ms)\x1b[0m`,
|
||||
);
|
||||
console.log(`\x1b[90m \u23BF (stderr)\x1b[0m`);
|
||||
console.log(`\x1b[90m ${errorMessage}\x1b[0m`);
|
||||
|
||||
return {
|
||||
exitCode: HookExitCode.ERROR,
|
||||
stdout: "",
|
||||
stderr: errorMessage,
|
||||
timedOut,
|
||||
durationMs,
|
||||
error: errorMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Call the POST /v1/agents/{agent_id}/generate endpoint for hook evaluation.
|
||||
* Uses the Letta SDK client's raw post() method since the SDK doesn't have
|
||||
* a typed generate() method yet.
|
||||
*/
|
||||
async function callGenerateEndpoint(
|
||||
agentId: string,
|
||||
systemPrompt: string,
|
||||
userPrompt: string,
|
||||
overrideModel: string | undefined,
|
||||
timeout: number,
|
||||
): Promise<string> {
|
||||
const client = await getClient();
|
||||
|
||||
const response = await client.post<GenerateResponse>(
|
||||
`/v1/agents/${agentId}/generate`,
|
||||
{
|
||||
body: {
|
||||
prompt: userPrompt,
|
||||
system_prompt: systemPrompt,
|
||||
...(overrideModel && { override_model: overrideModel }),
|
||||
response_schema: PROMPT_HOOK_RESPONSE_SCHEMA,
|
||||
},
|
||||
timeout,
|
||||
},
|
||||
);
|
||||
|
||||
return response.content;
|
||||
}
|
||||
@@ -29,17 +29,82 @@ export type SimpleHookEvent =
|
||||
export type HookEvent = ToolHookEvent | SimpleHookEvent;
|
||||
|
||||
/**
|
||||
* Individual hook command configuration
|
||||
* Command hook configuration - executes a shell command
|
||||
*/
|
||||
export interface HookCommand {
|
||||
/** Type of hook - currently only "command" is supported */
|
||||
export interface CommandHookConfig {
|
||||
/** Type of hook */
|
||||
type: "command";
|
||||
/** Shell command to execute */
|
||||
command: string;
|
||||
/** Optional timeout in milliseconds (default: 60000) */
|
||||
/** Optional timeout in milliseconds (default: 60000 for command hooks) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt hook configuration - sends hook input to an LLM for evaluation.
|
||||
* Supported events: PreToolUse, PostToolUse, PostToolUseFailure,
|
||||
* PermissionRequest, UserPromptSubmit, Stop, and SubagentStop.
|
||||
*/
|
||||
export interface PromptHookConfig {
|
||||
/** Type of hook */
|
||||
type: "prompt";
|
||||
/**
|
||||
* Prompt text to send to the model.
|
||||
* Use $ARGUMENTS as a placeholder for the hook input JSON.
|
||||
*/
|
||||
prompt: string;
|
||||
/** Optional model to use for evaluation */
|
||||
model?: string;
|
||||
/** Optional timeout in milliseconds (default: 30000 for prompt hooks) */
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder for $ARGUMENTS in prompt hooks
|
||||
*/
|
||||
export const PROMPT_ARGUMENTS_PLACEHOLDER = "$ARGUMENTS";
|
||||
|
||||
/**
|
||||
* Events that support prompt-based hooks:
|
||||
* PreToolUse, PostToolUse, PostToolUseFailure, PermissionRequest,
|
||||
* UserPromptSubmit, Stop, SubagentStop
|
||||
*/
|
||||
export const PROMPT_HOOK_SUPPORTED_EVENTS: Set<HookEvent> = new Set([
|
||||
"PreToolUse",
|
||||
"PostToolUse",
|
||||
"PostToolUseFailure",
|
||||
"PermissionRequest",
|
||||
"UserPromptSubmit",
|
||||
"Stop",
|
||||
"SubagentStop",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Type guard to check if an event supports prompt hooks
|
||||
*/
|
||||
export function supportsPromptHooks(event: HookEvent): boolean {
|
||||
return PROMPT_HOOK_SUPPORTED_EVENTS.has(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Individual hook configuration - can be command or prompt type
|
||||
*/
|
||||
export type HookCommand = CommandHookConfig | PromptHookConfig;
|
||||
|
||||
/**
|
||||
* Type guard to check if a hook is a command hook
|
||||
*/
|
||||
export function isCommandHook(hook: HookCommand): hook is CommandHookConfig {
|
||||
return hook.type === "command";
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard to check if a hook is a prompt hook
|
||||
*/
|
||||
export function isPromptHook(hook: HookCommand): hook is PromptHookConfig {
|
||||
return hook.type === "prompt";
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook matcher configuration for tool events - matches hooks to specific tools
|
||||
*/
|
||||
@@ -125,6 +190,17 @@ export interface HookResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expected JSON response structure from prompt hooks.
|
||||
* The LLM must respond with this schema per Claude Code spec.
|
||||
*/
|
||||
export interface PromptHookResponse {
|
||||
/** true allows the action, false prevents it */
|
||||
ok: boolean;
|
||||
/** Required when ok is false. Explanation shown to Claude. */
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated result from running all matched hooks
|
||||
*/
|
||||
@@ -281,6 +357,8 @@ export interface StopHookInput extends HookInputBase {
|
||||
preceding_reasoning?: string;
|
||||
/** The assistant's final message content */
|
||||
assistant_message?: string;
|
||||
/** The user's original prompt that initiated this turn */
|
||||
user_message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,8 @@ import {
|
||||
mergeHooksConfigs,
|
||||
} from "../../hooks/loader";
|
||||
import {
|
||||
type CommandHookConfig,
|
||||
type HookCommand,
|
||||
type HookEvent,
|
||||
type HooksConfig,
|
||||
isToolEvent,
|
||||
@@ -20,6 +22,16 @@ import {
|
||||
} from "../../hooks/types";
|
||||
import { settingsManager } from "../../settings-manager";
|
||||
|
||||
// Type-safe helper to extract command from a hook (tests only use command hooks)
|
||||
function asCommand(
|
||||
hook: HookCommand | undefined,
|
||||
): CommandHookConfig | undefined {
|
||||
if (hook && hook.type === "command") {
|
||||
return hook as CommandHookConfig;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
describe("Hooks Loader", () => {
|
||||
let tempDir: string;
|
||||
let fakeHome: string;
|
||||
@@ -215,11 +227,11 @@ describe("Hooks Loader", () => {
|
||||
|
||||
const bashHooks = getMatchingHooks(config, "PreToolUse", "Bash");
|
||||
expect(bashHooks).toHaveLength(1);
|
||||
expect(bashHooks[0]?.command).toBe("bash hook");
|
||||
expect(asCommand(bashHooks[0])?.command).toBe("bash hook");
|
||||
|
||||
const editHooks = getMatchingHooks(config, "PreToolUse", "Edit");
|
||||
expect(editHooks).toHaveLength(1);
|
||||
expect(editHooks[0]?.command).toBe("edit hook");
|
||||
expect(asCommand(editHooks[0])?.command).toBe("edit hook");
|
||||
});
|
||||
|
||||
test("returns wildcard hooks for any tool", () => {
|
||||
@@ -234,7 +246,7 @@ describe("Hooks Loader", () => {
|
||||
|
||||
const hooks = getMatchingHooks(config, "PreToolUse", "AnyTool");
|
||||
expect(hooks).toHaveLength(1);
|
||||
expect(hooks[0]?.command).toBe("all tools hook");
|
||||
expect(asCommand(hooks[0])?.command).toBe("all tools hook");
|
||||
});
|
||||
|
||||
test("returns multiple matching hooks", () => {
|
||||
@@ -315,9 +327,9 @@ describe("Hooks Loader", () => {
|
||||
|
||||
const hooks = getMatchingHooks(config, "PreToolUse", "Bash");
|
||||
expect(hooks).toHaveLength(3);
|
||||
expect(hooks[0]?.command).toBe("multi tool");
|
||||
expect(hooks[1]?.command).toBe("bash specific");
|
||||
expect(hooks[2]?.command).toBe("wildcard");
|
||||
expect(asCommand(hooks[0])?.command).toBe("multi tool");
|
||||
expect(asCommand(hooks[1])?.command).toBe("bash specific");
|
||||
expect(asCommand(hooks[2])?.command).toBe("wildcard");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -496,7 +508,9 @@ describe("Hooks Loader", () => {
|
||||
|
||||
const hooks = await loadProjectLocalHooks(tempDir);
|
||||
expect(hooks.PreToolUse).toHaveLength(1);
|
||||
expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("echo local");
|
||||
expect(asCommand(hooks.PreToolUse?.[0]?.hooks[0])?.command).toBe(
|
||||
"echo local",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -517,8 +531,12 @@ describe("Hooks Loader", () => {
|
||||
const merged = mergeHooksConfigs(global, project, projectLocal);
|
||||
|
||||
expect(merged.PreToolUse).toHaveLength(2);
|
||||
expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); // Local first
|
||||
expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("project"); // Project second
|
||||
expect(asCommand(merged.PreToolUse?.[0]?.hooks[0])?.command).toBe(
|
||||
"local",
|
||||
); // Local first
|
||||
expect(asCommand(merged.PreToolUse?.[1]?.hooks[0])?.command).toBe(
|
||||
"project",
|
||||
); // Project second
|
||||
});
|
||||
|
||||
test("project-local hooks run before global hooks", () => {
|
||||
@@ -537,8 +555,12 @@ describe("Hooks Loader", () => {
|
||||
const merged = mergeHooksConfigs(global, project, projectLocal);
|
||||
|
||||
expect(merged.PreToolUse).toHaveLength(2);
|
||||
expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local"); // Local first
|
||||
expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("global"); // Global last
|
||||
expect(asCommand(merged.PreToolUse?.[0]?.hooks[0])?.command).toBe(
|
||||
"local",
|
||||
); // Local first
|
||||
expect(asCommand(merged.PreToolUse?.[1]?.hooks[0])?.command).toBe(
|
||||
"global",
|
||||
); // Global last
|
||||
});
|
||||
|
||||
test("all three levels merge correctly", () => {
|
||||
@@ -577,9 +599,15 @@ describe("Hooks Loader", () => {
|
||||
|
||||
// PreToolUse: local -> project -> global
|
||||
expect(merged.PreToolUse).toHaveLength(3);
|
||||
expect(merged.PreToolUse?.[0]?.hooks[0]?.command).toBe("local");
|
||||
expect(merged.PreToolUse?.[1]?.hooks[0]?.command).toBe("project");
|
||||
expect(merged.PreToolUse?.[2]?.hooks[0]?.command).toBe("global");
|
||||
expect(asCommand(merged.PreToolUse?.[0]?.hooks[0])?.command).toBe(
|
||||
"local",
|
||||
);
|
||||
expect(asCommand(merged.PreToolUse?.[1]?.hooks[0])?.command).toBe(
|
||||
"project",
|
||||
);
|
||||
expect(asCommand(merged.PreToolUse?.[2]?.hooks[0])?.command).toBe(
|
||||
"global",
|
||||
);
|
||||
|
||||
// Others only have one source
|
||||
expect(merged.PostToolUse).toHaveLength(1);
|
||||
@@ -624,8 +652,10 @@ describe("Hooks Loader", () => {
|
||||
|
||||
// Local should come before project
|
||||
expect(hooks.PreToolUse).toHaveLength(2);
|
||||
expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("local");
|
||||
expect(hooks.PreToolUse?.[1]?.hooks[0]?.command).toBe("project");
|
||||
expect(asCommand(hooks.PreToolUse?.[0]?.hooks[0])?.command).toBe("local");
|
||||
expect(asCommand(hooks.PreToolUse?.[1]?.hooks[0])?.command).toBe(
|
||||
"project",
|
||||
);
|
||||
});
|
||||
|
||||
test("handles missing local settings gracefully", async () => {
|
||||
@@ -650,7 +680,9 @@ describe("Hooks Loader", () => {
|
||||
const hooks = await loadHooks(tempDir);
|
||||
|
||||
expect(hooks.PreToolUse).toHaveLength(1);
|
||||
expect(hooks.PreToolUse?.[0]?.hooks[0]?.command).toBe("project");
|
||||
expect(asCommand(hooks.PreToolUse?.[0]?.hooks[0])?.command).toBe(
|
||||
"project",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
425
src/tests/hooks/prompt-executor.test.ts
Normal file
425
src/tests/hooks/prompt-executor.test.ts
Normal file
@@ -0,0 +1,425 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
||||
import { executePromptHook } from "../../hooks/prompt-executor";
|
||||
import {
|
||||
HookExitCode,
|
||||
type PreToolUseHookInput,
|
||||
type StopHookInput,
|
||||
} from "../../hooks/types";
|
||||
|
||||
interface GenerateOpts {
|
||||
body: {
|
||||
prompt: string;
|
||||
system_prompt: string;
|
||||
override_model?: string;
|
||||
response_schema?: Record<string, unknown>;
|
||||
};
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
// Mock getClient to avoid real API calls
|
||||
const mockPost = mock(
|
||||
(_path: string, _opts: GenerateOpts) =>
|
||||
Promise.resolve({
|
||||
content: '{"ok": true}',
|
||||
model: "test-model",
|
||||
usage: {
|
||||
completion_tokens: 10,
|
||||
prompt_tokens: 50,
|
||||
total_tokens: 60,
|
||||
},
|
||||
}) as Promise<Record<string, unknown>>,
|
||||
);
|
||||
const mockGetClient = mock(() => Promise.resolve({ post: mockPost }));
|
||||
|
||||
// Mock getCurrentAgentId
|
||||
const mockGetCurrentAgentId = mock(() => "agent-test-123");
|
||||
|
||||
mock.module("../../agent/client", () => ({
|
||||
getClient: mockGetClient,
|
||||
}));
|
||||
|
||||
mock.module("../../agent/context", () => ({
|
||||
getCurrentAgentId: mockGetCurrentAgentId,
|
||||
}));
|
||||
|
||||
/** Helper to get the first call's [path, opts] from mockPost */
|
||||
function firstPostCall(): [string, GenerateOpts] {
|
||||
const calls = mockPost.mock.calls;
|
||||
const call = calls[0];
|
||||
if (!call) throw new Error("mockPost was not called");
|
||||
return call;
|
||||
}
|
||||
|
||||
describe("Prompt Hook Executor", () => {
|
||||
beforeEach(() => {
|
||||
mockPost.mockClear();
|
||||
mockGetClient.mockClear();
|
||||
mockGetCurrentAgentId.mockClear();
|
||||
|
||||
// Default: allow
|
||||
mockPost.mockResolvedValue({
|
||||
content: '{"ok": true}',
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
usage: {
|
||||
completion_tokens: 10,
|
||||
prompt_tokens: 50,
|
||||
total_tokens: 60,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up env vars
|
||||
delete process.env.LETTA_AGENT_ID;
|
||||
});
|
||||
|
||||
describe("executePromptHook", () => {
|
||||
test("calls generate endpoint and returns ALLOW when ok is true", async () => {
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check if this tool call is safe",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
const result = await executePromptHook(hook, input);
|
||||
|
||||
expect(result.exitCode).toBe(HookExitCode.ALLOW);
|
||||
expect(result.timedOut).toBe(false);
|
||||
expect(mockGetClient).toHaveBeenCalledTimes(1);
|
||||
expect(mockPost).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Verify the correct path and body were sent
|
||||
const [path, opts] = firstPostCall();
|
||||
expect(path).toBe("/v1/agents/agent-abc-123/generate");
|
||||
expect(opts.body.prompt).toContain("Check if this tool call is safe");
|
||||
expect(opts.body.system_prompt).toBeTruthy();
|
||||
expect(opts.body.override_model).toBeUndefined();
|
||||
expect(opts.body.response_schema).toBeDefined();
|
||||
const schema = opts.body.response_schema as {
|
||||
properties: { ok: { type: string } };
|
||||
};
|
||||
expect(schema.properties.ok.type).toBe("boolean");
|
||||
});
|
||||
|
||||
test("returns BLOCK when ok is false", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
content: '{"ok": false, "reason": "Dangerous command detected"}',
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
usage: {
|
||||
completion_tokens: 15,
|
||||
prompt_tokens: 50,
|
||||
total_tokens: 65,
|
||||
},
|
||||
});
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Block dangerous commands",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "rm -rf /" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
const result = await executePromptHook(hook, input);
|
||||
|
||||
expect(result.exitCode).toBe(HookExitCode.BLOCK);
|
||||
expect(result.stderr).toBe("Dangerous command detected");
|
||||
});
|
||||
|
||||
test("uses custom model when specified in hook config", async () => {
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Evaluate this action",
|
||||
model: "openai/gpt-4o",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Edit",
|
||||
tool_input: { file_path: "/etc/passwd" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
await executePromptHook(hook, input);
|
||||
|
||||
const [, opts] = firstPostCall();
|
||||
expect(opts.body.override_model).toBe("openai/gpt-4o");
|
||||
});
|
||||
|
||||
test("uses custom timeout when specified", async () => {
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Evaluate this action",
|
||||
timeout: 5000,
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "echo hi" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
await executePromptHook(hook, input);
|
||||
|
||||
const [, opts] = firstPostCall();
|
||||
expect(opts.timeout).toBe(5000);
|
||||
});
|
||||
|
||||
test("replaces $ARGUMENTS placeholder in prompt", async () => {
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: 'Check if tool "$ARGUMENTS" is safe to run',
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
await executePromptHook(hook, input);
|
||||
|
||||
const [, opts] = firstPostCall();
|
||||
// $ARGUMENTS should have been replaced with JSON
|
||||
expect(opts.body.prompt).not.toContain("$ARGUMENTS");
|
||||
expect(opts.body.prompt).toContain('"event_type": "PreToolUse"');
|
||||
expect(opts.body.prompt).toContain('"tool_name": "Bash"');
|
||||
});
|
||||
|
||||
test("appends hook input when $ARGUMENTS is not in prompt", async () => {
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Is this tool call safe?",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
await executePromptHook(hook, input);
|
||||
|
||||
const [, opts] = firstPostCall();
|
||||
expect(opts.body.prompt).toContain("Is this tool call safe?");
|
||||
expect(opts.body.prompt).toContain("Hook input:");
|
||||
expect(opts.body.prompt).toContain('"tool_name": "Bash"');
|
||||
});
|
||||
|
||||
test("falls back to getCurrentAgentId when input has no agent_id", async () => {
|
||||
mockGetCurrentAgentId.mockReturnValue("agent-from-context");
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check this",
|
||||
};
|
||||
const input: StopHookInput = {
|
||||
event_type: "Stop",
|
||||
working_directory: "/tmp",
|
||||
stop_reason: "end_turn",
|
||||
};
|
||||
|
||||
await executePromptHook(hook, input);
|
||||
|
||||
const [path] = firstPostCall();
|
||||
expect(path).toBe("/v1/agents/agent-from-context/generate");
|
||||
});
|
||||
|
||||
test("falls back to LETTA_AGENT_ID env var when context unavailable", async () => {
|
||||
mockGetCurrentAgentId.mockImplementation(() => {
|
||||
throw new Error("No agent context set");
|
||||
});
|
||||
process.env.LETTA_AGENT_ID = "agent-from-env";
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check this",
|
||||
};
|
||||
const input: StopHookInput = {
|
||||
event_type: "Stop",
|
||||
working_directory: "/tmp",
|
||||
stop_reason: "end_turn",
|
||||
};
|
||||
|
||||
await executePromptHook(hook, input);
|
||||
|
||||
const [path] = firstPostCall();
|
||||
expect(path).toBe("/v1/agents/agent-from-env/generate");
|
||||
});
|
||||
|
||||
test("returns ERROR when no agent_id available", async () => {
|
||||
mockGetCurrentAgentId.mockImplementation(() => {
|
||||
throw new Error("No agent context set");
|
||||
});
|
||||
delete process.env.LETTA_AGENT_ID;
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check this",
|
||||
};
|
||||
const input: StopHookInput = {
|
||||
event_type: "Stop",
|
||||
working_directory: "/tmp",
|
||||
stop_reason: "end_turn",
|
||||
};
|
||||
|
||||
const result = await executePromptHook(hook, input);
|
||||
|
||||
expect(result.exitCode).toBe(HookExitCode.ERROR);
|
||||
expect(result.error).toContain("agent_id");
|
||||
expect(mockPost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("returns ERROR when API call fails", async () => {
|
||||
mockPost.mockRejectedValue(new Error("Network error"));
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check this",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
const result = await executePromptHook(hook, input);
|
||||
|
||||
expect(result.exitCode).toBe(HookExitCode.ERROR);
|
||||
expect(result.error).toContain("Network error");
|
||||
});
|
||||
|
||||
test("returns ERROR when LLM returns unparseable response", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
content: "This is not valid JSON at all",
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
usage: {
|
||||
completion_tokens: 10,
|
||||
prompt_tokens: 50,
|
||||
total_tokens: 60,
|
||||
},
|
||||
});
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check this",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
const result = await executePromptHook(hook, input);
|
||||
|
||||
expect(result.exitCode).toBe(HookExitCode.ERROR);
|
||||
expect(result.error).toContain("Failed to parse");
|
||||
});
|
||||
|
||||
test("handles JSON wrapped in markdown code blocks", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
content: '```json\n{"ok": true}\n```',
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
usage: {
|
||||
completion_tokens: 10,
|
||||
prompt_tokens: 50,
|
||||
total_tokens: 60,
|
||||
},
|
||||
});
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check this",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
const result = await executePromptHook(hook, input);
|
||||
|
||||
expect(result.exitCode).toBe(HookExitCode.ALLOW);
|
||||
});
|
||||
|
||||
test("returns ERROR when ok is not a boolean", async () => {
|
||||
mockPost.mockResolvedValue({
|
||||
content: '{"ok": "yes", "reason": "looks fine"}',
|
||||
model: "anthropic/claude-3-5-haiku-20241022",
|
||||
usage: {
|
||||
completion_tokens: 10,
|
||||
prompt_tokens: 50,
|
||||
total_tokens: 60,
|
||||
},
|
||||
});
|
||||
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Check this",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
const result = await executePromptHook(hook, input);
|
||||
|
||||
expect(result.exitCode).toBe(HookExitCode.ERROR);
|
||||
expect(result.error).toContain('"ok" must be a boolean');
|
||||
});
|
||||
|
||||
test("sends response_schema for structured output", async () => {
|
||||
const hook = {
|
||||
type: "prompt" as const,
|
||||
prompt: "Is this safe?",
|
||||
};
|
||||
const input: PreToolUseHookInput = {
|
||||
event_type: "PreToolUse",
|
||||
working_directory: "/tmp",
|
||||
tool_name: "Bash",
|
||||
tool_input: { command: "ls" },
|
||||
agent_id: "agent-abc-123",
|
||||
};
|
||||
|
||||
await executePromptHook(hook, input);
|
||||
|
||||
const [, opts] = firstPostCall();
|
||||
expect(opts.body.response_schema).toEqual({
|
||||
properties: {
|
||||
ok: {
|
||||
type: "boolean",
|
||||
description: "true to allow the action, false to block it",
|
||||
},
|
||||
reason: {
|
||||
type: "string",
|
||||
description:
|
||||
"Explanation for the decision. Required when ok is false.",
|
||||
},
|
||||
},
|
||||
required: ["ok"],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,19 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { CommandHookConfig, HookCommand } from "../hooks/types";
|
||||
import { settingsManager } from "../settings-manager";
|
||||
|
||||
// Type-safe helper to extract command from a hook (tests only use command hooks)
|
||||
function asCommand(
|
||||
hook: HookCommand | undefined,
|
||||
): CommandHookConfig | undefined {
|
||||
if (hook && hook.type === "command") {
|
||||
return hook as CommandHookConfig;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
import {
|
||||
deleteSecureTokens,
|
||||
isKeychainAvailable,
|
||||
@@ -558,7 +570,7 @@ describe("Settings Manager - Hooks", () => {
|
||||
|
||||
const settings = settingsManager.getSettings();
|
||||
expect(settings.hooks?.PreToolUse).toHaveLength(1);
|
||||
expect(settings.hooks?.PreToolUse?.[0]?.hooks[0]?.command).toBe(
|
||||
expect(asCommand(settings.hooks?.PreToolUse?.[0]?.hooks[0])?.command).toBe(
|
||||
"echo persisted",
|
||||
);
|
||||
expect(settings.hooks?.SessionStart).toHaveLength(1);
|
||||
@@ -631,7 +643,9 @@ describe("Settings Manager - Hooks", () => {
|
||||
|
||||
expect(reloaded.hooks?.Stop).toHaveLength(1);
|
||||
// Simple event hooks are in SimpleHookMatcher format with hooks array
|
||||
expect(reloaded.hooks?.Stop?.[0]?.hooks[0]?.command).toBe("echo stop-hook");
|
||||
expect(asCommand(reloaded.hooks?.Stop?.[0]?.hooks[0])?.command).toBe(
|
||||
"echo stop-hook",
|
||||
);
|
||||
});
|
||||
|
||||
test("All 11 hook event types can be configured", async () => {
|
||||
|
||||
Reference in New Issue
Block a user