const 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 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 normal final completion propagates normalized 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 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; 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; 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; 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; 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, ); } 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 returns a final response after repeated malformed calls" { 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 = "Recovered after malformed arguments." }, }; 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.expect(!textContains(&hooks, "Agent stopped: 3 consecutive steps with all-error tool calls.")); 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.completed, hooks.finalized_outcome.?); try std.testing.expectEqualStrings("Recovered after malformed arguments.", hooks.history_assistant_text.?); try std.testing.expectEqualStrings("Recovered after malformed arguments.", 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 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.?); 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, );Lines 1–500 of 1786. Load more
const 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 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 normal final completion propagates normalized 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 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; 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; 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; 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; 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, ); } 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 returns a final response after repeated malformed calls" { 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 = "Recovered after malformed arguments." }, }; 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.expect(!textContains(&hooks, "Agent stopped: 3 consecutive steps with all-error tool calls.")); 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.completed, hooks.finalized_outcome.?); try std.testing.expectEqualStrings("Recovered after malformed arguments.", hooks.history_assistant_text.?); try std.testing.expectEqualStrings("Recovered after malformed arguments.", 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 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.?); 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, );