Something went wrong. Try again.
My version of https://github.com/vercel-labs/fx
Something went wrong. Try again.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252const std = @import("std");const definitions = @import("definitions.zig");const debug_trace = @import("../shared/debug_trace.zig");const io_mod = @import("../shared/io.zig");
const Allocator = std.mem.Allocator;
const HookEvent = definitions.HookKind;
const RegisteredHook = struct { event: HookEvent, name: []u8, ctx: *anyopaque,};
const RegisteredPreToolUseHandler = struct { hook: RegisteredHook, run: *const fn ( ctx: *anyopaque, input: definitions.PreToolUseInput, ) definitions.HandlerError!definitions.PreToolUseAction,};
const RegisteredStopHandler = struct { hook: RegisteredHook, run: *const fn ( ctx: *anyopaque, input: definitions.StopInput, ) definitions.HandlerError!definitions.StopAction,};
fn RegisteredSideEffectHandler(comptime Input: type) type { return struct { hook: RegisteredHook, run: *const fn (*anyopaque, Input) definitions.HandlerError!void, };}
const RegisteredPostTurnEndHandler = RegisteredSideEffectHandler(definitions.PostTurnEndInput);const RegisteredAttentionRequiredHandler = RegisteredSideEffectHandler(definitions.AttentionRequiredInput);
pub const Runtime = struct { alloc: Allocator, pre_tool_use_handlers: std.ArrayList(RegisteredPreToolUseHandler) = .empty, stop_handlers: std.ArrayList(RegisteredStopHandler) = .empty, post_turn_end_handlers: std.ArrayList(RegisteredPostTurnEndHandler) = .empty, attention_required_handlers: std.ArrayList(RegisteredAttentionRequiredHandler) = .empty, frozen: bool = false,
pub fn init(alloc: Allocator) Runtime { return .{ .alloc = alloc }; }
pub fn deinit(self: *Runtime) void { deinitRegisteredHooks(self.alloc, self.pre_tool_use_handlers.items); self.pre_tool_use_handlers.deinit(self.alloc); deinitRegisteredHooks(self.alloc, self.stop_handlers.items); self.stop_handlers.deinit(self.alloc); deinitRegisteredHooks(self.alloc, self.post_turn_end_handlers.items); self.post_turn_end_handlers.deinit(self.alloc); deinitRegisteredHooks(self.alloc, self.attention_required_handlers.items); self.attention_required_handlers.deinit(self.alloc); self.* = undefined; }
pub fn registerPreToolUse( self: *Runtime, handler: definitions.PreToolUseHandler, ) (Allocator.Error || definitions.RegistrationError)!void { try self.validateRegistration( .pre_tool_use, handler.name, self.pre_tool_use_handlers.items, );
const hook = try self.copyHook(.pre_tool_use, handler.name, handler.ctx); errdefer self.alloc.free(hook.name); try self.pre_tool_use_handlers.append(self.alloc, .{ .hook = hook, .run = handler.run, }); }
pub fn registerStop( self: *Runtime, handler: definitions.StopHandler, ) (Allocator.Error || definitions.RegistrationError)!void { try self.validateRegistration( .stop, handler.name, self.stop_handlers.items, );
const hook = try self.copyHook(.stop, handler.name, handler.ctx); errdefer self.alloc.free(hook.name); try self.stop_handlers.append(self.alloc, .{ .hook = hook, .run = handler.run, }); }
pub fn registerPostTurnEnd( self: *Runtime, handler: definitions.PostTurnEndHandler, ) (Allocator.Error || definitions.RegistrationError)!void { try self.registerSideEffect( .post_turn_end, handler, &self.post_turn_end_handlers, ); }
pub fn registerAttentionRequired( self: *Runtime, handler: definitions.AttentionRequiredHandler, ) (Allocator.Error || definitions.RegistrationError)!void { try self.registerSideEffect( .attention_required, handler, &self.attention_required_handlers, ); }
fn registerSideEffect( self: *Runtime, event: HookEvent, handler: anytype, registered_handlers: anytype, ) (Allocator.Error || definitions.RegistrationError)!void { try self.validateRegistration(event, handler.name, registered_handlers.items); const hook = try self.copyHook(event, handler.name, handler.ctx); errdefer self.alloc.free(hook.name); try registered_handlers.append(self.alloc, .{ .hook = hook, .run = handler.run, }); }
pub fn freeze(self: *Runtime) RuntimeView { self.frozen = true; return self.currentView(); }
pub fn view(self: *const Runtime) definitions.ViewError!RuntimeView { if (!self.frozen) return error.RuntimeNotFrozen; return self.currentView(); }
fn currentView(self: *const Runtime) RuntimeView { return .{ .pre_tool_use_handlers = self.pre_tool_use_handlers.items, .stop_handlers = self.stop_handlers.items, .post_turn_end_handlers = self.post_turn_end_handlers.items, .attention_required_handlers = self.attention_required_handlers.items, }; }
fn validateRegistration( self: *const Runtime, event: HookEvent, name: []const u8, registered_handlers: anytype, ) definitions.RegistrationError!void { if (self.frozen) return error.RuntimeFrozen; try validateHandlerName(name); for (registered_handlers) |registered| { std.debug.assert(registered.hook.event == event); if (std.mem.eql(u8, registered.hook.name, name)) return error.DuplicateHandlerName; } }
fn copyHook( self: *Runtime, event: HookEvent, name: []const u8, ctx: *anyopaque, ) Allocator.Error!RegisteredHook { return .{ .event = event, .name = try self.alloc.dupe(u8, name), .ctx = ctx, }; }};
pub const RuntimeView = struct { pre_tool_use_handlers: []const RegisteredPreToolUseHandler, stop_handlers: []const RegisteredStopHandler, post_turn_end_handlers: []const RegisteredPostTurnEndHandler, attention_required_handlers: []const RegisteredAttentionRequiredHandler,
pub fn empty() RuntimeView { return .{ .pre_tool_use_handlers = &.{}, .stop_handlers = &.{}, .post_turn_end_handlers = &.{}, .attention_required_handlers = &.{}, }; }
pub fn hasPreToolUse(self: RuntimeView) bool { return self.pre_tool_use_handlers.len != 0; }
pub fn runPreToolUse( self: RuntimeView, alloc: Allocator, input: definitions.PreToolUseInput, ) definitions.MutationDispatchError!definitions.PreToolUseOutcome { if (self.pre_tool_use_handlers.len == 0) return .unchanged;
var dispatch = PreToolUseDispatch{ .alloc = alloc, .input = input, }; errdefer dispatch.deinit(); return try dispatch.run(self.pre_tool_use_handlers); }
pub fn hasStop(self: RuntimeView) bool { return self.stop_handlers.len != 0; }
pub fn runStop( self: RuntimeView, alloc: Allocator, input: definitions.StopInput, ) definitions.StopOutcome { if (self.stop_handlers.len == 0) return .allow;
const dispatch = StopDispatch{ .alloc = alloc, .input = input, }; return dispatch.run(self.stop_handlers); }
pub fn hasPostTurnEnd(self: RuntimeView) bool { return self.post_turn_end_handlers.len != 0; }
pub fn runPostTurnEnd( self: RuntimeView, input: definitions.PostTurnEndInput, ) void { runSideEffectHandlers(input, self.post_turn_end_handlers); }
pub fn hasAttentionRequired(self: RuntimeView) bool { return self.attention_required_handlers.len != 0; }
pub fn runAttentionRequired( self: RuntimeView, input: definitions.AttentionRequiredInput, ) void { runSideEffectHandlers(input, self.attention_required_handlers); }};
const PreToolUseDispatch = struct { alloc: Allocator, input: definitions.PreToolUseInput, current_arguments: ?[]u8 = null,
fn deinit(self: *PreToolUseDispatch) void { if (self.current_arguments) |arguments_json| self.alloc.free(arguments_json); self.current_arguments = null; }
fn run( self: *PreToolUseDispatch, handlers: []const RegisteredPreToolUseHandler, ) definitions.MutationDispatchError!definitions.PreToolUseOutcome { for (handlers) |handler| { switch (try self.runHandler(handler)) { .next => {}, .blocked => |reason| return .{ .blocked = reason }, } }
return self.takeOutcome(); }
fn runHandler( self: *PreToolUseDispatch, handler: RegisteredPreToolUseHandler, ) definitions.MutationDispatchError!PreToolUseActionResult { const trace = HandlerTrace.start( handler.hook, self.input.invocation, self.input.call_id, ); const action = handler.run(handler.hook.ctx, self.handlerInput()) catch |err| { trace.fail(err); return switch (err) { error.Failed => error.HandlerFailed, error.Cancelled => error.Cancelled, }; }; return applyPreToolUseAction( self.alloc, action, &self.current_arguments, trace, ); }
fn takeOutcome(self: *PreToolUseDispatch) definitions.PreToolUseOutcome { if (self.current_arguments) |arguments_json| { self.current_arguments = null; return .{ .rewritten = arguments_json }; } return .unchanged; }
fn handlerInput(self: PreToolUseDispatch) definitions.PreToolUseInput { var input = self.input; if (self.current_arguments) |arguments_json| input.arguments_json = arguments_json; return input; }};
const PreToolUseActionResult = union(enum) { next, blocked: []u8,};
fn applyPreToolUseAction( alloc: Allocator, action: definitions.PreToolUseAction, current_arguments: *?[]u8, trace: HandlerTrace,) definitions.MutationDispatchError!PreToolUseActionResult { switch (action) { .continue_ => { trace.finish("continue"); return .next; }, .rewrite_arguments => |borrowed| { if (borrowed.len > definitions.Limits.arguments_json_bytes) { trace.finish("rewrite_too_large"); return error.HandlerOutputTooLarge; }
const copied = alloc.dupe(u8, borrowed) catch { trace.finish("copy_failed"); return error.OutOfMemory; }; validateJsonObject(alloc, copied) catch |err| { alloc.free(copied); trace.fail(err); return err; };
if (current_arguments.*) |previous| alloc.free(previous); current_arguments.* = copied; trace.finish("rewrite"); return .next; }, .block => |borrowed| { if (borrowed.len == 0 or !std.unicode.utf8ValidateSlice(borrowed)) { trace.finish("invalid_block"); return error.InvalidHandlerOutput; } if (borrowed.len > definitions.Limits.reason_bytes) { trace.finish("block_too_large"); return error.HandlerOutputTooLarge; }
const reason = alloc.dupe(u8, borrowed) catch { trace.finish("copy_failed"); return error.OutOfMemory; }; if (current_arguments.*) |previous| { alloc.free(previous); current_arguments.* = null; } trace.finish("block"); return .{ .blocked = reason }; }, }}
const StopActionResult = union(enum) { next, done: definitions.StopOutcome,};
const StopDispatch = struct { alloc: Allocator, input: definitions.StopInput,
fn run( self: StopDispatch, handlers: []const RegisteredStopHandler, ) definitions.StopOutcome { for (handlers) |handler| { switch (self.runHandler(handler)) { .next => {}, .done => |outcome| return outcome, } } return .allow; }
fn runHandler( self: StopDispatch, handler: RegisteredStopHandler, ) StopActionResult { const trace = HandlerTrace.start( handler.hook, self.input.invocation, null, ); const action = handler.run(handler.hook.ctx, self.input) catch |err| { trace.fail(err); return .{ .done = .allow }; }; return applyStopAction( self.alloc, action, self.input.can_continue, trace, ); }};
fn applyStopAction( alloc: Allocator, action: definitions.StopAction, can_continue: bool, trace: HandlerTrace,) StopActionResult { switch (action) { .allow => { trace.finish("allow"); return .next; }, .continue_once => |borrowed| { if (!can_continue) { trace.finish("continue_not_allowed"); return .{ .done = .allow }; } if (borrowed.len > definitions.Limits.context_bytes or !std.unicode.utf8ValidateSlice(borrowed)) { trace.finish("invalid_continue"); return .{ .done = .allow }; }
const context = alloc.dupe(u8, borrowed) catch { trace.finish("copy_failed"); return .{ .done = .allow }; }; trace.finish("continue_once"); return .{ .done = .{ .continue_once = context } }; }, }}
fn runSideEffectHandlers(input: anytype, handlers: anytype) void { for (handlers) |handler| { const trace = HandlerTrace.start( handler.hook, input.invocation, null, ); handler.run(handler.hook.ctx, input) catch |err| { trace.fail(err); continue; }; trace.finish("ok"); }}
fn deinitRegisteredHooks(alloc: Allocator, registered_handlers: anytype) void { for (registered_handlers) |handler| alloc.free(handler.hook.name);}
fn validateHandlerName(name: []const u8) definitions.RegistrationError!void { if (name.len == 0) return error.EmptyHandlerName; if (name.len > definitions.Limits.handler_name_bytes) return error.HandlerNameTooLong; for (name) |byte| { const valid = std.ascii.isAlphanumeric(byte) or byte == '.' or byte == '_' or byte == '-'; if (!valid) return error.InvalidHandlerName; }}
fn validateJsonObject( alloc: Allocator, arguments_json: []const u8,) definitions.MutationDispatchError!void { if (!std.unicode.utf8ValidateSlice(arguments_json)) return error.InvalidHandlerOutput; var parsed = std.json.parseFromSlice(std.json.Value, alloc, arguments_json, .{}) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return error.InvalidHandlerOutput; }; defer parsed.deinit(); if (parsed.value != .object) return error.InvalidHandlerOutput;}
const HandlerTrace = struct { event: HookEvent, handler_name: []const u8, invocation: definitions.Invocation, call_id: ?[]const u8, started_ms: i64,
fn start( hook: RegisteredHook, invocation: definitions.Invocation, call_id: ?[]const u8, ) HandlerTrace { const trace: HandlerTrace = .{ .event = hook.event, .handler_name = hook.name, .invocation = invocation, .call_id = call_id, .started_ms = io_mod.milliTimestamp(), }; trace.emitStart(); return trace; }
fn finish(self: HandlerTrace, outcome: []const u8) void { const duration_ms = self.durationMs(); const trace_ctx = traceContext(self.invocation); if (self.call_id) |id| { debug_trace.eventf( "hooks", "handler_finish", trace_ctx, "lifecycle_event={s} handler={s} scope={s} call_id={s} duration_ms={d} outcome={s}", .{ self.event.definition().lifecycle_event, self.handler_name, @tagName(self.invocation.scope.kind), id, duration_ms, outcome }, ); } else { debug_trace.eventf( "hooks", "handler_finish", trace_ctx, "lifecycle_event={s} handler={s} scope={s} duration_ms={d} outcome={s}", .{ self.event.definition().lifecycle_event, self.handler_name, @tagName(self.invocation.scope.kind), duration_ms, outcome }, ); } }
fn fail(self: HandlerTrace, err: anyerror) void { const duration_ms = self.durationMs(); const trace_ctx = traceContext(self.invocation); if (self.call_id) |id| { debug_trace.eventf( "hooks", "handler_finish", trace_ctx, "lifecycle_event={s} handler={s} scope={s} call_id={s} duration_ms={d} outcome=error error={s}", .{ self.event.definition().lifecycle_event, self.handler_name, @tagName(self.invocation.scope.kind), id, duration_ms, @errorName(err) }, ); } else { debug_trace.eventf( "hooks", "handler_finish", trace_ctx, "lifecycle_event={s} handler={s} scope={s} duration_ms={d} outcome=error error={s}", .{ self.event.definition().lifecycle_event, self.handler_name, @tagName(self.invocation.scope.kind), duration_ms, @errorName(err) }, ); } }
fn emitStart(self: HandlerTrace) void { const trace_ctx = traceContext(self.invocation); if (self.call_id) |id| { debug_trace.eventf( "hooks", "handler_start", trace_ctx, "lifecycle_event={s} handler={s} scope={s} call_id={s}", .{ self.event.definition().lifecycle_event, self.handler_name, @tagName(self.invocation.scope.kind), id }, ); } else { debug_trace.eventf( "hooks", "handler_start", trace_ctx, "lifecycle_event={s} handler={s} scope={s}", .{ self.event.definition().lifecycle_event, self.handler_name, @tagName(self.invocation.scope.kind) }, ); } }
fn durationMs(self: HandlerTrace) i64 { return @max(io_mod.milliTimestamp() - self.started_ms, 0); }};
fn traceContext(invocation: definitions.Invocation) debug_trace.TraceContext { return .{ .turn_id = invocation.turn_id orelse 0, .subagent_id = invocation.scope.subagent_id orelse 0, };}
fn testInvocation() definitions.Invocation { return .{ .scope = .{ .kind = .interactive, .workspace_root = "/tmp/workspace", .session_id = "session", }, .turn_id = 42, };}
const TestHandler = struct { fn continueTool(_: *anyopaque, _: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { return .continue_; }
fn allowStop(_: *anyopaque, _: definitions.StopInput) definitions.HandlerError!definitions.StopAction { return .allow; }
fn notePostTurnEnd(_: *anyopaque, _: definitions.PostTurnEndInput) definitions.HandlerError!void {}
fn noteAttentionRequired(_: *anyopaque, _: definitions.AttentionRequiredInput) definitions.HandlerError!void {}};
test "runtime validates names owns copies freezes and exposes lifecycle events" { const alloc = std.testing.allocator; var runtime = Runtime.init(alloc); defer runtime.deinit();
try std.testing.expectError(error.RuntimeNotFrozen, runtime.view()); try std.testing.expectError(error.EmptyHandlerName, runtime.registerPreToolUse(.{ .name = "", .ctx = undefined, .run = TestHandler.continueTool, })); try std.testing.expectError(error.InvalidHandlerName, runtime.registerPreToolUse(.{ .name = "bad name", .ctx = undefined, .run = TestHandler.continueTool, }));
var long_name: [definitions.Limits.handler_name_bytes + 1]u8 = undefined; @memset(&long_name, 'a'); try std.testing.expectError(error.HandlerNameTooLong, runtime.registerPreToolUse(.{ .name = &long_name, .ctx = undefined, .run = TestHandler.continueTool, }));
var mutable_name = [_]u8{ 'a', 'l', 'p', 'h', 'a' }; try runtime.registerPreToolUse(.{ .name = &mutable_name, .ctx = undefined, .run = TestHandler.continueTool, }); mutable_name[0] = 'x'; try std.testing.expectError(error.DuplicateHandlerName, runtime.registerPreToolUse(.{ .name = "alpha", .ctx = undefined, .run = TestHandler.continueTool, })); try runtime.registerStop(.{ .name = "alpha", .ctx = undefined, .run = TestHandler.allowStop, }); try runtime.registerPostTurnEnd(.{ .name = "alpha", .ctx = undefined, .run = TestHandler.notePostTurnEnd, }); try std.testing.expectError(error.DuplicateHandlerName, runtime.registerPostTurnEnd(.{ .name = "alpha", .ctx = undefined, .run = TestHandler.notePostTurnEnd, })); try runtime.registerAttentionRequired(.{ .name = "alpha", .ctx = undefined, .run = TestHandler.noteAttentionRequired, }); try std.testing.expectError(error.DuplicateHandlerName, runtime.registerAttentionRequired(.{ .name = "alpha", .ctx = undefined, .run = TestHandler.noteAttentionRequired, }));
const view = runtime.freeze(); const second_view = runtime.freeze(); try std.testing.expect(view.hasPreToolUse()); try std.testing.expect(view.hasStop()); try std.testing.expect(view.hasPostTurnEnd()); try std.testing.expect(view.hasAttentionRequired()); try std.testing.expect(second_view.hasPreToolUse()); try std.testing.expect(second_view.hasStop()); try std.testing.expect(second_view.hasPostTurnEnd()); try std.testing.expect(second_view.hasAttentionRequired()); try std.testing.expectError(error.RuntimeFrozen, runtime.registerStop(.{ .name = "late", .ctx = undefined, .run = TestHandler.allowStop, })); try std.testing.expectError(error.RuntimeFrozen, runtime.registerPostTurnEnd(.{ .name = "late", .ctx = undefined, .run = TestHandler.notePostTurnEnd, })); try std.testing.expectError(error.RuntimeFrozen, runtime.registerAttentionRequired(.{ .name = "late", .ctx = undefined, .run = TestHandler.noteAttentionRequired, }));
try std.testing.expect(!@hasDecl(Runtime, "registerSessionStart")); try std.testing.expect(!@hasDecl(Runtime, "registerUserPromptSubmit")); try std.testing.expect(!@hasDecl(Runtime, "registerPermissionRequest")); try std.testing.expect(!@hasDecl(Runtime, "registerPostToolUse")); try std.testing.expect(!@hasDecl(Runtime, "registerPostToolFailure"));}
test "empty views dispatch without allocation" { var runtime = Runtime.init(std.testing.allocator); defer runtime.deinit(); const views = [_]RuntimeView{ runtime.freeze(), RuntimeView.empty() };
for (views) |view| { var failing = std.testing.FailingAllocator.init(std.testing.allocator, .{ .fail_index = 0 }); try std.testing.expect(!view.hasPreToolUse()); try std.testing.expect(!view.hasStop()); try std.testing.expect(!view.hasPostTurnEnd()); try std.testing.expect(!view.hasAttentionRequired());
var pre = try view.runPreToolUse(failing.allocator(), .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "read_file", .arguments_json = "{}", }); defer pre.deinit(failing.allocator()); try std.testing.expect(pre == .unchanged);
var stop = view.runStop(failing.allocator(), .{ .invocation = testInvocation(), .step_index = 1, .assistant_text = "done", .provider_disposition = .completed, .can_continue = true, }); defer stop.deinit(failing.allocator()); try std.testing.expect(stop == .allow);
view.runPostTurnEnd(.{ .invocation = testInvocation(), .outcome = .completed, .provider_disposition = .completed, }); view.runAttentionRequired(.{ .invocation = testInvocation(), .kind = .permission, }); try std.testing.expectEqual(@as(usize, 0), failing.alloc_index); }}
const RewriteCapture = struct { order: [4]u8 = undefined, count: usize = 0, second_saw_first: bool = false, third_called: bool = false, mutable_rewrite: [7]u8 = .{ '{', '"', 'a', '"', ':', '1', '}' },
fn note(self: *RewriteCapture, value: u8) void { self.order[self.count] = value; self.count += 1; }
fn first(raw: *anyopaque, _: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { const self: *RewriteCapture = @ptrCast(@alignCast(raw)); self.note(1); return .{ .rewrite_arguments = &self.mutable_rewrite }; }
fn second(raw: *anyopaque, input: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { const self: *RewriteCapture = @ptrCast(@alignCast(raw)); self.note(2); self.second_saw_first = std.mem.eql(u8, input.arguments_json, "{\"a\":1}"); return .{ .rewrite_arguments = "{\"b\":2}" }; }
fn block(raw: *anyopaque, input: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { const self: *RewriteCapture = @ptrCast(@alignCast(raw)); self.note(3); if (!std.mem.eql(u8, input.arguments_json, "{\"b\":2}")) return error.Failed; return .{ .block = "blocked by policy" }; }
fn third(raw: *anyopaque, _: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { const self: *RewriteCapture = @ptrCast(@alignCast(raw)); self.third_called = true; return .continue_; }};
test "PreToolUse chains rewrites in registration order and first block wins" { const alloc = std.testing.allocator; var capture = RewriteCapture{}; var runtime = Runtime.init(alloc); defer runtime.deinit(); try runtime.registerPreToolUse(.{ .name = "first", .ctx = &capture, .run = RewriteCapture.first }); try runtime.registerPreToolUse(.{ .name = "second", .ctx = &capture, .run = RewriteCapture.second }); try runtime.registerPreToolUse(.{ .name = "block", .ctx = &capture, .run = RewriteCapture.block }); try runtime.registerPreToolUse(.{ .name = "never", .ctx = &capture, .run = RewriteCapture.third }); var outcome = try runtime.freeze().runPreToolUse(alloc, .{ .invocation = testInvocation(), .step_index = 2, .call_id = "call-1", .tool_name = "read_file", .arguments_json = "{\"original\":true}", }); defer outcome.deinit(alloc);
try std.testing.expect(outcome == .blocked); try std.testing.expectEqualStrings("blocked by policy", outcome.blocked); try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3 }, capture.order[0..capture.count]); try std.testing.expect(capture.second_saw_first); try std.testing.expect(!capture.third_called);}
const OutputHandler = struct { output: []const u8, action: enum { rewrite, block, stop } = .rewrite,
fn runTool(raw: *anyopaque, _: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { const self: *OutputHandler = @ptrCast(@alignCast(raw)); return switch (self.action) { .rewrite => .{ .rewrite_arguments = self.output }, .block => .{ .block = self.output }, .stop => .continue_, }; }
fn runStop(raw: *anyopaque, _: definitions.StopInput) definitions.HandlerError!definitions.StopAction { const self: *OutputHandler = @ptrCast(@alignCast(raw)); return .{ .continue_once = self.output }; }};
test "PreToolUse returns an owned final rewrite" { const alloc = std.testing.allocator; var source = [_]u8{ '{', '"', 'x', '"', ':', '1', '}' }; var handler = OutputHandler{ .output = &source }; var runtime = Runtime.init(alloc); defer runtime.deinit(); try runtime.registerPreToolUse(.{ .name = "rewrite", .ctx = &handler, .run = OutputHandler.runTool }); var outcome = try runtime.freeze().runPreToolUse(alloc, .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "tool", .arguments_json = "{}", }); defer outcome.deinit(alloc); source[5] = '9';
try std.testing.expect(outcome == .rewritten); try std.testing.expectEqualStrings("{\"x\":1}", outcome.rewritten);}
test "PreToolUse rejects non-object malformed trailing and oversized rewrites" { const alloc = std.testing.allocator; const cases = [_][]const u8{ "[]", "1", "null", "{", "{} trailing", };
for (cases) |output| { var handler = OutputHandler{ .output = output }; var runtime = Runtime.init(alloc); defer runtime.deinit(); try runtime.registerPreToolUse(.{ .name = "invalid", .ctx = &handler, .run = OutputHandler.runTool }); try std.testing.expectError(error.InvalidHandlerOutput, runtime.freeze().runPreToolUse(alloc, .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "tool", .arguments_json = "{}", })); }
const oversized = try alloc.alloc(u8, definitions.Limits.arguments_json_bytes + 1); defer alloc.free(oversized); @memset(oversized, ' '); var handler = OutputHandler{ .output = oversized }; var runtime = Runtime.init(alloc); defer runtime.deinit(); try runtime.registerPreToolUse(.{ .name = "oversized", .ctx = &handler, .run = OutputHandler.runTool }); try std.testing.expectError(error.HandlerOutputTooLarge, runtime.freeze().runPreToolUse(alloc, .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "tool", .arguments_json = "{}", }));}
const ErrorHandler = struct { cancelled: bool,
fn runTool(raw: *anyopaque, _: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { const self: *ErrorHandler = @ptrCast(@alignCast(raw)); if (self.cancelled) return error.Cancelled; return error.Failed; }
fn runStop(raw: *anyopaque, _: definitions.StopInput) definitions.HandlerError!definitions.StopAction { const self: *ErrorHandler = @ptrCast(@alignCast(raw)); if (self.cancelled) return error.Cancelled; return error.Failed; }};
test "PreToolUse maps handler errors and allocation failure without leaking prior rewrites" { const alloc = std.testing.allocator; for ([_]bool{ false, true }) |cancelled| { var handler = ErrorHandler{ .cancelled = cancelled }; var runtime = Runtime.init(alloc); defer runtime.deinit(); try runtime.registerPreToolUse(.{ .name = "error", .ctx = &handler, .run = ErrorHandler.runTool }); const result = runtime.freeze().runPreToolUse(alloc, .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "tool", .arguments_json = "{}", }); if (cancelled) { try std.testing.expectError(error.Cancelled, result); } else { try std.testing.expectError(error.HandlerFailed, result); } }
var rewrite_handler = OutputHandler{ .output = "{\"ok\":true}" }; var error_handler = ErrorHandler{ .cancelled = false }; var chained_runtime = Runtime.init(alloc); defer chained_runtime.deinit(); try chained_runtime.registerPreToolUse(.{ .name = "rewrite", .ctx = &rewrite_handler, .run = OutputHandler.runTool }); try chained_runtime.registerPreToolUse(.{ .name = "error", .ctx = &error_handler, .run = ErrorHandler.runTool }); try std.testing.expectError(error.HandlerFailed, chained_runtime.freeze().runPreToolUse(alloc, .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "tool", .arguments_json = "{}", }));
var handler = OutputHandler{ .output = "{\"ok\":true}" }; var runtime = Runtime.init(alloc); defer runtime.deinit(); try runtime.registerPreToolUse(.{ .name = "rewrite", .ctx = &handler, .run = OutputHandler.runTool }); var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 }); try std.testing.expectError(error.OutOfMemory, runtime.freeze().runPreToolUse(failing.allocator(), .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "tool", .arguments_json = "{}", }));}
const StopCapture = struct { calls: usize = 0, third_called: bool = false, mutable_context: [8]u8 = .{ 'c', 'o', 'n', 't', 'i', 'n', 'u', 'e' },
fn allow(raw: *anyopaque, _: definitions.StopInput) definitions.HandlerError!definitions.StopAction { const self: *StopCapture = @ptrCast(@alignCast(raw)); self.calls += 1; return .allow; }
fn continueOnce(raw: *anyopaque, _: definitions.StopInput) definitions.HandlerError!definitions.StopAction { const self: *StopCapture = @ptrCast(@alignCast(raw)); self.calls += 1; return .{ .continue_once = &self.mutable_context }; }
fn third(raw: *anyopaque, _: definitions.StopInput) definitions.HandlerError!definitions.StopAction { const self: *StopCapture = @ptrCast(@alignCast(raw)); self.third_called = true; return .allow; }};
test "Stop returns the first owned continuation and honors can_continue" { const alloc = std.testing.allocator; var capture = StopCapture{}; var runtime = Runtime.init(alloc); defer runtime.deinit(); try runtime.registerStop(.{ .name = "allow", .ctx = &capture, .run = StopCapture.allow }); try runtime.registerStop(.{ .name = "continue", .ctx = &capture, .run = StopCapture.continueOnce }); try runtime.registerStop(.{ .name = "never", .ctx = &capture, .run = StopCapture.third }); const view = runtime.freeze();
var outcome = view.runStop(alloc, .{ .invocation = testInvocation(), .step_index = 3, .assistant_text = "candidate", .provider_disposition = .completed, .can_continue = true, }); defer outcome.deinit(alloc); capture.mutable_context[0] = 'X'; try std.testing.expect(outcome == .continue_once); try std.testing.expectEqualStrings("continue", outcome.continue_once); try std.testing.expectEqual(@as(usize, 2), capture.calls); try std.testing.expect(!capture.third_called);
var normalized = view.runStop(alloc, .{ .invocation = testInvocation(), .step_index = 3, .assistant_text = "candidate", .provider_disposition = .length_limited, .can_continue = false, }); defer normalized.deinit(alloc); try std.testing.expect(normalized == .allow);}
test "Stop fails open on handler errors, output limits, and copy failures" { const alloc = std.testing.allocator;
for ([_]bool{ false, true }) |cancelled| { var error_handler = ErrorHandler{ .cancelled = cancelled }; var error_runtime = Runtime.init(alloc); defer error_runtime.deinit(); try error_runtime.registerStop(.{ .name = "error", .ctx = &error_handler, .run = ErrorHandler.runStop }); var handler_failed = error_runtime.freeze().runStop(alloc, .{ .invocation = testInvocation(), .step_index = 1, .assistant_text = "done", .provider_disposition = .completed, .can_continue = true, }); defer handler_failed.deinit(alloc); try std.testing.expect(handler_failed == .allow); }
const oversized = try alloc.alloc(u8, definitions.Limits.context_bytes + 1); defer alloc.free(oversized); var output_handler = OutputHandler{ .output = oversized }; var output_runtime = Runtime.init(alloc); defer output_runtime.deinit(); try output_runtime.registerStop(.{ .name = "oversized", .ctx = &output_handler, .run = OutputHandler.runStop }); const view = output_runtime.freeze(); var oversized_outcome = view.runStop(alloc, .{ .invocation = testInvocation(), .step_index = 1, .assistant_text = "done", .provider_disposition = .completed, .can_continue = true, }); defer oversized_outcome.deinit(alloc); try std.testing.expect(oversized_outcome == .allow);
output_handler.output = "continue"; var failing = std.testing.FailingAllocator.init(alloc, .{ .fail_index = 0 }); var allocation_failed = view.runStop(failing.allocator(), .{ .invocation = testInvocation(), .step_index = 1, .assistant_text = "done", .provider_disposition = .completed, .can_continue = true, }); defer allocation_failed.deinit(failing.allocator()); try std.testing.expect(allocation_failed == .allow);}
const PostTurnEndCapture = struct { order: [4]u8 = undefined, count: usize = 0, saw_scope: bool = false, saw_turn_id: bool = false, saw_outcome: bool = false, saw_disposition: bool = false,
fn note(self: *PostTurnEndCapture, value: u8) void { self.order[self.count] = value; self.count += 1; }
fn first(raw: *anyopaque, input: definitions.PostTurnEndInput) definitions.HandlerError!void { const self: *PostTurnEndCapture = @ptrCast(@alignCast(raw)); self.note(1); self.saw_scope = input.invocation.scope.kind == .interactive and std.mem.eql(u8, input.invocation.scope.workspace_root, "/tmp/workspace") and std.mem.eql(u8, input.invocation.scope.session_id orelse "", "session"); self.saw_turn_id = input.invocation.turn_id == 42; self.saw_outcome = input.outcome == .completed; self.saw_disposition = input.provider_disposition == .length_limited; }
fn failing(raw: *anyopaque, _: definitions.PostTurnEndInput) definitions.HandlerError!void { const self: *PostTurnEndCapture = @ptrCast(@alignCast(raw)); self.note(2); return error.Failed; }
fn cancelled(raw: *anyopaque, _: definitions.PostTurnEndInput) definitions.HandlerError!void { const self: *PostTurnEndCapture = @ptrCast(@alignCast(raw)); self.note(3); return error.Cancelled; }
fn fourth(raw: *anyopaque, _: definitions.PostTurnEndInput) definitions.HandlerError!void { const self: *PostTurnEndCapture = @ptrCast(@alignCast(raw)); self.note(4); }};
test "PostTurnEnd runs side-effect handlers in order and fails open on handler errors" { const alloc = std.testing.allocator; var capture = PostTurnEndCapture{}; var runtime = Runtime.init(alloc); defer runtime.deinit();
try runtime.registerPostTurnEnd(.{ .name = "first", .ctx = &capture, .run = PostTurnEndCapture.first }); try runtime.registerPostTurnEnd(.{ .name = "failing", .ctx = &capture, .run = PostTurnEndCapture.failing }); try runtime.registerPostTurnEnd(.{ .name = "cancelled", .ctx = &capture, .run = PostTurnEndCapture.cancelled }); try runtime.registerPostTurnEnd(.{ .name = "fourth", .ctx = &capture, .run = PostTurnEndCapture.fourth });
runtime.freeze().runPostTurnEnd(.{ .invocation = testInvocation(), .outcome = .completed, .provider_disposition = .length_limited, });
try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4 }, capture.order[0..capture.count]); try std.testing.expect(capture.saw_scope); try std.testing.expect(capture.saw_turn_id); try std.testing.expect(capture.saw_outcome); try std.testing.expect(capture.saw_disposition);}
const AttentionRequiredCapture = struct { order: [4]u8 = undefined, count: usize = 0, saw_input: bool = false,
fn note(self: *AttentionRequiredCapture, value: u8) void { self.order[self.count] = value; self.count += 1; }
fn first(raw: *anyopaque, input: definitions.AttentionRequiredInput) definitions.HandlerError!void { const self: *AttentionRequiredCapture = @ptrCast(@alignCast(raw)); self.note(1); self.saw_input = input.invocation.scope.kind == .interactive and input.invocation.turn_id == 42 and input.kind == .route_recovery; }
fn failing(raw: *anyopaque, _: definitions.AttentionRequiredInput) definitions.HandlerError!void { const self: *AttentionRequiredCapture = @ptrCast(@alignCast(raw)); self.note(2); return error.Failed; }
fn cancelled(raw: *anyopaque, _: definitions.AttentionRequiredInput) definitions.HandlerError!void { const self: *AttentionRequiredCapture = @ptrCast(@alignCast(raw)); self.note(3); return error.Cancelled; }
fn fourth(raw: *anyopaque, _: definitions.AttentionRequiredInput) definitions.HandlerError!void { const self: *AttentionRequiredCapture = @ptrCast(@alignCast(raw)); self.note(4); }};
test "AttentionRequired runs in order propagates input and fails open" { var capture = AttentionRequiredCapture{}; var runtime = Runtime.init(std.testing.allocator); defer runtime.deinit();
try runtime.registerAttentionRequired(.{ .name = "first", .ctx = &capture, .run = AttentionRequiredCapture.first }); try runtime.registerAttentionRequired(.{ .name = "failing", .ctx = &capture, .run = AttentionRequiredCapture.failing }); try runtime.registerAttentionRequired(.{ .name = "cancelled", .ctx = &capture, .run = AttentionRequiredCapture.cancelled }); try runtime.registerAttentionRequired(.{ .name = "fourth", .ctx = &capture, .run = AttentionRequiredCapture.fourth });
runtime.freeze().runAttentionRequired(.{ .invocation = testInvocation(), .kind = .route_recovery, });
try std.testing.expectEqualSlices(u8, &.{ 1, 2, 3, 4 }, capture.order[0..capture.count]); try std.testing.expect(capture.saw_input);}
const ConcurrentHandler = struct { calls: std.atomic.Value(usize) = std.atomic.Value(usize).init(0),
fn run(raw: *anyopaque, _: definitions.PreToolUseInput) definitions.HandlerError!definitions.PreToolUseAction { const self: *ConcurrentHandler = @ptrCast(@alignCast(raw)); _ = self.calls.fetchAdd(1, .seq_cst); return .continue_; }};
const ConcurrentRun = struct { view: RuntimeView, failed: *std.atomic.Value(bool),
fn run(self: ConcurrentRun) void { var outcome = self.view.runPreToolUse(std.heap.page_allocator, .{ .invocation = testInvocation(), .step_index = 1, .call_id = "call", .tool_name = "tool", .arguments_json = "{}", }) catch { self.failed.store(true, .seq_cst); return; }; outcome.deinit(std.heap.page_allocator); }};
test "a frozen view is safe to copy across concurrent invocations" { var handler = ConcurrentHandler{}; var runtime = Runtime.init(std.testing.allocator); defer runtime.deinit(); try runtime.registerPreToolUse(.{ .name = "concurrent", .ctx = &handler, .run = ConcurrentHandler.run }); const view = runtime.freeze(); var failed = std.atomic.Value(bool).init(false);
const first = try std.Thread.spawn(.{}, ConcurrentRun.run, .{ConcurrentRun{ .view = view, .failed = &failed }}); const second = try std.Thread.spawn(.{}, ConcurrentRun.run, .{ConcurrentRun{ .view = view, .failed = &failed }}); first.join(); second.join();
try std.testing.expect(!failed.load(.seq_cst)); try std.testing.expectEqual(@as(usize, 2), handler.calls.load(.seq_cst));}