Something went wrong. Try again.
My version of https://github.com/vercel-labs/fx
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259const std = @import("std");const types = @import("../../../shared/types.zig");const debug_trace = @import("../../../shared/debug_trace.zig");const io_mod = @import("../../../shared/io.zig");const tool_result_errors = @import("../../../tooling/tool_result_errors.zig");const lifecycle_hooks = @import("../../../hooks/hooks.zig");const session_runtime = @import("../../../session/session.zig");
const test_support = @import("support.zig");const runtime_finalization = @import("../finalization.zig");const runtime_orchestrator = @import("../orchestrator.zig");const runtime_telemetry = @import("../telemetry.zig");
const ChatMessage = types.ChatMessage;const ToolCall = types.ToolCall;const TurnFinalizationGuard = runtime_finalization.TurnFinalizationGuard;const PromptFinishTrace = runtime_finalization.PromptFinishTrace;const TurnSummaryAccumulator = runtime_telemetry.TurnSummaryAccumulator;const CommonStopState = runtime_orchestrator.CommonStopState;const copyLatestStopPartial = runtime_orchestrator.copyLatestStopPartial;
const FakeCompletion = test_support.FakeCompletion;const FakeGateway = test_support.FakeGateway;const FakeAgentRuntimeDeps = test_support.FakeAgentRuntimeDeps;const FakeExecPlan = test_support.FakeExecPlan;const PromptFixture = test_support.PromptFixture;const StopTestHandler = test_support.StopTestHandler;
const runFakePrompt = test_support.runFakePrompt;const runFakePromptWithLifecycle = test_support.runFakePromptWithLifecycle;const registerStopTestHandler = test_support.registerStopTestHandler;const testLifecycleContext = test_support.testLifecycleContext;const finishCommonAssistantTerminal = runtime_orchestrator.finishCommonAssistantTerminal;const expectBodyContains = test_support.expectBodyContains;const expectBodyContainsInOrder = test_support.expectBodyContainsInOrder;const expectBodyNotContains = test_support.expectBodyNotContains;const countText = test_support.countText;const countNeedle = test_support.countNeedle;const readTraceFile = test_support.readTraceFile;const textContains = test_support.textContains;const logIndex = test_support.logIndex;const toolCall = test_support.toolCall;
const PostTurnEndFinalizationCapture = struct { calls: usize = 0, turn_ids: [8]u64 = undefined, scope_kinds: [8]lifecycle_hooks.ScopeKind = undefined, workspace_matches: [8]bool = undefined, outcomes: [8]types.TurnPresentationOutcome = undefined, dispositions: [8]?types.ProviderCompletionDisposition = undefined, finish_event_attempts: [8]usize = undefined, deps: ?*FakeAgentRuntimeDeps = null,
fn run(raw: *anyopaque, input: lifecycle_hooks.PostTurnEndInput) lifecycle_hooks.HandlerError!void { const self: *PostTurnEndFinalizationCapture = @ptrCast(@alignCast(raw)); const index = self.calls; self.calls += 1; self.turn_ids[index] = input.invocation.turn_id orelse 0; self.scope_kinds[index] = input.invocation.scope.kind; self.workspace_matches[index] = std.mem.eql( u8, input.invocation.scope.workspace_root, "/tmp/workspace", ); self.outcomes[index] = input.outcome; self.dispositions[index] = input.provider_disposition; self.finish_event_attempts[index] = if (self.deps) |deps| deps.finish_event_attempt_count else 0; }};
test "processQueuedPrompt preserves original final history before finish event" { const alloc = std.testing.allocator; const chunks = [_][]const u8{" **Hello** "}; const completions = [_]FakeCompletion{.{ .chunks = &chunks, .content = " **Hello** ", }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{};
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
try std.testing.expectEqualStrings(" **Hello** ", hooks.history_assistant_text.?); try std.testing.expectEqualStrings(" **Hello** ", hooks.finish_assistant_text.?); try std.testing.expectEqual(@as(?types.TurnPresentationOutcome, .completed), hooks.finish_terminal_outcome); const newline_idx = logIndex(&hooks, "text:newline").?; const finish_idx = logIndex(&hooks, "event:finish_prompt").?; try std.testing.expect(newline_idx < finish_idx); try expectBodyNotContains(&gateway, 0, "<turn_aborted>");}
test "processQueuedPrompt propagates completed summary with durable history" { const alloc = std.testing.allocator; const completions = [_]FakeCompletion{.{ .content = "complete", .usage = .{ .input_tokens = 12, .output_tokens = 34 }, }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{};
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); const propagated_summary = types.historyTurnSummary( hooks.history_turns.items[0], ) orelse return error.TestExpectedTurnSummary; try std.testing.expectEqual( hooks.finish_summary.?, propagated_summary, );}
test "processQueuedPrompt pauses missing finish without synthesizing output" { const alloc = std.testing.allocator; const completions = [_]FakeCompletion{.{ .omit_finish = true }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{};
var config = fixture.config(); config.max_provider_attempts = 1; // Missing finish retries autonomously now; park the turn with a lifecycle // pause (the user's try-later) raised when the retry is scheduled. var pause_flag = std.atomic.Value(bool).init(false); hooks.pause_on_auto_retry_status = true; hooks.recovery_pause_flag = &pause_flag; config.recovery_pause_flag = &pause_flag; try runFakePrompt(&gateway, &hooks, config, fixture.job());
try std.testing.expectEqual(@as(usize, 0), hooks.finish_event_count); try std.testing.expectEqual(@as(usize, 0), hooks.history_turns.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.system_notices.items.len); try std.testing.expect(!textContains(&hooks, "Done.")); try std.testing.expectEqual(types.TurnPresentationOutcome.paused, hooks.finalized_outcome.?);}
test "processQueuedPrompt pauses partial text without finish proof" { const alloc = std.testing.allocator; const chunks = [_][]const u8{"Partial answer"}; const completions = [_]FakeCompletion{.{ .chunks = &chunks, .omit_finish = true, }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{};
var config = fixture.config(); config.max_provider_attempts = 1; // Missing finish retries autonomously now; park the turn with a lifecycle // pause (the user's try-later) raised when the retry is scheduled. var pause_flag = std.atomic.Value(bool).init(false); hooks.pause_on_auto_retry_status = true; hooks.recovery_pause_flag = &pause_flag; config.recovery_pause_flag = &pause_flag; try runFakePrompt(&gateway, &hooks, config, fixture.job());
try std.testing.expect(textContains(&hooks, "Partial answer")); try std.testing.expect(!textContains(&hooks, "Done.")); try std.testing.expectEqual(@as(usize, 0), hooks.finish_event_count); try std.testing.expectEqual(@as(usize, 0), hooks.history_turns.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.system_notices.items.len); try std.testing.expectEqual(types.TurnPresentationOutcome.paused, hooks.finalized_outcome.?);}
test "processQueuedPrompt pauses tool calls without finish proof" { const alloc = std.testing.allocator; const calls = [_]ToolCall{toolCall("call_write", "write_file", "{\"path\":\"a.txt\",\"content\":\"x\"}")}; const completions = [_]FakeCompletion{.{ .tool_calls = &calls, .omit_finish = true, }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.agent_step_limit = 1; config.max_provider_attempts = 1; // Missing finish retries autonomously now; park the turn with a lifecycle // pause (the user's try-later) raised when the retry is scheduled. var pause_flag = std.atomic.Value(bool).init(false); hooks.pause_on_auto_retry_status = true; hooks.recovery_pause_flag = &pause_flag; config.recovery_pause_flag = &pause_flag;
try runFakePrompt(&gateway, &hooks, config, fixture.job());
try std.testing.expectEqual(@as(usize, 0), hooks.permission_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.executed_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.finish_event_count); try std.testing.expectEqual(@as(usize, 0), hooks.history_turns.items.len); try std.testing.expectEqual(types.TurnPresentationOutcome.paused, hooks.finalized_outcome.?);}
test "processQueuedPrompt preserves finish precedence over malformed argument recovery" { const alloc = std.testing.allocator; const calls = [_]ToolCall{.{ .id = "call_bad", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const Expected = enum { paused, model_error, length_limited, }; const Case = struct { finish_reason: ?types.ProviderFinishReason = null, omit_finish: bool = false, expected: Expected, }; const cases = [_]Case{ .{ .omit_finish = true, .expected = .paused }, .{ .finish_reason = .provider_error, .expected = .paused }, .{ .finish_reason = .stop, .expected = .model_error }, .{ .finish_reason = .length, .expected = .length_limited }, };
for (cases) |case| { const completions = [_]FakeCompletion{.{ .tool_calls = &calls, .finish_reason = case.finish_reason, .omit_finish = case.omit_finish, }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); if (case.finish_reason == .provider_error) hooks.enable_route_recovery = false; defer hooks.deinit(); var fixture = PromptFixture{};
var config = fixture.config(); config.max_provider_attempts = 1; // Recoverable cases retry autonomously now; park the turn with a // lifecycle pause (the user's try-later) when the retry is scheduled. var pause_flag = std.atomic.Value(bool).init(false); hooks.pause_on_auto_retry_status = true; hooks.recovery_pause_flag = &pause_flag; config.recovery_pause_flag = &pause_flag; switch (case.expected) { .paused => try runFakePrompt(&gateway, &hooks, config, fixture.job()), .model_error => try std.testing.expectError( error.ModelError, runFakePrompt(&gateway, &hooks, config, fixture.job()), ), .length_limited => try runFakePrompt( &gateway, &hooks, config, fixture.job(), ), }
try std.testing.expectEqual(@as(usize, 1), gateway.request_bodies.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.validated_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.availability_checked_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.permission_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.executed_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.rejected_names.items.len);
if (case.expected == .length_limited) { try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); try std.testing.expectEqual( @as(usize, 0), hooks.history_turns.items[0].assistant.execution.tool_steps.len, ); const propagated_summary = types.historyTurnSummary( hooks.history_turns.items[0], ) orelse return error.TestExpectedTurnSummary; try std.testing.expectEqual(hooks.finish_summary.?, propagated_summary); } else { try std.testing.expectEqual(@as(usize, 0), hooks.history_turns.items.len); } if (case.expected == .paused) { try std.testing.expectEqual(types.TurnPresentationOutcome.paused, hooks.finalized_outcome.?); } }}
test "processQueuedPrompt returns a final response after repeated tool failures" { const alloc = std.testing.allocator; const call_one = [_]ToolCall{toolCall("call_1", "read_file", "{\"path\":\"a\"}")}; const call_two = [_]ToolCall{toolCall("call_2", "read_file", "{\"path\":\"b\"}")}; const call_three = [_]ToolCall{toolCall("call_3", "read_file", "{\"path\":\"c\"}")}; const completions = [_]FakeCompletion{ .{ .tool_calls = &call_one }, .{ .tool_calls = &call_two }, .{ .tool_calls = &call_three }, .{ .content = "Recovered after errors." }, }; const error_result = FakeExecPlan{ .result = .{ .model_output = "Tool read_file failed: bad" } }; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); hooks.permission_decisions = &.{ .once, .once, .once }; hooks.exec_plans = &.{ error_result, error_result, error_result }; defer hooks.deinit(); var fixture = PromptFixture{};
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
try std.testing.expect(!textContains(&hooks, "Agent stopped: 3 consecutive steps with all-error tool calls.")); try std.testing.expectEqualStrings("Recovered after errors.", hooks.history_assistant_text.?); try std.testing.expectEqual(@as(usize, 3), hooks.permission_names.items.len); try std.testing.expectEqual(@as(usize, 3), hooks.executed_names.items.len);}
test "processQueuedPrompt stops repeated malformed calls before another provider request" { const alloc = std.testing.allocator; const call_one = [_]ToolCall{.{ .id = "call_1", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const call_two = [_]ToolCall{.{ .id = "call_2", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const call_three = [_]ToolCall{.{ .id = "call_3", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const completions = [_]FakeCompletion{ .{ .tool_calls = &call_one }, .{ .tool_calls = &call_two }, .{ .tool_calls = &call_three }, .{ .content = "must not be requested" }, }; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.agent_step_limit = 0;
try runFakePrompt(&gateway, &hooks, config, fixture.job());
const notice = "Repeated malformed tool arguments stopped the agent loop. The invalid calls were not executed. Continue with a follow-up prompt if needed."; try std.testing.expectEqual(@as(usize, 3), gateway.request_bodies.items.len); try std.testing.expect(textContains(&hooks, notice)); try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); try std.testing.expectEqual(@as(usize, 1), hooks.finalization_count); try std.testing.expectEqual(@as(usize, 1), hooks.finish_event_count); try std.testing.expectEqual(types.TurnPresentationOutcome.failed, hooks.finalized_outcome.?); try std.testing.expectEqualStrings(notice, hooks.history_assistant_text.?); try std.testing.expectEqualStrings(notice, hooks.finish_assistant_text.?); try std.testing.expect( logIndex(&hooks, "event:turn_finished").? < logIndex(&hooks, "event:finish_prompt").?, );
const execution = hooks.history_turns.items[0].assistant.execution; try std.testing.expectEqual(@as(usize, 3), execution.tool_steps.len); for (execution.tool_steps) |step| { try std.testing.expectEqual(@as(usize, 1), step.tool_calls.len); try std.testing.expectEqualStrings("{}", step.tool_calls[0].arguments_json); try std.testing.expectEqual(types.ToolArgumentIntegrity.valid, step.tool_calls[0].argument_integrity); try std.testing.expectEqual(@as(usize, 1), step.tool_results.len); try std.testing.expectEqual(types.PersistedToolStatus.failure, step.tool_results[0].status); try std.testing.expect(tool_result_errors.isToolExecutionFailedOutput(step.tool_results[0].output)); try std.testing.expectEqualStrings(step.tool_calls[0].id, step.tool_results[0].tool_call_id); } try std.testing.expectEqual(@as(usize, 0), hooks.permission_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.executed_names.items.len);}
test "processQueuedPrompt continues with steering queued before a repeated-malformed terminal" { const alloc = std.testing.allocator; const call_one = [_]ToolCall{.{ .id = "call_1", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const call_two = [_]ToolCall{.{ .id = "call_2", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const call_three = [_]ToolCall{.{ .id = "call_3", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const completions = [_]FakeCompletion{ .{ .tool_calls = &call_one }, .{ .tool_calls = &call_two }, .{ .tool_calls = &call_three }, .{ .content = "Steered recovery answer" }, }; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); const steering = [_][]const u8{"stop repeating the broken call"}; var hooks = FakeAgentRuntimeDeps.init(alloc); // Step-top boundaries are takes 1-3; the terminal-exit boundary is take 4. hooks.steering_messages = &steering; hooks.steering_take_at = 4; defer hooks.deinit(); var fixture = PromptFixture{};
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
// The turn continued instead of ending: the fourth request carries the // queued steering, and the malformed-stop notice never rendered. try std.testing.expectEqual(@as(usize, 4), gateway.request_bodies.items.len); try expectBodyContains(&gateway, 3, "user_steering"); try expectBodyContains(&gateway, 3, "stop repeating the broken call"); try std.testing.expect(!textContains(&hooks, "Repeated malformed tool arguments")); try std.testing.expectEqualStrings("Steered recovery answer", hooks.finish_assistant_text.?); try std.testing.expectEqual(types.TurnPresentationOutcome.completed, hooks.finalized_outcome.?); const execution = hooks.history_turns.items[0].assistant.execution; try std.testing.expectEqual(@as(usize, 1), execution.steering.len); try std.testing.expectEqualStrings("stop repeating the broken call", execution.steering[0].text);}
test "processQueuedPrompt continues with steering queued before a length-limited tool completion" { const alloc = std.testing.allocator; const truncated_calls = [_]ToolCall{toolCall("len_call_1", "read_file", "{\"path\":\"a.txt\"}")}; const completions = [_]FakeCompletion{ .{ .content = "partial draft", .tool_calls = &truncated_calls, .finish_reason = .length }, .{ .content = "Steered length recovery" }, }; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); const steering = [_][]const u8{"narrow the scope"}; var hooks = FakeAgentRuntimeDeps.init(alloc); // Step-top boundary is take 1; the length-limited terminal boundary is take 2. hooks.steering_messages = &steering; hooks.steering_take_at = 2; defer hooks.deinit(); var fixture = PromptFixture{};
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
try std.testing.expectEqual(@as(usize, 2), gateway.request_bodies.items.len); try expectBodyContainsInOrder(&gateway, 1, &.{ "partial draft", "user_steering", "narrow the scope", }); try std.testing.expectEqualStrings("Steered length recovery", hooks.finish_assistant_text.?); try std.testing.expectEqual(types.TurnPresentationOutcome.completed, hooks.finalized_outcome.?);}
test "processQueuedPrompt returns a final response after a repeated tool-name cycle" { const alloc = std.testing.allocator; const c1 = [_]ToolCall{toolCall("call_1", "read_file", "{\"path\":\"a\"}")}; const c2 = [_]ToolCall{toolCall("call_2", "edit_file", "{\"path\":\"a\",\"old_string\":\"old\",\"new_string\":\"new\"}")}; const c3 = [_]ToolCall{toolCall("call_3", "read_file", "{\"path\":\"b\"}")}; const c4 = [_]ToolCall{toolCall("call_4", "edit_file", "{\"path\":\"b\",\"old_string\":\"old\",\"new_string\":\"new\"}")}; const c5 = [_]ToolCall{toolCall("call_5", "read_file", "{\"path\":\"c\"}")}; const c6 = [_]ToolCall{toolCall("call_6", "edit_file", "{\"path\":\"c\",\"old_string\":\"old\",\"new_string\":\"new\"}")}; const completions = [_]FakeCompletion{ .{ .tool_calls = &c1 }, .{ .tool_calls = &c2 }, .{ .tool_calls = &c3 }, .{ .tool_calls = &c4 }, .{ .tool_calls = &c5 }, .{ .tool_calls = &c6 }, .{ .content = "Recovered after cycle." }, }; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); hooks.permission_decisions = &.{ .once, .once, .once, .once, .once, .once }; defer hooks.deinit(); var fixture = PromptFixture{};
try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job());
try std.testing.expect(!textContains(&hooks, "Agent stopped: detected repeating tool call cycle.")); try std.testing.expectEqualStrings("Recovered after cycle.", hooks.history_assistant_text.?); try std.testing.expectEqual(@as(usize, 6), hooks.permission_names.items.len);}
test "processQueuedPrompt step limit pushes configured notice and finish event" { const alloc = std.testing.allocator; const calls = [_]ToolCall{toolCall("call_1", "read_file", "{\"path\":\"a\"}")}; const completions = [_]FakeCompletion{.{ .tool_calls = &calls }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.agent_step_limit = 1; config.step_limit_notice = "custom limit";
try runFakePrompt(&gateway, &hooks, config, fixture.job());
try std.testing.expect(textContains(&hooks, "custom limit")); try std.testing.expectEqualStrings("custom limit", hooks.history_assistant_text.?); try std.testing.expectEqualStrings("custom limit", hooks.finish_assistant_text.?); const propagated_summary = types.historyTurnSummary( hooks.history_turns.items[0], ) orelse return error.TestExpectedTurnSummary; try std.testing.expectEqual(hooks.finish_summary.?, propagated_summary); try std.testing.expect(hooks.finalized_disposition == null);}
test "processQueuedPrompt step limit persists malformed argument failure execution" { const alloc = std.testing.allocator; const calls = [_]ToolCall{.{ .id = "call_bad", .name = "read_file", .arguments_json = "{}", .argument_integrity = .malformed_json, }}; const completions = [_]FakeCompletion{.{ .tool_calls = &calls }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.agent_step_limit = 1; config.step_limit_notice = "custom limit";
try runFakePrompt(&gateway, &hooks, config, fixture.job());
try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); const execution = hooks.history_turns.items[0].assistant.execution; try std.testing.expectEqual(@as(usize, 1), execution.tool_steps.len); try std.testing.expectEqualStrings("{}", execution.tool_steps[0].tool_calls[0].arguments_json); try std.testing.expectEqual(types.ToolArgumentIntegrity.valid, execution.tool_steps[0].tool_calls[0].argument_integrity); try std.testing.expectEqual(@as(usize, 1), execution.tool_steps[0].tool_results.len); try std.testing.expectEqual(types.PersistedToolStatus.failure, execution.tool_steps[0].tool_results[0].status); try std.testing.expect(tool_result_errors.isToolExecutionFailedOutput(execution.tool_steps[0].tool_results[0].output)); try std.testing.expectEqual(@as(usize, 0), hooks.permission_names.items.len); try std.testing.expectEqual(@as(usize, 0), hooks.executed_names.items.len);}
test "finalization sink failure makes guard fatal and suppresses finish payload" { const alloc = std.testing.allocator; const completions = [_]FakeCompletion{.{ .content = "Final" }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); hooks.finalization_error = error.TurnFinalizationDeliveryFailed; defer hooks.deinit(); var fixture = PromptFixture{};
try std.testing.expectError( error.TurnFinalizationDeliveryFailed, runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()), );
try std.testing.expectEqual(@as(usize, 1), hooks.finalization_count); try std.testing.expectEqual(@as(usize, 0), hooks.finish_event_count);}
test "processQueuedPrompt terminal outcomes finalize exactly once before optional finish" { const alloc = std.testing.allocator; const cases = [_]struct { completion: FakeCompletion, outcome: types.TurnPresentationOutcome, finish_count: usize, propagates_error: bool = false, }{ .{ .completion = .{ .content = "Final" }, .outcome = .completed, .finish_count = 1 }, .{ .completion = .{ .cancel_before_output = true }, .outcome = .interrupted, .finish_count = 1 }, .{ .completion = .{ .status = .bad_request, .err_body = "bad" }, .outcome = .failed, .finish_count = 0 }, .{ .completion = .{ .stream_error = error.TestProviderFailure }, .outcome = .failed, .finish_count = 0, .propagates_error = true }, }; for (cases) |case| { const completions = [_]FakeCompletion{case.completion}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; if (case.propagates_error) { try std.testing.expectError( error.TestProviderFailure, runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()), ); } else { try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()); } try std.testing.expectEqual(@as(usize, 1), hooks.finalization_count); try std.testing.expectEqual(case.outcome, hooks.finalized_outcome.?); try std.testing.expectEqual(case.finish_count, hooks.finish_event_count); if (case.finish_count > 0) { try std.testing.expect( logIndex(&hooks, "event:turn_finished").? < logIndex(&hooks, "event:finish_prompt").?, ); } }}
test "partial stream failure persists failed terminal reason" { const alloc = std.testing.allocator; const completions = [_]FakeCompletion{.{ .chunks = &.{"partial response"}, .stream_error_after_chunks = error.TestProviderFailure, }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.gateway_retry_count = 0; config.max_provider_attempts = 1;
try std.testing.expectError( error.TestProviderFailure, runFakePrompt(&gateway, &hooks, config, fixture.job()), );
try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); try std.testing.expect(hooks.history_turns.items[0] == .interrupted); try std.testing.expectEqual( types.InterruptedTerminalReason.failed, hooks.history_turns.items[0].interrupted.terminal_reason, ); try std.testing.expectEqual(types.TurnPresentationOutcome.failed, hooks.finalized_outcome.?);}
test "processQueuedPrompt emits exactly one structured prompt finish per terminal outcome" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); defer alloc.free(root); const trace_path = try std.fs.path.join(alloc, &.{ root, "finish-trace.log" }); defer alloc.free(trace_path);
debug_trace.resetForTest(); defer debug_trace.resetForTest(); try debug_trace.configureForTestWithScopes(alloc, trace_path, "agent");
{ const completions = [_]FakeCompletion{.{ .content = "Final" }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()); }
{ const completions = [_]FakeCompletion{.{ .cancel_before_output = true }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()); }
{ const completions = [_]FakeCompletion{.{ .status = .bad_request, .err_body = "bad" }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()); }
{ const completions = [_]FakeCompletion{.{ .stream_error = error.TestProviderFailure }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; try std.testing.expectError(error.TestProviderFailure, runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job())); }
debug_trace.shutdown(); const trace = try readTraceFile(alloc, trace_path, 65536); defer alloc.free(trace);
try std.testing.expectEqual(@as(usize, 4), countNeedle(trace, " event=prompt_finish ")); try std.testing.expectEqual(@as(usize, 1), countNeedle(trace, "outcome_kind=assistant")); try std.testing.expectEqual(@as(usize, 1), countNeedle(trace, "outcome_kind=interrupted")); try std.testing.expectEqual(@as(usize, 2), countNeedle(trace, "outcome_kind=http_error")); try std.testing.expectEqual(@as(usize, 0), countNeedle(trace, "event=prompt_finish_duplicate"));}
test "processQueuedPrompt step limit writes active debug trace" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const root = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); defer alloc.free(root); const trace_path = try std.fs.path.join(alloc, &.{ root, "trace.log" }); defer alloc.free(trace_path);
debug_trace.resetForTest(); defer debug_trace.resetForTest(); try debug_trace.configureForTest(alloc, trace_path);
const calls = [_]ToolCall{toolCall("call_1", "read_file", "{\"path\":\"a\"}")}; const completions = [_]FakeCompletion{.{ .tool_calls = &calls }}; var gateway = FakeGateway.init(alloc, &completions); defer gateway.deinit(); var hooks = FakeAgentRuntimeDeps.init(alloc); defer hooks.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.agent_step_limit = 1;
try runFakePrompt(&gateway, &hooks, config, fixture.job()); debug_trace.shutdown();
var trace_file = try std.Io.Dir.openFileAbsolute(io_mod.getIo(), trace_path, .{}); defer trace_file.close(io_mod.getIo()); const trace = try io_mod.readFileToEnd(alloc, &trace_file, 8192); defer alloc.free(trace);
try std.testing.expect(std.mem.find(u8, trace, "[agent] step start step=1 limit=1") != null); try std.testing.expect(std.mem.find(u8, trace, "[agent] step completion step=1") != null); try std.testing.expect(std.mem.find(u8, trace, "[agent] step limit reached turn_id=1 step_id=1 step_index=1 step_limit=1 gateway_messages=3 completed_tool_count=1 completed_tool_names=read_file last_tool_call_name=read_file last_tool_call_id=call_1 outcome_kind=step_limit") != null); try std.testing.expect(std.mem.find(u8, trace, "event=step_limit_reached turn_id=1 step_id=1 step_index=1 step_limit=1 gateway_messages=3 completed_tool_count=1 completed_tool_names=read_file last_tool_call_name=read_file last_tool_call_id=call_1 outcome_kind=step_limit") != null); try std.testing.expect(std.mem.find(u8, trace, "{\"path\":\"a\"}") == null);}
test "common Stop natural completion allows and preserves disposition" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate", .finish_reason = .length, }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.enable_route_recovery = false; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .allow, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqualStrings("candidate", handler.seen_text.?); try std.testing.expectEqual(@as(usize, 1), handler.seen_step_index); try std.testing.expect(handler.seen_can_continue); try std.testing.expectEqual( types.ProviderCompletionDisposition.length_limited, handler.seen_disposition.?, ); try std.testing.expectEqualStrings( "candidate", deps.history_turns.items[0].assistant.execution.tool_steps[0].assistant.?, ); try std.testing.expectEqual( types.ProviderCompletionDisposition.length_limited, deps.finalized_disposition.?, );}
test "common Stop cancellation and failure preserve original replay" { const alloc = std.testing.allocator; var failures: usize = 0; for (0..6) |iteration| { const cancel = iteration % 2 == 0; const first_replay = iteration % 4 < 2; const partial = if (iteration < 4) "PARTIAL_963" else ""; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "CANDIDATE_741", .provider_state_json = if (first_replay) "[{\"type\":\"reasoning\",\"text\":\"FIRST_PRIVATE_STATE\"}]" else null }, .{ .chunks = &.{partial}, .cancel_after_chunks = cancel, .stream_error_after_chunks = if (cancel) null else error.TestProviderFailure }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.enable_route_recovery = false; defer deps.deinit(); var fixture = PromptFixture{}; var runtime = lifecycle_hooks.Runtime.init(alloc); defer runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" } }; defer handler.deinit(); const view = try registerStopTestHandler(&runtime, &handler); var config = fixture.config(); config.max_provider_attempts = 1; runFakePromptWithLifecycle(&gateway, &deps, config, fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root)) catch |err| { if (err != error.TestProviderFailure) return err; }; var candidates: usize = 0; var replay_count: usize = 0; for (deps.history_turns.items) |history| { const execution = switch (history) { .assistant => |entry| entry.execution, .interrupted => |entry| entry.execution, else => types.ExecutionMemory{}, }; for (execution.tool_steps) |step| if (step.provider_replay) |replay| { if (std.mem.find(u8, replay.parts_json, "FIRST_PRIVATE_STATE") != null) replay_count += 1; }; switch (history) { .assistant => |entry| { candidates += std.mem.count(u8, entry.assistant, "CANDIDATE_741"); for (entry.execution.tool_steps) |step| candidates += std.mem.count(u8, step.assistant orelse "", "CANDIDATE_741"); }, .interrupted => |entry| { candidates += std.mem.count(u8, entry.assistant orelse "", "CANDIDATE_741"); for (entry.execution.tool_steps) |step| candidates += std.mem.count(u8, step.assistant orelse "", "CANDIDATE_741"); }, else => {}, } } const pass = candidates == 1 and replay_count == @intFromBool(first_replay) and deps.finalized_outcome != .completed; if (!pass) failures += 1; } try std.testing.expectEqual(@as(usize, 0), failures);}
test "common Stop preserves both original replies with optional replay" { const alloc = std.testing.allocator; var successes: usize = 0; for (0..5) |iteration| { const first_replay = (iteration % 4) < 2; const last_replay = (iteration % 2) == 0; const final_text = if (iteration == 4) "CANDIDATE_741" else "FINAL_852"; const first_state = "[{\"type\":\"reasoning\",\"text\":\"FIRST_REPLAY_PRIVATE\"}]"; const last_state = "[{\"type\":\"reasoning\",\"text\":\"FINAL_REPLAY_PRIVATE\"}]"; var gateway = FakeGateway.init(alloc, &.{ .{ .chunks = &.{"CANDIDATE_741"}, .content = "CANDIDATE_741", .provider_state_json = if (first_replay) first_state else null }, .{ .content = final_text, .provider_state_json = if (last_replay) last_state else null }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.enable_route_recovery = false; defer deps.deinit(); var fixture = PromptFixture{}; var hooks_runtime = lifecycle_hooks.Runtime.init(alloc); defer hooks_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify the answer" } }; defer handler.deinit(); const view = try registerStopTestHandler(&hooks_runtime, &handler); try runFakePromptWithLifecycle(&gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root)); const turn = deps.history_turns.items[0].assistant; var pass = gateway.request_bodies.items.len == 2 and handler.calls == 1 and std.mem.eql(u8, turn.assistant, final_text) and (turn.provider_replay != null) == last_replay and turn.execution.tool_steps.len == 1; if (turn.provider_replay) |replay| pass = pass and std.mem.eql(u8, replay.parts_json, last_state); if (turn.execution.tool_steps.len == 1) { const first = turn.execution.tool_steps[0]; pass = pass and std.mem.eql(u8, first.assistant orelse "", "CANDIDATE_741") and (first.provider_replay != null) == first_replay and first.tool_calls.len == 0 and first.tool_results.len == 0; if (first.provider_replay) |replay| pass = pass and std.mem.eql(u8, replay.parts_json, first_state); } if (pass) successes += 1; } try std.testing.expectEqual(@as(usize, 5), successes);}
test "common Stop continues once with original history and joined presentation" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .chunks = &.{"candidate"}, .content = "candidate" }, .{ .content = "final" }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.enable_route_recovery = false; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify the answer" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqual(@as(usize, 2), gateway.request_bodies.items.len); try expectBodyContains(&gateway, 1, "candidate"); try expectBodyContains( &gateway, 1, "Continue the turn. fx hook context:\\nverify the answer", ); try std.testing.expectEqualStrings( "candidate\nfinal", deps.finish_presentation_text.?, ); const original = deps.history_turns.items[0].assistant; try std.testing.expectEqualStrings("final", original.assistant); try std.testing.expectEqual(@as(usize, 1), original.execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", original.execution.tool_steps[0].assistant.?);
var follow_gateway = FakeGateway.init(alloc, &.{ .{ .content = "follow-up" }, }); defer follow_gateway.deinit(); var follow_deps = FakeAgentRuntimeDeps.init(alloc); defer follow_deps.deinit(); var follow_fixture = PromptFixture{}; var follow_job = follow_fixture.job(); follow_job.history = deps.history_turns.items; try runFakePrompt( &follow_gateway, &follow_deps, follow_fixture.config(), follow_job, ); try expectBodyContains(&follow_gateway, 0, "candidate"); try expectBodyContains(&follow_gateway, 0, "final"); try expectBodyNotContains( &follow_gateway, 0, "Continue the turn. fx hook context", );}
test "common Stop continuation normalizes to allow without remaining budget" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.agent_step_limit = 1; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, config, fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expect(!handler.seen_can_continue); try std.testing.expectEqual(@as(usize, 1), gateway.request_bodies.items.len); try std.testing.expectEqualStrings( "candidate", deps.history_turns.items[0].assistant.execution.tool_steps[0].assistant.?, );}
test "common Stop cancellation during dispatch persists interrupted candidate" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate", .provider_state_json = "[{\"type\":\"reasoning\",\"text\":\"original replay\"}]" }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .cancel_continue = "ignored" }, .cancel_flag = &fixture.cancel_flag, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expect(deps.history_turns.items[0].interrupted.execution.tool_steps[0].provider_replay != null); try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqual(@as(usize, 1), deps.history_turns.items.len); try std.testing.expect(deps.history_turns.items[0] == .interrupted); try std.testing.expectEqualStrings( "candidate", deps.history_turns.items[0].interrupted.execution.tool_steps[0].assistant.?, ); try std.testing.expectEqual(@as(usize, 1), deps.history_turns.items[0].interrupted.execution.tool_steps.len); try std.testing.expectEqual(@as(usize, 0), deps.history_turns.items[0].interrupted.execution.tool_steps[0].tool_calls.len); try std.testing.expectEqualStrings("", deps.history_turns.items[0].interrupted.assistant orelse ""); try std.testing.expectEqual( types.FinishedPromptProjection.assistant_text, deps.finish_projection.?, ); try std.testing.expectEqual( types.TurnPresentationOutcome.interrupted, deps.finalized_outcome.?, );}
test "common Stop pre-dispatch cancellation retains candidate without calling handler" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; deps.cancel_on_text = &fixture.cancel_flag; deps.cancel_on_text_match = "\n"; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .allow, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 0), handler.calls); try std.testing.expectEqual(@as(usize, 1), deps.history_turns.items.len); try std.testing.expectEqualStrings( "candidate", deps.history_turns.items[0].interrupted.execution.tool_steps[0].assistant.?, ); try std.testing.expectEqual( types.FinishedPromptProjection.assistant_text, deps.finish_projection.?, ); try std.testing.expectEqual( types.TurnPresentationOutcome.interrupted, deps.finalized_outcome.?, );}
test "common Stop later provider failure pauses with candidate still visible" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .chunks = &.{"candidate"}, .content = "candidate" }, .{ .finish_reason = .provider_error }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.enable_route_recovery = false; deps.enable_recovery_checkpoint = true; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
var config = fixture.config(); config.max_provider_attempts = 1; // The provider failure retries autonomously now; park the turn with a // lifecycle pause (the user's try-later) when the retry is scheduled. var pause_flag = std.atomic.Value(bool).init(false); deps.pause_on_auto_retry_status = true; deps.recovery_pause_flag = &pause_flag; config.recovery_pause_flag = &pause_flag; try runFakePromptWithLifecycle( &gateway, &deps, config, fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expect(textContains(&deps, "candidate")); try std.testing.expectEqual(@as(usize, 0), deps.history_turns.items.len); try std.testing.expectEqual( types.TurnPresentationOutcome.paused, deps.finalized_outcome.?, ); try std.testing.expectEqual(@as(usize, 0), deps.finish_event_count); const checkpoint = deps.recovery_checkpoints.items[deps.recovery_checkpoints.items.len - 1]; try std.testing.expectEqualStrings("", checkpoint.assistant_source); try std.testing.expectEqual(@as(usize, 1), checkpoint.execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", checkpoint.execution.tool_steps[0].assistant.?);}
test "common Stop later stream failure persists copied partial text" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .chunks = &.{"partial"}, .stream_error_after_chunks = error.TestProviderFailure, }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try std.testing.expectError( error.TestProviderFailure, runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), ), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqualStrings( "candidate\npartial", deps.finish_presentation_text.?, ); try std.testing.expectEqualStrings("partial", deps.history_turns.items[0].assistant.assistant); try std.testing.expectEqualStrings("candidate", deps.history_turns.items[0].assistant.execution.tool_steps[0].assistant.?); try std.testing.expectEqual( types.TurnPresentationOutcome.failed, deps.finalized_outcome.?, );}
test "common Stop retained partial does not borrow the stream buffer" { const alloc = std.testing.allocator; var source = [_]u8{ 'p', 'a', 'r', 't', 'i', 'a', 'l' }; var stop_state = CommonStopState{};
try copyLatestStopPartial(alloc, &stop_state, &source); defer alloc.free(@constCast(stop_state.latest_partial.?)); source[0] = 'X';
try std.testing.expectEqualStrings("partial", stop_state.latest_partial.?);}
test "common Stop step limit retains candidate and current-turn execution" { const alloc = std.testing.allocator; const calls = [_]ToolCall{ toolCall("call_read", "read_file", "{\"path\":\"README.md\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .tool_calls = &calls }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var config = fixture.config(); config.agent_step_limit = 2; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, config, fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
const turn = deps.history_turns.items[0].assistant; try std.testing.expectEqualStrings( "candidate\nAgent step limit reached; continue with a follow-up prompt if needed.", deps.finish_presentation_text.?, ); try std.testing.expectEqualStrings("Agent step limit reached; continue with a follow-up prompt if needed.", turn.assistant); try std.testing.expectEqual(@as(usize, 2), turn.execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", turn.execution.tool_steps[0].assistant.?); try std.testing.expectEqualStrings( "read_file", turn.execution.tool_steps[1].tool_calls[0].name, ); try std.testing.expectEqual( types.PersistedToolStatus.success, turn.execution.tool_steps[1].tool_results[0].status, );}
test "common Stop length-limited tool failure retains candidate" { const alloc = std.testing.allocator; const calls = [_]ToolCall{ toolCall("call_read", "read_file", "{\"path\":\"README.md\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .content = "partial", .tool_calls = &calls, .finish_reason = .length, }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
const assistant = deps.finish_presentation_text.?; try std.testing.expect(std.mem.startsWith(u8, assistant, "candidate\npartial")); try std.testing.expect(std.mem.find(u8, assistant, "did not execute") != null); try std.testing.expect(std.mem.startsWith(u8, deps.history_turns.items[0].assistant.assistant, "partial\n")); try std.testing.expectEqualStrings("candidate", deps.history_turns.items[0].assistant.execution.tool_steps[0].assistant.?); try std.testing.expectEqual( types.TurnPresentationOutcome.failed, deps.finalized_outcome.?, );}
test "common Stop handler errors fail open" { const actions = [_]StopTestHandler.Action{ .fail, .cancel_error, };
for (actions) |action| { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = action, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqualStrings( "candidate", deps.history_turns.items[0].assistant.execution.tool_steps[0].assistant.?, ); try std.testing.expectEqual( types.TurnPresentationOutcome.completed, deps.finalized_outcome.?, ); }}
test "common Stop does not run for silent-tool auto-continuation" { const alloc = std.testing.allocator; const first_calls = [_]ToolCall{ toolCall("call_a", "read_file", "{\"path\":\"a.txt\"}"), }; const second_calls = [_]ToolCall{ toolCall("call_b", "read_file", "{\"path\":\"b.txt\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .tool_calls = &first_calls }, .{ .tool_calls = &second_calls }, .{}, .{ .content = "summary" }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .allow, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqualStrings("summary", handler.seen_text.?); try std.testing.expectEqual(@as(usize, 4), gateway.request_bodies.items.len); try expectBodyContains(&gateway, 3, "Summarize what you just did.");}
test "common Stop HTTP failure pauses with candidate and partial still visible" { const alloc = std.testing.allocator; const chunks = [_][]const u8{"later partial"}; var gateway = FakeGateway.init(alloc, &.{ .{ .chunks = &.{"candidate"}, .content = "candidate" }, .{ .status = .bad_gateway, .err_body = "gateway unavailable", .chunks = &chunks, }, .{ .status = .bad_gateway, .err_body = "gateway unavailable", }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
var config = fixture.config(); config.max_provider_attempts = 2; // HTTP status failures retry patiently forever now; park the turn with a // lifecycle pause (the user's try-later) when the retry is scheduled. var pause_flag = std.atomic.Value(bool).init(false); deps.pause_on_auto_retry_status = true; deps.recovery_pause_flag = &pause_flag; config.recovery_pause_flag = &pause_flag; try runFakePromptWithLifecycle( &gateway, &deps, config, fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expect(textContains(&deps, "candidate")); try std.testing.expect(textContains(&deps, "later partial")); try std.testing.expectEqual(@as(usize, 0), deps.history_turns.items.len); try std.testing.expectEqual(@as(?std.http.Status, null), deps.http_status); try std.testing.expectEqual( types.TurnPresentationOutcome.paused, deps.finalized_outcome.?, );}
test "common Stop finish_turn retains candidate and execution memory" { const alloc = std.testing.allocator; const calls = [_]ToolCall{ toolCall("call_finish", "read_file", "{\"path\":\"README.md\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .content = "working", .tool_calls = &calls, }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.exec_plans = &.{.{ .result = .{ .model_output = "finished", .finish_turn = true, } }}; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqual(@as(usize, 1), deps.history_turns.items.len); const turn = deps.history_turns.items[0].assistant; try std.testing.expectEqualStrings("candidate\nworking", deps.finish_presentation_text.?); try std.testing.expectEqualStrings("", turn.assistant); try std.testing.expectEqual(@as(usize, 2), turn.execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", turn.execution.tool_steps[0].assistant.?); try std.testing.expectEqualStrings("working", turn.execution.tool_steps[1].assistant.?); try std.testing.expectEqualStrings( "call_finish", turn.execution.tool_steps[1].tool_calls[0].id, ); try std.testing.expectEqual( types.PersistedToolStatus.success, turn.execution.tool_steps[1].tool_results[0].status, );}
test "common Stop cancellation excludes active call and keeps typed failed peer" { const alloc = std.testing.allocator; const peer_calls = [_]ToolCall{ toolCall("call_peer", "web_search", "{\"query\":\"current news\"}"), }; const active_calls = [_]ToolCall{ toolCall("call_active", "read_file", "{\"path\":\"README.md\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .tool_calls = &peer_calls }, .{ .tool_calls = &active_calls }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.exec_plans = &.{.{ .result = .{ .model_output = "plain peer result", .status = .failure, } }}; deps.permission_decisions = &.{ .once, .deny }; defer deps.deinit(); var fixture = PromptFixture{}; deps.cancel_on_permission = &fixture.cancel_flag; deps.cancel_on_permission_name = "read_file"; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 1), handler.calls); try std.testing.expectEqual(@as(usize, 1), deps.history_turns.items.len); const turn = deps.history_turns.items[0].interrupted; try std.testing.expectEqualStrings("candidate", deps.finish_presentation_text.?); try std.testing.expectEqualStrings("", turn.assistant orelse ""); try std.testing.expectEqualStrings("call_active", turn.tool_call.?.id); try std.testing.expectEqual(@as(usize, 2), turn.execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", turn.execution.tool_steps[0].assistant.?); try std.testing.expectEqual(@as(usize, 0), turn.execution.tool_steps[0].tool_calls.len); try std.testing.expectEqualStrings( "call_peer", turn.execution.tool_steps[1].tool_calls[0].id, ); try std.testing.expectEqual( types.PersistedToolStatus.failure, turn.execution.tool_steps[1].tool_results[0].status, ); try std.testing.expectEqualStrings( "plain peer result", turn.execution.tool_steps[1].tool_results[0].output, ); try std.testing.expectEqual( types.FinishedPromptProjection.assistant_text, deps.finish_projection.?, );}
test "common Stop interruption keeps only completed calls from a partially attempted step" { const alloc = std.testing.allocator; var tmp = std.testing.tmpDir(.{ .iterate = true }); defer tmp.cleanup(); const result_dir = try io_mod.dirRealpathAlloc(alloc, tmp.dir, "."); defer alloc.free(result_dir); const trace_path = try std.fs.path.join(alloc, &.{ result_dir, "trace.log" }); defer alloc.free(trace_path); debug_trace.resetForTest(); defer debug_trace.resetForTest(); try debug_trace.configureForTest(alloc, trace_path); const calls = [_]ToolCall{ toolCall("call_completed", "read_file", "{\"path\":\"README.md\"}"), toolCall("call_cancelled", "browser_snapshot", "{}"), toolCall("call_unattempted", "grep_files", "{\"pattern\":\"Hooks\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .tool_calls = &calls, .provider_state_json = \\[{"type":"reasoning","text":"PARTIAL_BATCH_REPLAY"},{"type":"tool-call","toolCallId":"call_completed"},{"type":"tool-call","toolCallId":"call_cancelled"},{"type":"tool-call","toolCallId":"call_unattempted"}] , }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.exec_plans = &.{ .{ .result = .{ .model_output = "completed result" } }, .{ .err = error.Cancelled }, }; defer deps.deinit(); var fixture = PromptFixture{}; deps.cancel_on_execute = &fixture.cancel_flag; deps.cancel_on_execute_name = "browser_snapshot"; var config = fixture.config(); config.tool_result_dir = result_dir; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try runFakePromptWithLifecycle( &gateway, &deps, config, fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 2), deps.executed_names.items.len); const turn = deps.history_turns.items[0].interrupted; try std.testing.expectEqualStrings("call_cancelled", turn.tool_call.?.id); try std.testing.expectEqual(@as(usize, 2), turn.execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", turn.execution.tool_steps[0].assistant.?); try std.testing.expectEqual(@as(usize, 0), turn.execution.tool_steps[0].tool_calls.len); try std.testing.expectEqual(@as(usize, 1), turn.execution.tool_steps[1].tool_calls.len); try std.testing.expectEqualStrings( "call_completed", turn.execution.tool_steps[1].tool_calls[0].id, ); try std.testing.expectEqual(@as(usize, 1), turn.execution.tool_steps[1].tool_results.len); try std.testing.expectEqualStrings( "completed result", turn.execution.tool_steps[1].tool_results[0].output, );
const session_log = @import("../../../session/session_log.zig"); var dir = try io_mod.openOrCreateVerifiedPrivateDirFromDir(tmp.dir, "session"); defer dir.close(); { const file = try dir.dir.createFile(std.testing.io, "events.jsonl", .{ .read = true, .permissions = .fromMode(0o600), }); var writer = try session_log.ConversationWriter.init(alloc, file); defer writer.deinit(); try writer.appendHistoryTurn(alloc, 1, deps.history_turns.items[0]); } const history = try session_log.loadConversationHistoryRange(alloc, &dir, 0, 1); defer types.freeHistoryTurnSlice(alloc, history); try std.testing.expectEqual(@as(usize, 1), history.len); var follow_gateway = FakeGateway.init(alloc, &.{.{ .content = "resumed" }}); defer follow_gateway.deinit(); var follow_deps = FakeAgentRuntimeDeps.init(alloc); defer follow_deps.deinit(); var follow_fixture = PromptFixture{}; var follow_job = follow_fixture.job(); follow_job.history = history; try runFakePrompt(&follow_gateway, &follow_deps, follow_fixture.config(), follow_job); try expectBodyContains(&follow_gateway, 0, "completed result"); try expectBodyContains(&follow_gateway, 0, "call_cancelled"); try expectBodyNotContains(&follow_gateway, 0, "call_unattempted"); try expectBodyNotContains(&follow_gateway, 0, "PARTIAL_BATCH_REPLAY"); try std.testing.expect(history[0].interrupted.execution.tool_steps[1].provider_replay == null); debug_trace.shutdown(); const trace = try readTraceFile(alloc, trace_path, 256 * 1024); defer alloc.free(trace); try std.testing.expect(std.mem.find(u8, trace, "provider replay omitted reason=interrupted_incomplete_association source_calls=3 completed_calls=1") != null);}
test "two standalone compaction cuts retain matching request and finalization history" { const execution_memory = @import("../execution_memory.zig"); const alloc = std.testing.allocator; const first_call = toolCall("first_tool", "read_file", "{\"path\":\"a.txt\"}"); const last_call = toolCall("last_tool", "read_file", "{\"path\":\"b.txt\"}"); const messages = [_]ChatMessage{ .{ .role = .assistant, .content = "first candidate", .standalone_response = true }, .{ .role = .user, .content = "Continue the turn. fx hook context:\nverify" }, .{ .role = .assistant, .tool_calls = &.{first_call} }, .{ .role = .tool, .content = "first result", .tool_call_id = first_call.id, .tool_name = first_call.name, .tool_result_status = .success }, .{ .role = .assistant, .provider_replay = .{ .source = .{ .provider = .gateway, .model = "anthropic/claude-opus-4.6" }, .parts_json = "[{\"type\":\"reasoning\",\"text\":\"COMPACTED_PRIVATE_REPLAY\"}]", }, }, .{ .role = .user, .content = "Summarize what you just did." }, .{ .role = .assistant, .tool_calls = &.{last_call} }, .{ .role = .tool, .content = "last result", .tool_call_id = last_call.id, .tool_name = last_call.name, .tool_result_status = .success }, }; var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); var offset: usize = 0; var boundary: execution_memory.CompactedExecutionBoundary = .{}; const cuts = [_]struct { end: usize, steps: usize }{ .{ .end = 4, .steps = 1 }, .{ .end = messages.len, .steps = 2 }, }; for (cuts) |cut| { const before = try execution_memory.buildExecutionMemory(arena.allocator(), messages[offset..cut.end]); offset += try execution_memory.retainedMessageOffset(messages[offset..cut.end], .{ .tool_steps = cut.steps }); boundary.tool_steps += cut.steps; const retained = try execution_memory.buildExecutionMemory(arena.allocator(), messages[offset..cut.end]); try std.testing.expectEqual(before.tool_steps.len - cut.steps, retained.tool_steps.len); try std.testing.expectEqual(@as(usize, 1), retained.tool_steps.len); try std.testing.expectEqualStrings(before.tool_steps[cut.steps].tool_calls[0].id, retained.tool_steps[0].tool_calls[0].id); } try std.testing.expectEqual(@as(usize, 5), offset); const builtin_gateway = @import("../../../../builtins/gateway.zig"); const body = try builtin_gateway.buildAgentRequest(alloc, .{ .model = "anthropic/claude-opus-4.6", .messages = messages[offset..], .tool_choice = .auto, .provider_options = .{}, }); defer alloc.free(body); try std.testing.expect(std.mem.find(u8, body, "last_tool") != null); try std.testing.expect(std.mem.find(u8, body, "first candidate") == null); try std.testing.expect(std.mem.find(u8, body, "first_tool") == null); try std.testing.expect(std.mem.find(u8, body, "COMPACTED_PRIVATE_REPLAY") == null);
var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); const runtime_deps = deps.deps(); var finalization = TurnFinalizationGuard.init(&runtime_deps, 1, testLifecycleContext(lifecycle_hooks.RuntimeView.empty(), alloc, "/tmp/workspace")); defer finalization.deinit(); finalization.compacted_execution = boundary; var fixture = PromptFixture{}; var summary = TurnSummaryAccumulator{ .turn_started_at_ms = io_mod.milliTimestamp() }; var finish_trace = PromptFinishTrace{ .ctx = .{ .turn_id = 1 } }; try finishCommonAssistantTerminal(&runtime_deps, &finalization, arena.allocator(), fixture.job(), &messages, &summary, .{ .history = "final answer" }, .completed, .completed, &finish_trace, "completed", null); const turn = deps.history_turns.items[0].assistant; try std.testing.expectEqualStrings("final answer", turn.assistant); try std.testing.expectEqual(@as(usize, 1), turn.execution.tool_steps.len); try std.testing.expectEqualStrings(last_call.id, turn.execution.tool_steps[0].tool_calls[0].id); try std.testing.expectEqualStrings("last result", turn.execution.tool_steps[0].tool_results[0].output); var follow_gateway = FakeGateway.init(alloc, &.{.{ .content = "follow-up" }}); defer follow_gateway.deinit(); var follow_deps = FakeAgentRuntimeDeps.init(alloc); defer follow_deps.deinit(); var follow_job = fixture.job(); follow_job.history = deps.history_turns.items; try runFakePrompt(&follow_gateway, &follow_deps, fixture.config(), follow_job); try expectBodyContains(&follow_gateway, 0, "last result"); try expectBodyNotContains(&follow_gateway, 0, "first candidate"); try expectBodyNotContains(&follow_gateway, 0, "first_tool"); try expectBodyNotContains(&follow_gateway, 0, "COMPACTED_PRIVATE_REPLAY");}
test "common Stop parallel cancellation preserves ordinary cancelled peer as completed failure" { const alloc = std.testing.allocator; const calls = [_]ToolCall{ toolCall("call_ordinary_cancel", "read_file", "{\"path\":\"README.md\"}"), toolCall("call_owner_signal", "grep_files", "{\"pattern\":\"Hooks\"}"), toolCall("call_completed_peer", "glob_files", "{\"pattern\":\"src/main.zig\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .tool_calls = &calls }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); var fixture = PromptFixture{}; deps.ordinary_cancel_on_execute_name = "read_file"; deps.cancel_on_execute = &fixture.cancel_flag; deps.cancel_on_execute_name = "grep_files"; deps.cancel_on_execute_delay_ms = 50; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler); var job = fixture.job(); job.permission_mode = .auto;
try runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), job, testLifecycleContext(view, alloc, fixture.workspace_root), );
try std.testing.expectEqual(@as(usize, 3), deps.executed_names.items.len); try std.testing.expectEqual(@as(usize, 1), deps.history_turns.items.len); const turn = deps.history_turns.items[0].interrupted; try std.testing.expectEqualStrings("candidate", deps.finish_presentation_text.?); try std.testing.expectEqualStrings("", turn.assistant orelse ""); try std.testing.expect(turn.tool_call == null); try std.testing.expectEqual(@as(usize, 2), turn.execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", turn.execution.tool_steps[0].assistant.?); try std.testing.expectEqual(@as(usize, 0), turn.execution.tool_steps[0].tool_calls.len); const step = turn.execution.tool_steps[1]; try std.testing.expectEqual(@as(usize, 3), step.tool_calls.len); try std.testing.expectEqual(@as(usize, 3), step.tool_results.len); try std.testing.expectEqualStrings("call_ordinary_cancel", step.tool_calls[0].id); try std.testing.expectEqualStrings("call_owner_signal", step.tool_calls[1].id); try std.testing.expectEqualStrings("call_completed_peer", step.tool_calls[2].id); try std.testing.expectEqual( types.PersistedToolStatus.failure, step.tool_results[0].status, ); try std.testing.expect(std.mem.find( u8, step.tool_results[0].output, "Cancelled", ) != null); try std.testing.expectEqual( types.PersistedToolStatus.success, step.tool_results[1].status, ); try std.testing.expectEqual( types.PersistedToolStatus.success, step.tool_results[2].status, );}
test "common Stop failure excludes a later call without a result" { const alloc = std.testing.allocator; const calls = [_]ToolCall{ .{ .id = "call_completed", .name = "perplexity_search", .arguments_json = "{}", .provider_result = "completed provider result", .provenance = .provider_executed, }, toolCall("call_resultless", "read_file", "{\"path\":\"README.md\"}"), }; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .tool_calls = &calls }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.exec_plans = &.{.{ .result = .{ .model_output = "never persisted" } }}; deps.fail_status_finished = true; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try std.testing.expectError( error.TestStatusPublicationFailure, runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), ), );
try std.testing.expectEqual(@as(usize, 1), deps.history_turns.items.len); const execution = deps.history_turns.items[0].assistant.execution; try std.testing.expectEqual(@as(usize, 2), execution.tool_steps.len); try std.testing.expectEqualStrings("candidate", execution.tool_steps[0].assistant.?); const step = execution.tool_steps[1]; try std.testing.expectEqual(@as(usize, 1), step.tool_calls.len); try std.testing.expectEqualStrings("call_completed", step.tool_calls[0].id); try std.testing.expectEqual(@as(usize, 1), step.tool_results.len);
var replay: std.ArrayList(ChatMessage) = .empty; defer replay.deinit(alloc); try session_runtime.appendHistoryChatMessages( alloc, &replay, deps.history_turns.items, ); try std.testing.expectEqual(@as(usize, 4), replay.items.len); try std.testing.expectEqualStrings("candidate", replay.items[1].content.?); try std.testing.expectEqual(@as(usize, 1), replay.items[2].tool_calls.len); try std.testing.expectEqualStrings( "call_completed", replay.items[2].tool_calls[0].id, ); try std.testing.expectEqualStrings( "call_completed", replay.items[3].tool_call_id.?, );}
test "common Stop history propagation failure preserves recovery checkpoint" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .finish_reason = .provider_error }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.history_propagation_error = error.TestHistoryPropagationFailed; deps.enable_recovery_checkpoint = true; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try std.testing.expectError( error.TestHistoryPropagationFailed, runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), ), );
try std.testing.expectEqual(@as(usize, 1), deps.history_propagation_count); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count); try std.testing.expectEqual(@as(usize, 1), deps.finish_event_attempt_count); try std.testing.expectEqual(@as(usize, 1), deps.finish_event_count); try std.testing.expect(deps.recovery_checkpoints.items.len > 0);}
test "common Stop finalization failure wins and is not retried" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .finish_reason = .provider_error }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.history_propagation_error = error.TestHistoryPropagationFailed; deps.finalization_error = error.TestFinalizationFailed; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try std.testing.expectError( error.TestFinalizationFailed, runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), ), );
try std.testing.expectEqual(@as(usize, 1), deps.history_propagation_count); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count); try std.testing.expectEqual(@as(usize, 0), deps.finish_event_attempt_count);}
test "common Stop finish event failure wins and is not retried" { const alloc = std.testing.allocator; var gateway = FakeGateway.init(alloc, &.{ .{ .content = "candidate" }, .{ .finish_reason = .provider_error }, }); defer gateway.deinit(); var deps = FakeAgentRuntimeDeps.init(alloc); deps.history_propagation_error = error.TestHistoryPropagationFailed; deps.finish_event_error = error.TestFinishEventFailed; defer deps.deinit(); var fixture = PromptFixture{}; var lifecycle_runtime = lifecycle_hooks.Runtime.init(alloc); defer lifecycle_runtime.deinit(); var handler = StopTestHandler{ .alloc = alloc, .action = .{ .continue_once = "verify" }, }; defer handler.deinit(); const view = try registerStopTestHandler(&lifecycle_runtime, &handler);
try std.testing.expectError( error.TestFinishEventFailed, runFakePromptWithLifecycle( &gateway, &deps, fixture.config(), fixture.job(), testLifecycleContext(view, alloc, fixture.workspace_root), ), );
try std.testing.expectEqual(@as(usize, 1), deps.history_propagation_count); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count); try std.testing.expectEqual(@as(usize, 1), deps.finish_event_attempt_count); try std.testing.expectEqual(@as(usize, 0), deps.finish_event_count);}
test "common Stop terminal payload construction failure leaves guard open for one fallback" { const alloc = std.testing.allocator; const calls = [_]ToolCall{ toolCall("call_read", "read_file", "{\"path\":\"README.md\"}"), }; const messages = [_]ChatMessage{ .{ .role = .assistant, .tool_calls = &calls, }, .{ .role = .tool, .content = "result", .tool_call_id = "call_read", .tool_name = "read_file", .tool_result_status = .success, }, }; var failing = std.testing.FailingAllocator.init( alloc, .{ .fail_index = 0 }, ); var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); const runtime_deps = deps.deps(); var finalization = TurnFinalizationGuard.init( &runtime_deps, 1, testLifecycleContext( lifecycle_hooks.RuntimeView.empty(), alloc, "/tmp/workspace", ), ); var fixture = PromptFixture{}; var job = fixture.job(); job.turn_id = 1; var summary = TurnSummaryAccumulator{ .turn_started_at_ms = io_mod.milliTimestamp(), }; var finish_trace = PromptFinishTrace{ .ctx = .{ .turn_id = 1 }, };
try std.testing.expectError( error.OutOfMemory, finishCommonAssistantTerminal( &runtime_deps, &finalization, failing.allocator(), job, &messages, &summary, .{ .history = "candidate" }, .failed, null, &finish_trace, "error", null, ), );
try std.testing.expectEqual(TurnFinalizationGuard.State.open, finalization.state); try std.testing.expectEqual(@as(usize, 0), deps.history_propagation_count); try std.testing.expectEqual(@as(usize, 0), deps.finalization_count);
try finalization.finish(.failed, null, null); try std.testing.expectEqual(TurnFinalizationGuard.State.emitted, finalization.state); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count); try std.testing.expectEqual(@as(usize, 0), deps.finish_event_attempt_count);}
test "TurnFinalizationGuard runs PostTurnEnd once for every terminal outcome and scope" { const alloc = std.testing.allocator; var hook_capture = PostTurnEndFinalizationCapture{}; var hook_runtime = lifecycle_hooks.Runtime.init(alloc); defer hook_runtime.deinit(); try hook_runtime.registerPostTurnEnd(.{ .name = "capture", .ctx = &hook_capture, .run = PostTurnEndFinalizationCapture.run, });
var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); const runtime_deps = deps.deps(); const view = hook_runtime.freeze(); const cases = [_]struct { scope_kind: lifecycle_hooks.ScopeKind, outcome: types.TurnPresentationOutcome, disposition: ?types.ProviderCompletionDisposition, }{ .{ .scope_kind = .interactive, .outcome = .completed, .disposition = .completed }, .{ .scope_kind = .ask, .outcome = .interrupted, .disposition = null }, .{ .scope_kind = .ask, .outcome = .paused, .disposition = null }, .{ .scope_kind = .acp, .outcome = .failed, .disposition = .length_limited }, .{ .scope_kind = .subagent, .outcome = .completed, .disposition = null }, };
for (cases, 0..) |case, index| { const turn_id: u64 = @intCast(index + 1); var lifecycle = testLifecycleContext(view, alloc, "/tmp/workspace"); lifecycle.scope.kind = case.scope_kind; if (case.scope_kind == .subagent) lifecycle.scope.subagent_id = 17; var finalization = TurnFinalizationGuard.init( &runtime_deps, turn_id, lifecycle, ); defer finalization.deinit(); try finalization.track_agent_terminal_lease("terminal-owned"); try finalization.track_agent_terminal_lease("terminal-owned"); try finalization.finish(case.outcome, case.disposition, null); try finalization.finish(.failed, null, null); }
try std.testing.expectEqual(cases.len, deps.finalization_count); try std.testing.expectEqual(cases.len, deps.terminal_lease_cleanup_ids.items.len); try std.testing.expectEqual(cases.len, hook_capture.calls); for (cases, 0..) |case, index| { try std.testing.expectEqual(@as(u64, @intCast(index + 1)), hook_capture.turn_ids[index]); try std.testing.expectEqual(case.scope_kind, hook_capture.scope_kinds[index]); try std.testing.expect(hook_capture.workspace_matches[index]); try std.testing.expectEqual(case.outcome, hook_capture.outcomes[index]); try std.testing.expectEqual(case.disposition, hook_capture.dispositions[index]); }}
test "TurnFinalizationGuard attempts every tracked lease without replacing accepted completion" { const alloc = std.testing.allocator; const cleanup_errors = [_]?anyerror{ error.TestFirstCleanupFailed, null, }; var deps = FakeAgentRuntimeDeps.init(alloc); deps.terminal_lease_cleanup_errors = &cleanup_errors; defer deps.deinit(); const runtime_deps = deps.deps(); var finalization = TurnFinalizationGuard.init( &runtime_deps, 41, testLifecycleContext(lifecycle_hooks.RuntimeView.empty(), alloc, "/tmp/workspace"), ); defer finalization.deinit();
try finalization.track_agent_terminal_lease("terminal-one"); try finalization.track_agent_terminal_lease("terminal-two"); finalization.remove_agent_terminal_lease("terminal-missing"); const finished = try types.dupeFinishedPrompt(std.heap.c_allocator, .{ .turn = .{ .assistant = .{ .user = .{ .text = @constCast("prompt") }, .assistant = @constCast("accepted answer"), } }, });
try finalization.finish(.completed, .completed, finished); try finalization.finish(.failed, null, null); try std.testing.expectEqual(TurnFinalizationGuard.State.emitted, finalization.state); try std.testing.expectEqual(@as(usize, 2), deps.terminal_lease_cleanup_ids.items.len); try std.testing.expectEqualStrings("terminal-one", deps.terminal_lease_cleanup_ids.items[0]); try std.testing.expectEqualStrings("terminal-two", deps.terminal_lease_cleanup_ids.items[1]); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count); try std.testing.expectEqual(@as(usize, 1), deps.finish_event_count); try std.testing.expectEqual(@as(usize, 1), deps.finish_event_attempt_count); try std.testing.expectEqualStrings("accepted answer", deps.finish_assistant_text.?);}
test "TurnFinalizationGuard removes explicit release without cleanup" { const alloc = std.testing.allocator; var deps = FakeAgentRuntimeDeps.init(alloc); defer deps.deinit(); const runtime_deps = deps.deps(); var finalization = TurnFinalizationGuard.init( &runtime_deps, 42, testLifecycleContext(lifecycle_hooks.RuntimeView.empty(), alloc, "/tmp/workspace"), ); defer finalization.deinit();
try finalization.track_agent_terminal_lease("terminal-released"); finalization.remove_agent_terminal_lease("terminal-released"); try finalization.finish(.completed, null, null);
try std.testing.expectEqual(@as(usize, 0), deps.terminal_lease_cleanup_ids.items.len); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count);}
test "TurnFinalizationGuard skips PostTurnEnd when terminal finalization fails" { const alloc = std.testing.allocator; var hook_capture = PostTurnEndFinalizationCapture{}; var hook_runtime = lifecycle_hooks.Runtime.init(alloc); defer hook_runtime.deinit(); try hook_runtime.registerPostTurnEnd(.{ .name = "capture", .ctx = &hook_capture, .run = PostTurnEndFinalizationCapture.run, });
var deps = FakeAgentRuntimeDeps.init(alloc); deps.finalization_error = error.TestFinalizationFailed; defer deps.deinit(); const runtime_deps = deps.deps(); var finalization = TurnFinalizationGuard.init( &runtime_deps, 7, testLifecycleContext(hook_runtime.freeze(), alloc, "/tmp/workspace"), );
try std.testing.expectError( error.TestFinalizationFailed, finalization.finish(.failed, null, null), );
try std.testing.expectEqual(TurnFinalizationGuard.State.fatal, finalization.state); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count); try std.testing.expectEqual(@as(usize, 0), hook_capture.calls);}
test "TurnFinalizationGuard runs PostTurnEnd after failed finish prompt publication" { const alloc = std.testing.allocator; var deps = FakeAgentRuntimeDeps.init(alloc); deps.finish_event_error = error.TestFinishEventFailed; defer deps.deinit();
var hook_capture = PostTurnEndFinalizationCapture{ .deps = &deps }; var hook_runtime = lifecycle_hooks.Runtime.init(alloc); defer hook_runtime.deinit(); try hook_runtime.registerPostTurnEnd(.{ .name = "capture", .ctx = &hook_capture, .run = PostTurnEndFinalizationCapture.run, });
const runtime_deps = deps.deps(); var finalization = TurnFinalizationGuard.init( &runtime_deps, 9, testLifecycleContext(hook_runtime.freeze(), alloc, "/tmp/workspace"), ); const finished = try types.dupeFinishedPrompt(std.heap.c_allocator, .{ .turn = .{ .assistant = .{ .user = .{ .text = @constCast("prompt") }, .assistant = @constCast("answer"), } }, });
try std.testing.expectError( error.TestFinishEventFailed, finalization.finish(.completed, .completed, finished), ); try finalization.finish(.failed, null, null);
try std.testing.expectEqual(TurnFinalizationGuard.State.emitted, finalization.state); try std.testing.expectEqual(@as(usize, 1), deps.finalization_count); try std.testing.expectEqual(@as(usize, 1), deps.finish_event_attempt_count); try std.testing.expectEqual(@as(usize, 1), hook_capture.calls); try std.testing.expectEqual(@as(usize, 1), hook_capture.finish_event_attempts[0]); try std.testing.expectEqual(@as(u64, 9), hook_capture.turn_ids[0]); try std.testing.expectEqual(types.TurnPresentationOutcome.completed, hook_capture.outcomes[0]); try std.testing.expectEqual(@as(?types.ProviderCompletionDisposition, .completed), hook_capture.dispositions[0]);}