diff --git a/src/builtins/tools.zig b/src/builtins/tools.zig index 6070f844d..945f4bff4 100644 --- a/src/builtins/tools.zig +++ b/src/builtins/tools.zig @@ -190,134 +190,47 @@ const ask_user_question_question_schema = model_tool_schema.ObjectSchema{ }; const subagent_description = - "Create, inspect, message, relate, configure, or control ordinary fx child sessions through one asynchronous manager API. When to use: delegate independent work, inspect an explicit child, send ordinary content, emit a configured milestone, or change an authorized child. Select exactly one command branch; creation returns an admitted child handle without waiting for completion. When NOT to use: ordinary local work, implicit child discovery, multiple operations in one call, or milestone-shaped chat content. Inspect only explicit child IDs and requested bounded sections. When the current turn requires the child's settled result, use inspect.wait instead of shell.run sleep or repeated polling. The messages section includes queued work and recent committed child conversation; tool_activity returns recent persisted tool phases; failed status includes the latest retained failure reason. Ordinary content must use message.send."; + "Delegate independent work to one managed child. Use run with a task; fx returns the child ID. Omit model and effort to inherit the current settings; never invent a model ID. Use wait only when the current turn needs the child settled, send for one follow-up to that exact child, and stop to cancel owned active work. Child persistence, inspection, notification, relationship, permission, and lifecycle mechanics are owned by fx."; -const subagent_terminal_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "completed", .json_type = .boolean }, - .{ .name = "failed", .json_type = .boolean }, - .{ .name = "cancelled", .json_type = .boolean }, - }, - .additional_properties = false, -}; - -const subagent_notifications_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "terminal", .json_type = .object, .shape = &.{ .object = &subagent_terminal_schema } }, - .{ .name = "milestones", .json_type = .array, .bounds = &.{ .max_items = subagent_domain.max_milestones }, .shape = &.{ .array_values = .{ .json_type = .string } } }, - .{ .name = "report_interval_ms", .json_type = .integer, .bounds = &.{ .minimum = 1 } }, - .{ .name = "report_duration_ms", .json_type = .integer, .bounds = &.{ .minimum = 1 } }, - .{ .name = "stop_conditions", .json_type = .array, .bounds = &.{ .max_items = subagent_domain.max_stop_conditions }, .shape = &.{ .array_values = .{ .json_type = .string, .enum_values = &.{ "terminal", "duration_elapsed" } } } }, - }, - .additional_properties = false, -}; - -const subagent_create_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "name", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_name_bytes } }, - .{ .name = "mode", .json_type = .string, .shape = &.{ .enum_values = &.{ "one_off", "persistent" } } }, - .{ .name = "prompt", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_prompt_bytes } }, - .{ .name = "model", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_model_bytes } }, - .{ .name = "effort", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = types.ReasoningEffort.max_name_bytes } }, - .{ .name = "permission_mode", .json_type = .string, .shape = &.{ .enum_values = &.{ "ask", "auto", "yolo" } }, .description = "Child permission mode. Inherits the caller when omitted and cannot exceed it." }, - .{ .name = "notifications", .json_type = .object, .shape = &.{ .object = &subagent_notifications_schema } }, - }, - .required = &.{ "name", "mode" }, - .additional_properties = false, -}; - -const subagent_inspect_wait_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "until", .json_type = .string, .shape = &.{ .enum_values = &.{"settled"} }, .description = "Wait until a persistent child is idle or the child reaches another non-running terminal/recovery state." }, - .{ .name = "after_generation", .json_type = .integer, .bounds = &.{ .minimum = 0 }, .description = "Optional durable generation that must be exceeded before the wait can complete." }, - .{ .name = "timeout_ms", .json_type = .integer, .bounds = &.{ .minimum = 1, .maximum = subagent_domain.max_inspect_wait_ms }, .description = "Bounded wait deadline in milliseconds. A timeout returns the latest inspection with status wait_timed_out." }, - }, - .required = &.{ "until", "timeout_ms" }, - .additional_properties = false, -}; - -const subagent_inspect_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "id", .json_type = .string, .bounds = &.{ .min_length = 1 } }, - .{ .name = "sections", .json_type = .array, .bounds = &.{ .min_items = 1, .max_items = 6 }, .shape = &.{ .array_values = .{ .json_type = .string, .enum_values = &.{ "status", "messages", "tool_activity", "events", "configuration", "relationship" } } } }, - .{ .name = "cursor", .json_type = .string, .bounds = &.{ .min_length = 1 } }, - .{ .name = "limit", .json_type = .integer, .bounds = &.{ .minimum = 1, .maximum = subagent_domain.max_page_limit } }, - .{ .name = "wait", .json_type = .object, .shape = &.{ .object = &subagent_inspect_wait_schema }, .description = "Optional condition-driven same-turn wait. Requires the status section and cannot be combined with a cursor." }, - }, - .required = &.{ "id", "sections" }, - .additional_properties = false, -}; - -const subagent_send_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "id", .json_type = .string, .bounds = &.{ .min_length = 1 } }, - .{ .name = "content", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_message_bytes } }, - }, - .required = &.{ "id", "content" }, - .additional_properties = false, +const subagent_model_run_properties = [_]model_tool_schema.Property{ + .{ .name = "action", .json_type = .string, .shape = &.{ .enum_values = &.{"run"} } }, + .{ .name = "task", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_prompt_bytes }, .description = "Complete delegated task. fx creates one persistent child and owns its lifecycle." }, + .{ .name = "model", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_model_bytes }, .description = "Optional exact configured model ID. Omit to inherit the current model; never guess an ID." }, + .{ .name = "effort", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = types.ReasoningEffort.max_name_bytes }, .description = "Optional reasoning effort override. Omit to inherit the current effort." }, }; -const subagent_milestone_schema = model_tool_schema.ObjectSchema{ - .properties = &.{.{ .name = "name", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_name_bytes } }}, - .required = &.{"name"}, - .additional_properties = false, +const subagent_model_wait_properties = [_]model_tool_schema.Property{ + .{ .name = "action", .json_type = .string, .shape = &.{ .enum_values = &.{"wait"} }, .description = "Wait once for the exact child. Provide only action and child_id; fx owns the bounded wait." }, + .{ .name = "child_id", .json_type = .string, .bounds = &.{ .min_length = 1 }, .description = "Exact child ID returned by run." }, }; -const subagent_message_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "send", .json_type = .object, .shape = &.{ .object = &subagent_send_schema } }, - .{ .name = "milestone", .json_type = .object, .shape = &.{ .object = &subagent_milestone_schema } }, - }, - .additional_properties = false, - .min_properties = 1, - .max_properties = 1, +const subagent_model_send_properties = [_]model_tool_schema.Property{ + .{ .name = "action", .json_type = .string, .shape = &.{ .enum_values = &.{"send"} } }, + .{ .name = "child_id", .json_type = .string, .bounds = &.{ .min_length = 1 }, .description = "Exact messageable child ID returned by run." }, + .{ .name = "message", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_message_bytes }, .description = "One follow-up instruction for the same child." }, }; -const subagent_relationship_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "action", .json_type = .string, .shape = &.{ .enum_values = &.{ "attach", "detach", "reparent" } } }, - .{ .name = "id", .json_type = .string, .bounds = &.{ .min_length = 1 } }, - .{ .name = "parent_id", .json_type = .string, .bounds = &.{ .min_length = 1 } }, - }, - .required = &.{ "action", "id" }, - .additional_properties = false, +const subagent_model_stop_properties = [_]model_tool_schema.Property{ + .{ .name = "action", .json_type = .string, .shape = &.{ .enum_values = &.{"stop"} }, .description = "Stop owned active work. Already-settled children remain unchanged." }, + .{ .name = "child_id", .json_type = .string, .bounds = &.{ .min_length = 1 }, .description = "Exact child ID returned by run." }, }; -const subagent_configure_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "id", .json_type = .string, .bounds = &.{ .min_length = 1 } }, - .{ .name = "name", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_name_bytes } }, - .{ .name = "model", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = subagent_domain.max_model_bytes } }, - .{ .name = "effort", .json_type = .string, .bounds = &.{ .min_length = 1, .max_length = types.ReasoningEffort.max_name_bytes } }, - .{ .name = "permission_mode", .json_type = .string, .shape = &.{ .enum_values = &.{ "ask", "auto", "yolo" } }, .description = "New child permission mode. Cannot exceed the caller's current mode." }, - .{ .name = "notifications", .json_type = .object, .shape = &.{ .object = &subagent_notifications_schema } }, - }, - .required = &.{"id"}, - .additional_properties = false, +const subagent_model_action_schemas = [_]model_tool_schema.ObjectSchema{ + .{ .properties = &subagent_model_run_properties, .required = &.{ "action", "task" }, .additional_properties = false }, + .{ .properties = &subagent_model_wait_properties, .required = &.{ "action", "child_id" }, .additional_properties = false }, + .{ .properties = &subagent_model_send_properties, .required = &.{ "action", "child_id", "message" }, .additional_properties = false }, + .{ .properties = &subagent_model_stop_properties, .required = &.{ "action", "child_id" }, .additional_properties = false }, }; -const subagent_lifecycle_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "id", .json_type = .string, .bounds = &.{ .min_length = 1 } }, - .{ .name = "action", .json_type = .string, .shape = &.{ .enum_values = &.{ "cancel", "resume", "close", "reopen" } } }, - }, - .required = &.{ "id", "action" }, - .additional_properties = false, +const subagent_model_action_union = model_tool_schema.ObjectSchema{ + .one_of = &subagent_model_action_schemas, }; -const subagent_command_schema = model_tool_schema.ObjectSchema{ - .properties = &.{ - .{ .name = "create", .json_type = .object, .shape = &.{ .object = &subagent_create_schema } }, - .{ .name = "inspect", .json_type = .object, .shape = &.{ .object = &subagent_inspect_schema } }, - .{ .name = "message", .json_type = .object, .shape = &.{ .object = &subagent_message_schema } }, - .{ .name = "relationship", .json_type = .object, .shape = &.{ .object = &subagent_relationship_schema } }, - .{ .name = "configure", .json_type = .object, .shape = &.{ .object = &subagent_configure_schema } }, - .{ .name = "lifecycle", .json_type = .object, .shape = &.{ .object = &subagent_lifecycle_schema } }, - }, - .additional_properties = false, - .min_properties = 1, - .max_properties = 1, -}; +const subagent_model_request_properties = [_]model_tool_schema.Property{.{ + .name = "request", + .json_type = .object, + .shape = &.{ .object = &subagent_model_action_union }, +}}; const vision_description = "Inspect authorized images attached by the user or local image paths supplied in the conversation, and return structured factual evidence. Pass exactly one source: image_ids for attached images, or paths for local images. When to use: read visible text, UI state, objects, layout, or other visual details needed for the task. When NOT to use: inspect paths the user did not supply, infer details not visible in an image, or repeat evidence already available in the conversation."; const read_tool_result_description = @@ -712,8 +625,8 @@ pub const subagent = ToolSpec{ .name = "subagent", .description = subagent_description, .input_schema = .{ - .properties = &.{.{ .name = "command", .json_type = .object, .shape = &.{ .object = &subagent_command_schema } }}, - .required = &.{"command"}, + .properties = &subagent_model_request_properties, + .required = &.{"request"}, .additional_properties = false, }, }, @@ -1017,7 +930,7 @@ test "built-in model-facing tool contract stays byte exact" { const actual_hex = std.fmt.bytesToHex(hasher.finalResult(), .lower); try std.testing.expectEqualStrings( - "4debb42dd1ceb414b85eea4ddfbf79443329290ab7526c7dffe35149e1335f19", + "69d5b313e8f44153775b1ab28c43de48fca702d2f9e7af12d2b7147fe497a714", &actual_hex, ); } @@ -1465,15 +1378,25 @@ test "built-in subagent owns product metadata schema and callbacks" { defer std.testing.allocator.free(schema_json); try std.testing.expectEqualStrings("subagent", subagent.name); - try std.testing.expect(std.mem.find(u8, subagent.description, "ordinary fx child sessions") != null); - try std.testing.expect(std.mem.find(u8, subagent.description, "Select exactly one command branch") != null); - try std.testing.expect(std.mem.find(u8, subagent.description, "use inspect.wait instead of shell.run") != null); - try std.testing.expect(std.mem.find(u8, schema_json, "\"command\":{\"type\":\"object\"") != null); - try std.testing.expect(std.mem.find(u8, schema_json, "\"minProperties\":1,\"maxProperties\":1") != null); - try std.testing.expect(std.mem.find(u8, schema_json, "\"required\":[\"id\",\"sections\"]") != null); - try std.testing.expect(std.mem.find(u8, schema_json, "\"until\":{\"type\":\"string\",\"enum\":[\"settled\"]") != null); - try std.testing.expect(std.mem.find(u8, schema_json, "\"timeout_ms\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":60000") != null); - try std.testing.expect(std.mem.find(u8, schema_json, "\"required\":[\"until\",\"timeout_ms\"]") != null); + try std.testing.expect(std.mem.find(u8, subagent.description, "one managed child") != null); + try std.testing.expect(std.mem.find(u8, subagent.description, "never invent a model ID") != null); + try std.testing.expect(std.mem.find(u8, schema_json, "\"request\":{") != null); + try std.testing.expect(std.mem.find(u8, schema_json, "\"required\":[\"request\"]") != null); + for ([_][]const u8{ "run", "wait", "send", "stop" }) |action| { + try std.testing.expect(std.mem.find(u8, schema_json, action) != null); + } + for ([_][]const u8{ + "\"command\":", + "\"relationship\":", + "\"configure\":", + "\"notifications\":", + "\"sections\":", + "\"cursor\":", + "\"generation\":", + "\"reopen\"", + }) |mechanism| { + try std.testing.expect(std.mem.find(u8, schema_json, mechanism) == null); + } try std.testing.expect(std.mem.find(u8, schema_json, "subagent_type") == null); try std.testing.expectEqual(tool_dispatch.ExecutorKind.subagent, subagent.executor_kind); try std.testing.expectEqual(types.ToolActivityKind.subagent, subagent.activity_kind); diff --git a/src/core/agent/runtime/orchestrator.zig b/src/core/agent/runtime/orchestrator.zig index 8abc869ee..3ef37b35d 100644 --- a/src/core/agent/runtime/orchestrator.zig +++ b/src/core/agent/runtime/orchestrator.zig @@ -32,6 +32,7 @@ const tool_preparation = @import("../tool_preparation.zig"); const command_admission = @import("../../permissions/command_admission.zig"); const permission_auto_classifier = @import("../../permissions/auto_classifier.zig"); const auto_classifier_context = @import("../../permissions/auto_classifier_context.zig"); +const subagent_model_contract = @import("../../subagent/model_contract.zig"); const runtime_config = @import("config.zig"); const runtime_finalization = @import("finalization.zig"); @@ -127,6 +128,19 @@ fn terminal_request_schema_advertised( return false; } +fn subagent_request_schema_advertised( + advertised_functions: []const model_tool_schema.FunctionSchema, +) bool { + for (advertised_functions) |function| { + if (!std.mem.eql(u8, function.name, "subagent")) continue; + return model_tool_schema.isSingleRequiredObjectUnionField( + function.input_schema, + "request", + ); + } + return false; +} + fn terminal_request_normalization_eligible( base_nested_terminal_advertised: bool, vision_mode: runtime_gateway_step.VisionToolMode, @@ -134,6 +148,13 @@ fn terminal_request_normalization_eligible( return base_nested_terminal_advertised and vision_mode != .required; } +fn subagent_request_normalization_eligible( + base_nested_subagent_advertised: bool, + vision_mode: runtime_gateway_step.VisionToolMode, +) bool { + return base_nested_subagent_advertised and vision_mode != .required; +} + fn terminal_action_is(object: std.json.ObjectMap, action_name: []const u8) bool { const action = object.get("action") orelse return false; return action == .string and std.mem.eql(u8, action.string, action_name); @@ -532,6 +553,468 @@ fn project_terminal_request_messages( return projected; } +const SubagentHistoryDisposition = enum { + current, + mapped, + inert, +}; + +const SubagentHistoryCall = struct { + id: []const u8, + action: []const u8, + disposition: SubagentHistoryDisposition, +}; + +fn find_subagent_history_call( + calls: []const SubagentHistoryCall, + id: []const u8, +) ?SubagentHistoryCall { + for (calls) |call| { + if (std.mem.eql(u8, call.id, id)) return call; + } + return null; +} + +fn legacy_subagent_action(arguments_json: []const u8) ?[]const u8 { + var parsed = std.json.parseFromSlice( + std.json.Value, + std.heap.page_allocator, + arguments_json, + .{}, + ) catch return null; + defer parsed.deinit(); + if (parsed.value != .object) return null; + const command = parsed.value.object.get("command") orelse return null; + if (command != .object or command.object.count() != 1) return "unknown"; + const branch = command.object.keys()[0]; + for ([_][]const u8{ "create", "inspect", "message", "relationship", "configure", "lifecycle" }) |known| { + if (std.mem.eql(u8, branch, known)) return known; + } + return "unknown"; +} + +fn project_legacy_subagent_arguments( + alloc: Allocator, + arguments_json: []const u8, +) Allocator.Error!?[]u8 { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, arguments_json, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; + defer parsed.deinit(); + if (parsed.value != .object) return null; + const command = parsed.value.object.get("command") orelse return null; + if (command != .object or command.object.count() != 1) return null; + const arena = parsed.arena.allocator(); + const branch_name = command.object.keys()[0]; + const branch = command.object.values()[0]; + if (branch != .object) return null; + + var request = std.json.Value{ .object = .empty }; + if (std.mem.eql(u8, branch_name, "create")) { + const task = branch.object.get("prompt") orelse return null; + if (task != .string or task.string.len == 0) return null; + try request.object.put(arena, "action", .{ .string = "run" }); + try request.object.put(arena, "task", task); + for ([_][]const u8{ "model", "effort" }) |name| { + if (branch.object.get(name)) |value| { + if (value != .string) return null; + try request.object.put(arena, name, value); + } + } + } else if (std.mem.eql(u8, branch_name, "inspect")) { + const child_id = branch.object.get("id") orelse return null; + const sections = branch.object.get("sections") orelse return null; + const wait = branch.object.get("wait") orelse return null; + if (child_id != .string or sections != .array or + sections.array.items.len != 1 or sections.array.items[0] != .string or + !std.mem.eql(u8, sections.array.items[0].string, "status") or + wait != .object or branch.object.get("cursor") != null) + { + return null; + } + const until = wait.object.get("until") orelse return null; + if (until != .string or !std.mem.eql(u8, until.string, "settled")) return null; + try request.object.put(arena, "action", .{ .string = "wait" }); + try request.object.put(arena, "child_id", child_id); + } else if (std.mem.eql(u8, branch_name, "message")) { + if (branch.object.count() != 1) return null; + const send = branch.object.get("send") orelse return null; + if (send != .object) return null; + const child_id = send.object.get("id") orelse return null; + const message = send.object.get("content") orelse return null; + if (child_id != .string or message != .string) return null; + try request.object.put(arena, "action", .{ .string = "send" }); + try request.object.put(arena, "child_id", child_id); + try request.object.put(arena, "message", message); + } else if (std.mem.eql(u8, branch_name, "lifecycle")) { + const child_id = branch.object.get("id") orelse return null; + const action = branch.object.get("action") orelse return null; + if (child_id != .string or action != .string or + !std.mem.eql(u8, action.string, "cancel")) + { + return null; + } + try request.object.put(arena, "action", .{ .string = "stop" }); + try request.object.put(arena, "child_id", child_id); + } else { + return null; + } + + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + std.json.Stringify.value(.{ .request = request }, .{}, &out.writer) catch + return error.OutOfMemory; + return try out.toOwnedSlice(); +} + +fn project_subagent_result_content( + alloc: Allocator, + content: []const u8, +) Allocator.Error!?[]u8 { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, content, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; + defer parsed.deinit(); + if (parsed.value != .object) return null; + const object = parsed.value.object; + if (object.count() == 5 and object.get("operation_id") == null and + object.get("requested") == null and object.get("cursor") == null) + { + return null; + } + const ok = object.get("ok") orelse return null; + const child_id = object.get("child_id") orelse return null; + const status = object.get("status") orelse return null; + const error_code = object.get("error_code") orelse return null; + const retryable = object.get("retryable") orelse return null; + if (ok != .bool or (child_id != .null and child_id != .string) or + status != .string or (error_code != .null and error_code != .string) or + retryable != .bool) + { + return null; + } + + const arena = parsed.arena.allocator(); + const model_child_id = if (child_id == .string) + std.json.Value{ .string = try subagent_model_contract.modelChildIdAlloc( + arena, + child_id.string, + ) } + else + child_id; + var compact = std.json.Value{ .object = .empty }; + try compact.object.put(arena, "ok", ok); + try compact.object.put(arena, "child_id", model_child_id); + try compact.object.put(arena, "status", status); + try compact.object.put(arena, "error_code", error_code); + try compact.object.put(arena, "retryable", retryable); + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + std.json.Stringify.value(compact, .{}, &out.writer) catch return error.OutOfMemory; + return try out.toOwnedSlice(); +} + +fn subagent_history_summary( + alloc: Allocator, + action: []const u8, + content: ?[]const u8, +) Allocator.Error![]u8 { + const bounded = text_utils.utf8PrefixByBytes(content orelse "", 4096); + return if (bounded.len == 0) + std.fmt.allocPrint(alloc, "[Prior subagent {s} action completed.]", .{action}) + else + std.fmt.allocPrint( + alloc, + "[Prior subagent {s} action completed. Stored result follows.]\n{s}", + .{ action, bounded }, + ); +} + +fn project_subagent_request_messages( + alloc: Allocator, + registry: tool_dispatch.Registry, + attempt_eligible: bool, + source: []const ChatMessage, +) Allocator.Error![]const ChatMessage { + if (!attempt_eligible or registry.lookup("subagent") == null) return source; + + var calls: std.ArrayList(SubagentHistoryCall) = .empty; + defer calls.deinit(alloc); + var needs_projection = false; + for (source) |message| { + if (message.role != .assistant) continue; + for (message.tool_calls) |call| { + if (!std.mem.eql(u8, call.name, "subagent")) continue; + if (call.argument_integrity != .valid) { + try calls.append(alloc, .{ + .id = call.id, + .action = "malformed", + .disposition = .inert, + }); + needs_projection = true; + continue; + } + if (legacy_subagent_action(call.arguments_json)) |action| { + const projected = try project_legacy_subagent_arguments( + alloc, + call.arguments_json, + ); + if (projected) |arguments| alloc.free(arguments); + try calls.append(alloc, .{ + .id = call.id, + .action = action, + .disposition = if (projected != null) .mapped else .inert, + }); + needs_projection = true; + continue; + } + try calls.append(alloc, .{ + .id = call.id, + .action = "managed", + .disposition = .current, + }); + if (try normalized_subagent_request_arguments( + alloc, + call.arguments_json, + )) |arguments| { + alloc.free(arguments); + needs_projection = true; + } + } + } + for (source) |message| { + if (message.role != .tool or message.tool_call_id == null) continue; + const call = find_subagent_history_call(calls.items, message.tool_call_id.?) orelse continue; + if (call.disposition == .inert) { + needs_projection = true; + continue; + } + if (message.content) |content| { + if (try project_subagent_result_content(alloc, content)) |projected| { + alloc.free(projected); + needs_projection = true; + } + } + } + if (!needs_projection) return source; + + const projected = try alloc.alloc(ChatMessage, source.len); + var initialized: usize = 0; + errdefer { + for (projected[0..initialized]) |message| { + if (message.content) |content| alloc.free(@constCast(content)); + for (message.tool_calls) |call| alloc.free(@constCast(call.arguments_json)); + if (message.tool_calls.len != 0) alloc.free(@constCast(message.tool_calls)); + } + alloc.free(projected); + } + for (source, projected) |message, *target| { + target.* = message; + target.content = if (message.content) |content| try alloc.dupe(u8, content) else null; + target.tool_calls = &.{}; + initialized += 1; + + if (message.role == .tool and message.tool_call_id != null) { + if (find_subagent_history_call(calls.items, message.tool_call_id.?)) |call| { + if (call.disposition == .inert) { + if (target.content) |content| alloc.free(@constCast(content)); + target.role = .assistant; + target.content = try subagent_history_summary( + alloc, + call.action, + message.content, + ); + target.tool_call_id = null; + target.tool_name = null; + } else if (message.content) |content| { + if (try project_subagent_result_content(alloc, content)) |compact| { + if (target.content) |owned| alloc.free(@constCast(owned)); + target.content = compact; + } + } + } + } + + if (message.tool_calls.len != 0) { + var projected_calls: std.ArrayList(ToolCall) = .empty; + errdefer { + for (projected_calls.items) |call| alloc.free(@constCast(call.arguments_json)); + projected_calls.deinit(alloc); + } + for (message.tool_calls) |call| { + const history_call = if (std.mem.eql(u8, call.name, "subagent")) + find_subagent_history_call(calls.items, call.id) + else + null; + if (history_call) |known| { + if (known.disposition == .inert) continue; + const arguments = if (known.disposition == .mapped) + (try project_legacy_subagent_arguments(alloc, call.arguments_json)).? + else + (try normalized_subagent_request_arguments( + alloc, + call.arguments_json, + )) orelse try alloc.dupe(u8, call.arguments_json); + var copied = call; + copied.arguments_json = arguments; + projected_calls.append(alloc, copied) catch |err| { + alloc.free(arguments); + return err; + }; + continue; + } + var copied = call; + copied.arguments_json = try alloc.dupe(u8, call.arguments_json); + projected_calls.append(alloc, copied) catch |err| { + alloc.free(@constCast(copied.arguments_json)); + return err; + }; + } + target.tool_calls = try projected_calls.toOwnedSlice(alloc); + } + if (message.role == .assistant and message.tool_calls.len != 0 and + target.tool_calls.len == 0 and target.content == null) + { + target.content = try alloc.dupe( + u8, + "Prior subagent manager actions are represented as completed history summaries below.", + ); + } + } + return projected; +} + +test "subagent history maps representable manager calls and makes removed actions inert" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const tool = tool_dispatch.Tool{ + .name = "subagent", + .description = "subagent", + .model_schema = .{ .name = "subagent", .description = "subagent" }, + .executor_kind = .subagent, + .decode = undefined, + .call = undefined, + .reads_only_fn = undefined, + .irreversible_fn = undefined, + }; + const registry = tool_dispatch.Registry{ .tools = &.{tool} }; + const calls = [_]ToolCall{ + .{ + .id = "legacy-create", + .name = "subagent", + .arguments_json = "{\"command\":{\"create\":{\"name\":\"worker\",\"mode\":\"persistent\",\"prompt\":\"do it\"}}}", + }, + .{ + .id = "legacy-configure", + .name = "subagent", + .arguments_json = "{\"command\":{\"configure\":{\"id\":\"child-1\",\"name\":\"renamed\"}}}", + }, + .{ + .id = "current-run", + .name = "subagent", + .arguments_json = "{\"request\":{\"action\":\"run\",\"task\":\"current\"}}", + }, + }; + const stored_result = + "{\"ok\":true,\"operation_id\":\"fxop:2:m:1:0000000000000000000000000000000000000000000000000000000000000000\",\"child_id\":\"1788212822437-1788212822437350000-0924a40611358d88\",\"status\":\"idle\",\"error_code\":null,\"retryable\":false}"; + const messages = [_]ChatMessage{ + .{ .role = .assistant, .tool_calls = &calls }, + .{ .role = .tool, .tool_call_id = "legacy-create", .tool_name = "subagent", .content = stored_result }, + .{ .role = .tool, .tool_call_id = "legacy-configure", .tool_name = "subagent", .content = "configured" }, + .{ .role = .tool, .tool_call_id = "current-run", .tool_name = "subagent", .content = stored_result }, + }; + + const projected = try project_subagent_request_messages( + arena, + registry, + true, + &messages, + ); + try std.testing.expect(projected.ptr != messages[0..].ptr); + try std.testing.expectEqual(@as(usize, 2), projected[0].tool_calls.len); + try std.testing.expectEqualStrings( + "{\"request\":{\"action\":\"run\",\"task\":\"do it\"}}", + projected[0].tool_calls[0].arguments_json, + ); + try std.testing.expectEqualStrings(calls[2].arguments_json, projected[0].tool_calls[1].arguments_json); + try std.testing.expect(std.mem.find(u8, projected[1].content.?, "operation_id") == null); + try std.testing.expect(std.mem.find( + u8, + projected[1].content.?, + "1788212822437-350000-0924a40611358d88", + ) != null); + try std.testing.expectEqual(types.ChatRole.assistant, projected[2].role); + try std.testing.expect(std.mem.find( + u8, + projected[2].content.?, + "Prior subagent configure action completed", + ) != null); + try std.testing.expect(std.mem.find(u8, projected[3].content.?, "operation_id") == null); + try std.testing.expectEqualStrings(calls[0].arguments_json, messages[0].tool_calls[0].arguments_json); + + const idempotent = try project_subagent_request_messages( + arena, + registry, + true, + projected, + ); + try std.testing.expectEqual(projected.ptr, idempotent.ptr); + const ineligible = try project_subagent_request_messages( + arena, + registry, + false, + &messages, + ); + try std.testing.expectEqual(messages[0..].ptr, ineligible.ptr); +} + +fn check_subagent_history_projection_allocation_failures(alloc: Allocator) !void { + const tool = tool_dispatch.Tool{ + .name = "subagent", + .description = "subagent", + .model_schema = .{ .name = "subagent", .description = "subagent" }, + .executor_kind = .subagent, + .decode = undefined, + .call = undefined, + .reads_only_fn = undefined, + .irreversible_fn = undefined, + }; + const registry = tool_dispatch.Registry{ .tools = &.{tool} }; + const calls = [_]ToolCall{.{ + .id = "legacy", + .name = "subagent", + .arguments_json = "{\"command\":{\"message\":{\"send\":{\"id\":\"child-1\",\"content\":\"next\"}}}}", + }}; + const messages = [_]ChatMessage{ + .{ .role = .assistant, .tool_calls = &calls }, + .{ + .role = .tool, + .tool_call_id = "legacy", + .tool_name = "subagent", + .content = "{\"ok\":true,\"operation_id\":\"internal\",\"child_id\":\"child-1\",\"status\":\"message_sent\",\"error_code\":null,\"retryable\":false}", + }, + }; + const projected = try project_subagent_request_messages( + alloc, + registry, + true, + &messages, + ); + if (projected.ptr == messages[0..].ptr) return error.TestUnexpectedResult; + defer free_terminal_request_projection(alloc, &messages, projected); +} + +test "subagent history projection cleans every partial allocation failure" { + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + check_subagent_history_projection_allocation_failures, + .{}, + ); +} + fn normalized_terminal_request_arguments( alloc: Allocator, arguments_json: []const u8, @@ -555,6 +1038,51 @@ fn normalized_terminal_request_arguments( return try out.toOwnedSlice(); } +fn managed_subagent_action(action: []const u8) ?[]const u8 { + if (std.mem.eql(u8, action, "cancel")) return "stop"; + for ([_][]const u8{ "run", "wait", "send", "stop" }) |known| { + if (std.mem.eql(u8, action, known)) return known; + } + return null; +} + +fn normalized_subagent_request_arguments( + alloc: Allocator, + arguments_json: []const u8, +) Allocator.Error!?[]u8 { + var parsed = std.json.parseFromSlice(std.json.Value, alloc, arguments_json, .{}) catch |err| switch (err) { + error.OutOfMemory => return error.OutOfMemory, + else => return null, + }; + defer parsed.deinit(); + if (parsed.value != .object) return null; + const arena = parsed.arena.allocator(); + + if (parsed.value.object.getPtr("request")) |request| { + if (parsed.value.object.count() != 1 or request.* != .object) return null; + const action = request.object.getPtr("action") orelse return null; + if (action.* != .string) return null; + const canonical = managed_subagent_action(action.string) orelse return null; + if (std.mem.eql(u8, canonical, action.string)) return null; + action.* = .{ .string = canonical }; + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + std.json.Stringify.value(parsed.value, .{}, &out.writer) catch return error.OutOfMemory; + return try out.toOwnedSlice(); + } + + const action = parsed.value.object.getPtr("action") orelse return null; + if (action.* != .string) return null; + const canonical = managed_subagent_action(action.string) orelse return null; + action.* = .{ .string = canonical }; + var out: std.Io.Writer.Allocating = .init(alloc); + defer out.deinit(); + std.json.Stringify.value(.{ .request = parsed.value }, .{}, &out.writer) catch + return error.OutOfMemory; + _ = arena; + return try out.toOwnedSlice(); +} + fn agentShellWriteLeaseSessionId( alloc: Allocator, registry: tool_dispatch.Registry, @@ -653,6 +1181,106 @@ fn normalize_terminal_request_tool_calls( return normalized orelse source; } +fn normalize_subagent_request_tool_calls( + alloc: Allocator, + registry: tool_dispatch.Registry, + attempt_eligible: bool, + source: []const ToolCall, +) Allocator.Error![]const ToolCall { + if (!attempt_eligible) return source; + + var normalized: ?[]ToolCall = null; + errdefer if (normalized) |calls| { + for (calls, source) |call, original| { + if (call.arguments_json.ptr != original.arguments_json.ptr) { + alloc.free(@constCast(call.arguments_json)); + } + } + alloc.free(calls); + }; + + for (source, 0..) |call, index| { + if (call.argument_integrity != .valid) continue; + const tool = registry.lookup(call.name) orelse continue; + if (tool.executor_kind != .subagent) continue; + const arguments_json = try normalized_subagent_request_arguments( + alloc, + call.arguments_json, + ) orelse continue; + if (normalized == null) { + normalized = alloc.dupe(ToolCall, source) catch |err| { + alloc.free(arguments_json); + return err; + }; + } + normalized.?[index].arguments_json = arguments_json; + } + return normalized orelse source; +} + +test "subagent request normalization follows effective attempt advertisement" { + var arena_state = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + const nested = tool_dispatch.Tool{ + .name = "subagent", + .description = "subagent", + .model_schema = .{ + .name = "subagent", + .description = "subagent", + .input_schema = .{ + .properties = &.{.{ + .name = "request", + .json_type = .object, + .shape = &.{ .object = &.{ .one_of = &.{.{}} } }, + }}, + .required = &.{"request"}, + .additional_properties = false, + }, + }, + .executor_kind = .subagent, + .decode = undefined, + .call = undefined, + .reads_only_fn = undefined, + .irreversible_fn = undefined, + }; + const registry = tool_dispatch.Registry{ .tools = &.{nested} }; + const calls = [_]ToolCall{ + .{ .id = "flat", .name = "subagent", .arguments_json = "{\"action\":\"wait\",\"child_id\":\"child-1\"}" }, + .{ .id = "cancel", .name = "subagent", .arguments_json = "{\"request\":{\"action\":\"cancel\",\"child_id\":\"child-2\"}}" }, + .{ .id = "canonical", .name = "subagent", .arguments_json = "{\"request\":{\"action\":\"stop\",\"child_id\":\"child-3\"}}" }, + .{ .id = "legacy", .name = "subagent", .arguments_json = "{\"command\":{\"lifecycle\":{\"id\":\"child-4\",\"action\":\"cancel\"}}}" }, + }; + + try std.testing.expect(subagent_request_schema_advertised(&.{nested.model_schema})); + try std.testing.expect(subagent_request_normalization_eligible(true, .optional)); + try std.testing.expect(!subagent_request_normalization_eligible(true, .required)); + const normalized = try normalize_subagent_request_tool_calls( + arena, + registry, + true, + &calls, + ); + try std.testing.expect(normalized.ptr != calls[0..].ptr); + try std.testing.expectEqualStrings( + "{\"request\":{\"action\":\"wait\",\"child_id\":\"child-1\"}}", + normalized[0].arguments_json, + ); + try std.testing.expectEqualStrings( + "{\"request\":{\"action\":\"stop\",\"child_id\":\"child-2\"}}", + normalized[1].arguments_json, + ); + try std.testing.expectEqual(calls[2].arguments_json.ptr, normalized[2].arguments_json.ptr); + try std.testing.expectEqual(calls[3].arguments_json.ptr, normalized[3].arguments_json.ptr); + const ineligible = try normalize_subagent_request_tool_calls( + arena, + registry, + false, + &calls, + ); + try std.testing.expectEqual(calls[0..].ptr, ineligible.ptr); +} + test "shell request normalization follows effective attempt advertisement" { const nested = tool_dispatch.Tool{ .name = "shell", @@ -3083,6 +3711,9 @@ fn processQueuedPromptInner( const base_nested_terminal_advertised = terminal_request_schema_advertised( config.advertised_functions, ); + const base_nested_subagent_advertised = subagent_request_schema_advertised( + config.advertised_functions, + ); var stable_prefix: std.ArrayList(ChatMessage) = .empty; defer stable_prefix.deinit(arena); @@ -3217,6 +3848,7 @@ fn processQueuedPromptInner( job, request_capabilities, base_nested_terminal_advertised, + base_nested_subagent_advertised, finalization, arena, turn_id, @@ -3474,6 +4106,7 @@ fn processQueuedPromptLoop( job: QueuedPrompt, request_capabilities: model_capabilities.Capabilities, base_nested_terminal_advertised: bool, + base_nested_subagent_advertised: bool, finalization: *TurnFinalizationGuard, arena: Allocator, turn_id: u64, @@ -3920,12 +4553,22 @@ fn processQueuedPromptLoop( base_nested_terminal_advertised, vision_mode, ); - const request_messages = try project_terminal_request_messages( + const subagent_request_eligible = subagent_request_normalization_eligible( + base_nested_subagent_advertised, + vision_mode, + ); + const terminal_request_messages = try project_terminal_request_messages( overlay_arena, deps.tool_registry, terminal_request_eligible, projected_request_messages, ); + const request_messages = try project_subagent_request_messages( + overlay_arena, + deps.tool_registry, + subagent_request_eligible, + terminal_request_messages, + ); last_gateway_message_count = request_messages.len; const provider_opts = model_capabilities.resolveProviderOptionsForCapabilities(request_capabilities, config.effort, route_fast_mode); runtime_telemetry.traceGatewayProviderOptions(step_ctx, gateway_model, route_fast_mode, config.effort, provider_opts); @@ -4425,6 +5068,12 @@ fn processQueuedPromptLoop( terminal_request_eligible, completion.tool_calls, ); + completion.tool_calls = try normalize_subagent_request_tool_calls( + arena, + deps.tool_registry, + subagent_request_eligible, + completion.tool_calls, + ); } if (recovery_strategy == .reconcile_tool and streamSucceeded(stream_result) and diff --git a/src/core/subagent/communication_manager.zig b/src/core/subagent/communication_manager.zig index 09a82ad04..a24761084 100644 --- a/src/core/subagent/communication_manager.zig +++ b/src/core/subagent/communication_manager.zig @@ -861,7 +861,7 @@ pub const FinalResultInput = struct { content: []const u8, }; -/// Appends the mandatory one-off result through the existing message ledger. +/// Appends the mandatory child-turn result through the existing message ledger. /// The stable ID makes normal completion, restart recovery, and retries /// idempotent. The caller retains ownership of every input slice. pub fn reconcileFinalResultLocked( diff --git a/src/core/subagent/execution.zig b/src/core/subagent/execution.zig index 9615d613b..45eb95122 100644 --- a/src/core/subagent/execution.zig +++ b/src/core/subagent/execution.zig @@ -2307,7 +2307,7 @@ pub const Owner = struct { "terminal reconciliation deferred child_id={s} outcome={s}", .{ child_id, @errorName(err) }, ); - _ = reconcileOneOffFinalResultLocked( + _ = reconcileFinalResultLocked( self.alloc, communication_state, record, @@ -2430,7 +2430,7 @@ pub const Owner = struct { communication_state, record, ) catch return error.ControlStoreFailed; - _ = reconcileOneOffFinalResultLocked( + _ = reconcileFinalResultLocked( self.alloc, communication_state, record, @@ -2671,7 +2671,7 @@ pub const Owner = struct { .capability = &communication_capability, .expected_session_id = child_id, }; - _ = reconcileOneOffFinalResultLocked( + _ = reconcileFinalResultLocked( self.alloc, communication_state, record, @@ -3209,7 +3209,7 @@ fn runOne(slot: *Slot) OneResult { communication_state, record, ) catch return .control_failed; - _ = reconcileOneOffFinalResultLocked( + _ = reconcileFinalResultLocked( owner.alloc, communication_state, record, @@ -3357,7 +3357,7 @@ fn runOne(slot: *Slot) OneResult { owner.wakeNotificationSchedules(slot.child_id, completed_at_ms); return .control_failed; }; - _ = reconcileOneOffFinalResultLocked( + _ = reconcileFinalResultLocked( owner.alloc, communication_state, current, @@ -3525,37 +3525,44 @@ fn boundedFinalResultAlloc( return std.fmt.allocPrint(alloc, "{s}{s}", .{ prefix, suffix }); } -fn oneOffFinalResultAlloc( +fn finalResultAlloc( alloc: Allocator, + mode: domain.Mode, work: domain.QueuedMessage, transition: TerminalTransition, history: []const types.HistoryTurn, ) (Allocator.Error || error{InvalidRecord})![]u8 { - const fallback_completed = "One-off subagent completed without a final text response."; + const subject = if (mode == .one_off) "One-off subagent" else "Subagent"; var formatted: ?[]u8 = null; defer if (formatted) |value| alloc.free(value); const raw = switch (work.status) { .completed => blk: { const assistant = assistantTextForWork(history, work.id) orelse - fallback_completed; + ""; break :blk if (assistant.len != 0 and text_utils.isModelSafeText(assistant)) assistant - else - fallback_completed; + else fallback: { + formatted = try std.fmt.allocPrint( + alloc, + "{s} completed without a final text response.", + .{subject}, + ); + break :fallback formatted.?; + }; }, .failed => blk: { formatted = try std.fmt.allocPrint( alloc, - "One-off subagent failed: {s}", - .{transition.reason orelse "unknown failure"}, + "{s} failed: {s}", + .{ subject, transition.reason orelse "unknown failure" }, ); break :blk formatted.?; }, .cancelled => blk: { formatted = try std.fmt.allocPrint( alloc, - "One-off subagent cancelled: {s}", - .{work.cancellation_reason orelse transition.reason orelse "cancelled"}, + "{s} cancelled: {s}", + .{ subject, work.cancellation_reason orelse transition.reason orelse "cancelled" }, ); break :blk formatted.?; }, @@ -3564,13 +3571,13 @@ fn oneOffFinalResultAlloc( return boundedFinalResultAlloc(alloc, raw); } -fn reconcileOneOffFinalResultLocked( +fn reconcileFinalResultLocked( alloc: Allocator, store: communication_store.Store, record: control_store.Record, history: []const types.HistoryTurn, ) communication_manager.Error!bool { - if (record.mode != .one_off) return false; + if (!shouldReconcileFinalResult(record)) return false; var index = record.queue.len; while (index > 0) { index -= 1; @@ -3585,8 +3592,9 @@ fn reconcileOneOffFinalResultLocked( work.id, work.status, ) orelse return error.InvalidRecord; - const content = oneOffFinalResultAlloc( + const content = finalResultAlloc( alloc, + record.mode, work, transition, history, @@ -3606,6 +3614,16 @@ fn reconcileOneOffFinalResultLocked( return false; } +fn shouldReconcileFinalResult(record: control_store.Record) bool { + if (record.mode == .one_off) return true; + for (record.operations) |operation| { + if (operation.code == .created and operation.identity_source == .model) { + return true; + } + } + return false; +} + fn findDeliveryById( deliveries: []const communication.Delivery, id: []const u8, @@ -3659,7 +3677,7 @@ test "one off terminal results and retry classification stay bounded" { .status = .failed, .created_at_ms = 1, }; - const failed_result = try oneOffFinalResultAlloc(alloc, failed, transition, &.{}); + const failed_result = try finalResultAlloc(alloc, .one_off, failed, transition, &.{}); defer alloc.free(failed_result); try std.testing.expectEqualStrings( "One-off subagent failed: provider_failed", @@ -3674,8 +3692,9 @@ test "one off terminal results and retry classification stay bounded" { .cancellation_reason = @constCast("user cancelled"), .created_at_ms = 1, }; - const cancelled_result = try oneOffFinalResultAlloc( + const cancelled_result = try finalResultAlloc( alloc, + .one_off, cancelled, .{ .timestamp_ms = 2, .reason = "user cancelled" }, &.{}, @@ -3700,8 +3719,9 @@ test "one off terminal results and retry classification stay bounded" { .status = .completed, .created_at_ms = 1, }; - const completed_result = try oneOffFinalResultAlloc( + const completed_result = try finalResultAlloc( alloc, + .one_off, completed, .{ .timestamp_ms = 2, .reason = null }, &history, @@ -8585,6 +8605,33 @@ test "completed one off reconciles one stable final result message" { )) == null); } +test "final result delivery is mandatory for one off and model-created persistent children" { + var record = try testRecord(std.testing.allocator, .persistent, &.{}); + defer record.deinit(std.testing.allocator); + try std.testing.expect(!shouldReconcileFinalResult(record)); + + const replacement = try std.testing.allocator.alloc(domain.OperationReceipt, 1); + std.testing.allocator.free(record.operations); + record.operations = replacement; + record.operations[0] = .{ + .id = try std.testing.allocator.dupe(u8, "fxop:2:m:1:0000000000000000000000000000000000000000000000000000000000000000"), + .request_fingerprint = [_]u8{0} ** 32, + .fingerprint = [_]u8{0} ** 32, + .code = .created, + .target_id = try std.testing.allocator.dupe(u8, "persistent-child"), + .generation = 1, + .event_sequence = 1, + .identity_source = .model, + .identity_epoch = 1, + }; + try std.testing.expect(shouldReconcileFinalResult(record)); + + record.operations[0].identity_source = .human; + try std.testing.expect(!shouldReconcileFinalResult(record)); + record.mode = .one_off; + try std.testing.expect(shouldReconcileFinalResult(record)); +} + fn checkAdmissionAllocationFailures(alloc: Allocator) !void { var snapshot = try domain.captureAdmission(alloc, .{ .parent_id = "parent", diff --git a/src/core/subagent/model_contract.zig b/src/core/subagent/model_contract.zig new file mode 100644 index 000000000..0b7c30043 --- /dev/null +++ b/src/core/subagent/model_contract.zig @@ -0,0 +1,433 @@ +const std = @import("std"); +const domain = @import("domain.zig"); +const text_utils = @import("../shared/text_utils.zig"); +const types = @import("../shared/types.zig"); + +const Allocator = std.mem.Allocator; + +pub const initial_observe_ms: u64 = 1_000; +const wait_ms: u64 = 30_000; +const max_error_code_bytes: usize = 64; + +pub const Action = enum { + run, + wait, + send, + stop, +}; + +pub const RunInput = struct { + task: []const u8, + model: ?[]const u8 = null, + effort: ?types.ReasoningEffort = null, +}; + +pub const ChildInput = struct { + child_id: []const u8, +}; + +pub const SendInput = struct { + child_id: []const u8, + message: []const u8, +}; + +pub const RequestInput = union(Action) { + run: RunInput, + wait: ChildInput, + send: SendInput, + stop: ChildInput, +}; + +pub const Request = union(Action) { + run: struct { + task: []u8, + model: ?[]u8, + effort: ?types.ReasoningEffort, + }, + wait: struct { child_id: []u8 }, + send: struct { + child_id: []u8, + message: []u8, + }, + stop: struct { child_id: []u8 }, + + pub fn deinit(self: *Request, alloc: Allocator) void { + switch (self.*) { + .run => |value| { + alloc.free(value.task); + if (value.model) |model| alloc.free(model); + }, + .wait => |value| alloc.free(value.child_id), + .send => |value| { + alloc.free(value.child_id); + alloc.free(value.message); + }, + .stop => |value| alloc.free(value.child_id), + } + self.* = undefined; + } + + pub fn action(self: Request) Action { + return std.meta.activeTag(self); + } + + pub fn childId(self: Request) ?[]const u8 { + return switch (self) { + .run => null, + .wait => |value| value.child_id, + .send => |value| value.child_id, + .stop => |value| value.child_id, + }; + } + + /// Returns an owned internal command. The caller frees it with + /// `domain.Command.deinit`. + pub fn toDomainCommand(self: Request, alloc: Allocator) domain.ValidationError!domain.Command { + var name_buffer: [domain.max_name_bytes]u8 = undefined; + return domain.validateCommand(alloc, switch (self) { + .run => |value| .{ .create = .{ + .name = generatedName(value.task, &name_buffer), + .mode = .persistent, + .prompt = value.task, + .model = value.model, + .effort = value.effort, + } }, + .wait => |value| .{ .inspect = .{ + .id = value.child_id, + .sections = &.{.status}, + .wait = .{ + .until = .settled, + .timeout_ms = wait_ms, + }, + } }, + .send => |value| .{ .message = .{ .send = .{ + .id = value.child_id, + .content = value.message, + } } }, + .stop => |value| .{ .lifecycle = .{ + .id = value.child_id, + .action = .cancel, + } }, + }); + } +}; + +fn generatedName( + task: []const u8, + buffer: *[domain.max_name_bytes]u8, +) []const u8 { + const first_line = if (std.mem.indexOfScalar(u8, task, '\n')) |index| + task[0..index] + else + task; + const trimmed = std.mem.trim(u8, first_line, " \t\r"); + if (trimmed.len == 0) return "delegate"; + const prefix = text_utils.utf8PrefixByBytes(trimmed, buffer.len); + @memcpy(buffer[0..prefix.len], prefix); + for (buffer[0..prefix.len]) |*byte| { + if (byte.* < 0x20 or byte.* == 0x7f) byte.* = ' '; + } + const generated = std.mem.trimEnd(u8, buffer[0..prefix.len], " \t\r"); + return if (generated.len == 0) "delegate" else generated; +} + +pub const ValidationError = error{ + OutOfMemory, + InvalidTask, + InvalidModel, + InvalidChildId, + InvalidMessage, +}; + +/// Validates and owns one model-facing request. +pub fn validateRequest( + alloc: Allocator, + input: RequestInput, +) ValidationError!Request { + return switch (input) { + .run => |value| blk: { + try validateText(value.task, domain.max_prompt_bytes, error.InvalidTask); + if (value.model) |model| { + try validateText(model, domain.max_model_bytes, error.InvalidModel); + } + const task = try alloc.dupe(u8, value.task); + errdefer alloc.free(task); + const model = if (value.model) |model| + try alloc.dupe(u8, model) + else + null; + break :blk .{ .run = .{ + .task = task, + .model = model, + .effort = value.effort, + } }; + }, + .wait => |value| .{ .wait = .{ + .child_id = try validateChildIdAlloc(alloc, value.child_id), + } }, + .send => |value| blk: { + try validateText(value.message, domain.max_message_bytes, error.InvalidMessage); + const child_id = try validateChildIdAlloc(alloc, value.child_id); + errdefer alloc.free(child_id); + break :blk .{ .send = .{ + .child_id = child_id, + .message = try alloc.dupe(u8, value.message), + } }; + }, + .stop => |value| .{ .stop = .{ + .child_id = try validateChildIdAlloc(alloc, value.child_id), + } }, + }; +} + +fn validateText( + value: []const u8, + max_bytes: usize, + invalid: ValidationError, +) ValidationError!void { + if (value.len == 0 or value.len > max_bytes or + !std.unicode.utf8ValidateSlice(value) or std.mem.findScalar(u8, value, 0) != null) + { + return invalid; + } +} + +fn validateChildIdAlloc(alloc: Allocator, value: []const u8) ValidationError![]u8 { + domain.validateId(value) catch return error.InvalidChildId; + var segments = std.mem.splitScalar(u8, value, '-'); + const millis = segments.next() orelse return alloc.dupe(u8, value); + const nanos_suffix = segments.next() orelse return alloc.dupe(u8, value); + const random = segments.next() orelse return alloc.dupe(u8, value); + if (segments.next() != null or nanos_suffix.len != 6 or + !asciiDigits(millis) or !asciiDigits(nanos_suffix) or + !lowerHex(random, 16)) + { + return alloc.dupe(u8, value); + } + const canonical = try std.fmt.allocPrint( + alloc, + "{s}-{s}{s}-{s}", + .{ millis, millis, nanos_suffix, random }, + ); + domain.validateId(canonical) catch { + alloc.free(canonical); + return error.InvalidChildId; + }; + return canonical; +} + +pub fn modelChildIdAlloc(alloc: Allocator, value: []const u8) Allocator.Error![]u8 { + var segments = std.mem.splitScalar(u8, value, '-'); + const millis = segments.next() orelse return alloc.dupe(u8, value); + const nanos = segments.next() orelse return alloc.dupe(u8, value); + const random = segments.next() orelse return alloc.dupe(u8, value); + if (segments.next() != null or nanos.len != millis.len + 6 or + !asciiDigits(millis) or !asciiDigits(nanos) or + !lowerHex(random, 16) or !std.mem.startsWith(u8, nanos, millis)) + { + return alloc.dupe(u8, value); + } + return std.fmt.allocPrint( + alloc, + "{s}-{s}-{s}", + .{ millis, nanos[millis.len..], random }, + ); +} + +fn asciiDigits(value: []const u8) bool { + if (value.len == 0) return false; + for (value) |byte| if (!std.ascii.isDigit(byte)) return false; + return true; +} + +fn lowerHex(value: []const u8, expected_len: usize) bool { + if (value.len != expected_len) return false; + for (value) |byte| { + if (!std.ascii.isDigit(byte) and (byte < 'a' or byte > 'f')) return false; + } + return true; +} + +pub const Snapshot = struct { + mode: domain.Mode, + state: domain.State, +}; + +pub const RejectCode = enum { + child_unavailable, + child_not_messageable, +}; + +pub const Plan = union(enum) { + create_and_observe, + inspect_wait, + send, + cancel, + no_op, + reject: RejectCode, +}; + +/// Purely selects the effect to perform from a validated request and an +/// optional authoritative child snapshot. +pub fn plan(request: Request, snapshot: ?Snapshot) Plan { + return switch (request) { + .run => .create_and_observe, + .wait => .inspect_wait, + .send => if (snapshot) |child| + if (child.mode == .persistent and switch (child.state) { + .idle, .queued, .running, .awaiting_approval => true, + .interrupted, .completed, .failed, .cancelled, .archived => false, + }) + .send + else + .{ .reject = .child_not_messageable } + else + .{ .reject = .child_unavailable }, + .stop => if (snapshot) |child| switch (child.state) { + .queued, .running, .awaiting_approval, .interrupted => .cancel, + .idle, .completed, .failed, .cancelled, .archived => .no_op, + } else .{ .reject = .child_unavailable }, + }; +} + +pub const Result = struct { + ok: bool, + operation_id: ?[]const u8 = null, + child_id: ?[]const u8, + status: []const u8, + error_code: ?[]const u8 = null, + retryable: bool = false, +}; + +pub fn encodeResultAlloc(alloc: Allocator, result: Result) ![]u8 { + var out: std.Io.Writer.Allocating = .init(alloc); + errdefer out.deinit(); + try out.writer.print("{{\"ok\":{s},\"operation_id\":", .{if (result.ok) "true" else "false"}); + try writeOptionalString(&out.writer, result.operation_id); + try out.writer.writeAll(",\"child_id\":"); + try writeOptionalString(&out.writer, result.child_id); + try out.writer.writeAll(",\"status\":"); + try std.json.Stringify.value(result.status, .{}, &out.writer); + try out.writer.writeAll(",\"error_code\":"); + try writeOptionalString( + &out.writer, + if (result.error_code) |code| code[0..@min(code.len, max_error_code_bytes)] else null, + ); + try out.writer.print(",\"retryable\":{s}}}", .{if (result.retryable) "true" else "false"}); + return out.toOwnedSlice(); +} + +fn writeOptionalString(writer: *std.Io.Writer, value: ?[]const u8) !void { + if (value) |text| { + try std.json.Stringify.value(text, .{}, writer); + } else { + try writer.writeAll("null"); + } +} + +test "managed request validation owns input and maps to internal commands" { + const alloc = std.testing.allocator; + var request = try validateRequest(alloc, .{ .run = .{ + .task = "inspect the failure", + .model = "openai/gpt-5.6-sol", + .effort = .literal("high"), + } }); + defer request.deinit(alloc); + var command = try request.toDomainCommand(alloc); + defer command.deinit(alloc); + try std.testing.expect(command == .create); + try std.testing.expectEqual(domain.Mode.persistent, command.create.mode); + try std.testing.expect(!command.create.permission_mode_explicit); + try std.testing.expectEqualStrings("inspect the failure", command.create.prompt.?); + try std.testing.expectEqualStrings("inspect the failure", command.create.configuration.name); +} + +test "managed display names are deterministic bounded task summaries" { + var buffer: [domain.max_name_bytes]u8 = undefined; + try std.testing.expectEqualStrings( + "first line", + generatedName(" first line\nsecond line", &buffer), + ); + try std.testing.expectEqualStrings("delegate", generatedName(" \nnext", &buffer)); + try std.testing.expectEqualStrings("delegate", generatedName("\x01", &buffer)); +} + +test "managed planner covers every child state without hidden lifecycle effects" { + const alloc = std.testing.allocator; + var send = try validateRequest(alloc, .{ .send = .{ + .child_id = "01J00000000000000000000000", + .message = "continue", + } }); + defer send.deinit(alloc); + var stop = try validateRequest(alloc, .{ .stop = .{ + .child_id = "01J00000000000000000000000", + } }); + defer stop.deinit(alloc); + var wait = try validateRequest(alloc, .{ .wait = .{ + .child_id = "01J00000000000000000000000", + } }); + defer wait.deinit(alloc); + try std.testing.expect(plan(wait, null) == .inspect_wait); + + inline for (std.meta.tags(domain.State)) |state| { + const snapshot = Snapshot{ .mode = .persistent, .state = state }; + const send_plan = plan(send, snapshot); + const stop_plan = plan(stop, snapshot); + switch (state) { + .idle, .queued, .running, .awaiting_approval => try std.testing.expect(send_plan == .send), + .interrupted, .completed, .failed, .cancelled, .archived => try std.testing.expect(send_plan == .reject), + } + switch (state) { + .queued, .running, .awaiting_approval, .interrupted => try std.testing.expect(stop_plan == .cancel), + .idle, .completed, .failed, .cancelled, .archived => try std.testing.expect(stop_plan == .no_op), + } + } + try std.testing.expect(plan(send, .{ .mode = .one_off, .state = .running }) == .reject); +} + +test "managed result encoding is compact and explicit" { + const encoded = try encodeResultAlloc(std.testing.allocator, .{ + .ok = true, + .child_id = "child-1", + .status = "running", + }); + defer std.testing.allocator.free(encoded); + try std.testing.expectEqualStrings( + "{\"ok\":true,\"operation_id\":null,\"child_id\":\"child-1\",\"status\":\"running\",\"error_code\":null,\"retryable\":false}", + encoded, + ); +} + +fn checkValidationAllocationFailures(alloc: Allocator) !void { + var request = try validateRequest(alloc, .{ .send = .{ + .child_id = "1788212822437-350000-0924a40611358d88", + .message = "continue", + } }); + request.deinit(alloc); +} + +test "managed request validation cleans partial allocation failures" { + try std.testing.checkAllAllocationFailures( + std.testing.allocator, + checkValidationAllocationFailures, + .{}, + ); +} + +test "managed child IDs use one reversible model-facing representation" { + const alloc = std.testing.allocator; + const canonical = "1788212822437-1788212822437350000-0924a40611358d88"; + const compact = try modelChildIdAlloc(alloc, canonical); + defer alloc.free(compact); + try std.testing.expectEqualStrings( + "1788212822437-350000-0924a40611358d88", + compact, + ); + var request = try validateRequest(alloc, .{ .wait = .{ .child_id = compact } }); + defer request.deinit(alloc); + try std.testing.expectEqualStrings(canonical, request.wait.child_id); + + const unchanged = try modelChildIdAlloc(alloc, "child-1"); + defer alloc.free(unchanged); + try std.testing.expectEqualStrings("child-1", unchanged); +} diff --git a/src/core/subagent/tool_host.zig b/src/core/subagent/tool_host.zig index 96318b459..ebf4295c4 100644 --- a/src/core/subagent/tool_host.zig +++ b/src/core/subagent/tool_host.zig @@ -12,6 +12,7 @@ const control_store = @import("control_store.zig"); const domain = @import("domain.zig"); const execution = @import("execution.zig"); const manager_mod = @import("manager.zig"); +const model_contract = @import("model_contract.zig"); const tool_result = @import("tool_result.zig"); const debug_trace = @import("../shared/debug_trace.zig"); const io_mod = @import("../shared/io.zig"); @@ -146,6 +147,11 @@ pub const ExecuteOptions = struct { identity_epoch: u64 = 0, }; +pub const ManagedExecutionResult = struct { + success: bool, + body: []u8, +}; + pub const MessageSendOptions = struct { caller_id: []const u8, invocation_id: []const u8, @@ -215,6 +221,16 @@ const ModelCommandOutcome = union(enum) { } }; +const ModelInspectionOutcome = struct { + result: manager_mod.Result, + timed_out: bool = false, + + fn deinit(self: *ModelInspectionOutcome, alloc: Allocator) void { + self.result.deinit(alloc); + self.* = undefined; + } +}; + pub const Runtime = struct { alloc: Allocator, sessions: *session_store.Store, @@ -462,6 +478,264 @@ pub const Runtime = struct { }; } + pub fn executeManaged( + self: *Runtime, + alloc: Allocator, + request: *model_contract.Request, + options: ExecuteOptions, + ) !ManagedExecutionResult { + var command = try request.toDomainCommand(alloc); + defer command.deinit(alloc); + const identity_epoch = if (request.* == .wait) + 0 + else if (options.identity_epoch != 0) + options.identity_epoch + else + try self.issueOperationIdentity(alloc, options.invocation_id, .model); + const operation_id = if (identity_epoch == 0) + null + else + try tool_result.boundOperationIdAlloc( + alloc, + options.invocation_id, + .model, + identity_epoch, + ); + defer if (operation_id) |id| alloc.free(id); + + const snapshot = switch (request.*) { + .send, .stop => if (request.childId()) |child_id| + try self.managedChildSnapshot( + alloc, + options.caller_id, + child_id, + options.timestamp_ms, + ) + else + null, + .run, .wait => null, + }; + const effect = model_contract.plan(request.*, snapshot); + switch (effect) { + .reject => |code| { + if (operation_id) |id| try self.retireManagedIdentity(alloc, id); + return self.encodeManaged(alloc, .{ + .ok = false, + .operation_id = operation_id, + .child_id = request.childId(), + .status = "rejected", + .error_code = @tagName(code), + }); + }, + .no_op => { + if (operation_id) |id| try self.retireManagedIdentity(alloc, id); + return self.encodeManaged(alloc, .{ + .ok = true, + .operation_id = operation_id, + .child_id = request.childId(), + .status = @tagName(snapshot.?.state), + }); + }, + .inspect_wait => { + var observed = try self.inspectModelResult(alloc, command, options); + defer observed.deinit(alloc); + return self.encodeManagedInspection(alloc, observed, null); + }, + .create_and_observe, .send, .cancel => {}, + } + + if (effect == .create_and_observe and + !try self.callerMayCreate(alloc, options.caller_id)) + { + try self.retireManagedIdentity(alloc, operation_id.?); + return self.encodeManaged(alloc, .{ + .ok = false, + .operation_id = operation_id, + .child_id = null, + .status = "rejected", + .error_code = "invalid_state", + }); + } + + const identity_admitted = try self.operationIdentityOutstanding( + alloc, + operation_id.?, + ); + self.recoverIfNeeded(options.timestamp_ms); + + var outcome = try self.executeModelMutation( + alloc, + &command, + options, + operation_id.?, + identity_epoch, + identity_admitted, + ); + defer outcome.deinit(alloc); + self.finishModelOutcome(alloc, operation_id.?, &outcome); + + return switch (outcome) { + .adapter_failure => |failure| self.encodeManaged(alloc, .{ + .ok = false, + .operation_id = operation_id, + .child_id = failure.child_id, + .status = "rejected", + .error_code = failure.code, + .retryable = failure.retryable, + }), + .relationship_approval => unreachable, + .result => |result| switch (result) { + .failure => |failure| self.encodeManaged(alloc, .{ + .ok = false, + .operation_id = operation_id, + .child_id = request.childId(), + .status = "rejected", + .error_code = @tagName(failure.code), + .retryable = failure.retryable, + }), + .inspection => unreachable, + .receipt => |receipt| switch (effect) { + .create_and_observe => self.observeManagedCreate( + alloc, + receipt.target_id, + options, + operation_id.?, + ), + .send => self.encodeManaged(alloc, .{ + .ok = true, + .operation_id = operation_id, + .child_id = receipt.target_id, + .status = "message_sent", + }), + .cancel => self.encodeManaged(alloc, .{ + .ok = true, + .operation_id = operation_id, + .child_id = receipt.target_id, + .status = "stopped", + }), + .inspect_wait, .no_op, .reject => unreachable, + }, + }, + }; + } + + fn retireManagedIdentity( + self: *Runtime, + alloc: Allocator, + operation_id: []const u8, + ) !void { + if (!try self.operationIdentityOutstanding(alloc, operation_id)) return; + try self.completeOperationIdentity(operation_id); + } + + fn managedChildSnapshot( + self: *Runtime, + alloc: Allocator, + caller_id: []const u8, + child_id: []const u8, + timestamp_ms: i64, + ) !?model_contract.Snapshot { + self.recoverIfNeeded(timestamp_ms); + if (!std.mem.eql(u8, caller_id, self.root_id) and + !try self.isAttached(self.root_id, caller_id)) + { + return null; + } + var result = try self.manager.snapshot(alloc, .{ + .root_id = caller_id, + .anchor_id = child_id, + .limit = 1, + }); + defer result.deinit(alloc); + return switch (result) { + .failure => null, + .snapshot => |value| if (value.nodes.len == 1) + .{ + .mode = value.nodes[0].mode, + .state = value.nodes[0].state, + } + else + null, + }; + } + + fn observeManagedCreate( + self: *Runtime, + alloc: Allocator, + child_id: []const u8, + options: ExecuteOptions, + operation_id: []const u8, + ) !ManagedExecutionResult { + var command = try domain.validateCommand(alloc, .{ .inspect = .{ + .id = child_id, + .sections = &.{.status}, + .wait = .{ + .until = .settled, + .timeout_ms = model_contract.initial_observe_ms, + }, + } }); + defer command.deinit(alloc); + var observed = self.inspectModelResult(alloc, command, options) catch |err| { + if (err == error.OutOfMemory) return error.OutOfMemory; + return self.encodeManaged(alloc, .{ + .ok = true, + .operation_id = operation_id, + .child_id = child_id, + .status = "unknown", + .error_code = "observation_failed", + }); + }; + defer observed.deinit(alloc); + return switch (observed.result) { + .inspection => self.encodeManagedInspection(alloc, observed, operation_id), + .failure => self.encodeManaged(alloc, .{ + .ok = true, + .operation_id = operation_id, + .child_id = child_id, + .status = "unknown", + .error_code = "observation_failed", + }), + .receipt => unreachable, + }; + } + + fn encodeManagedInspection( + self: *Runtime, + alloc: Allocator, + observed: ModelInspectionOutcome, + operation_id: ?[]const u8, + ) !ManagedExecutionResult { + return switch (observed.result) { + .inspection => |inspection| self.encodeManaged(alloc, .{ + .ok = true, + .operation_id = operation_id, + .child_id = inspection.child_id, + .status = if (inspection.status) |state| @tagName(state) else "unknown", + }), + .failure => |failure| self.encodeManaged(alloc, .{ + .ok = false, + .operation_id = operation_id, + .child_id = null, + .status = "rejected", + .error_code = @tagName(failure.code), + .retryable = failure.retryable, + }), + .receipt => unreachable, + }; + } + + fn encodeManaged( + self: *Runtime, + alloc: Allocator, + result: model_contract.Result, + ) !ManagedExecutionResult { + _ = self; + return .{ + .success = result.ok, + .body = try model_contract.encodeResultAlloc(alloc, result), + }; + } + fn executeModelInspection( self: *Runtime, alloc: Allocator, @@ -476,6 +750,24 @@ pub const Runtime = struct { 0, ); defer alloc.free(operation_id); + var observed = try self.inspectModelResult(alloc, command, options); + defer observed.deinit(alloc); + return self.encodeResult( + alloc, + operation_id, + observed.result, + options.max_result_bytes, + if (observed.timed_out) "wait_timed_out" else null, + ); + } + + fn inspectModelResult( + self: *Runtime, + alloc: Allocator, + command: domain.Command, + options: ExecuteOptions, + ) !ModelInspectionOutcome { + std.debug.assert(command == .inspect); self.recoverIfNeeded(options.timestamp_ms); const target_id = command.inspect.id; const wait = command.inspect.wait; @@ -495,28 +787,12 @@ pub const Runtime = struct { if (!std.mem.eql(u8, options.caller_id, self.root_id) and !try self.isAttached(self.root_id, options.caller_id)) { - return tool_result.failureAlloc( - alloc, - operation_id, - null, - "rejected", - "caller_unavailable", - false, - null, - ); + return .{ .result = .{ .failure = .{ .code = .caller_unavailable } } }; } if (!std.mem.eql(u8, target_id, options.caller_id) and !try self.isAttached(options.caller_id, target_id)) { - return tool_result.failureAlloc( - alloc, - operation_id, - target_id, - "rejected", - "child_unavailable", - false, - null, - ); + return .{ .result = .{ .failure = .{ .code = .child_unavailable } } }; } runAfterTargetAuthorizationTestHook(); @@ -525,50 +801,26 @@ pub const Runtime = struct { .target_authorization = .{ .attached_to_root = self.root_id }, .timestamp_ms = options.timestamp_ms, }); - defer result.deinit(alloc); - const requested_wait = wait orelse return self.encodeResult( - alloc, - operation_id, - result, - options.max_result_bytes, - null, - ); + const requested_wait = wait orelse return .{ .result = result }; const inspection = switch (result) { .inspection => |*value| value, .receipt => unreachable, - .failure => return self.encodeResult( - alloc, - operation_id, - result, - options.max_result_bytes, - null, - ), + .failure => return .{ .result = result }, }; if (domain.inspectWaitSatisfied( requested_wait, inspection.generation, inspection.status.?, )) { - return self.encodeResult( - alloc, - operation_id, - result, - options.max_result_bytes, - null, - ); + return .{ .result = result }; } const remaining = deadline.?.durationFromNow(io_mod.getIo()); if (remaining.raw.nanoseconds <= 0) { - return self.encodeResult( - alloc, - operation_id, - result, - options.max_result_bytes, - "wait_timed_out", - ); + return .{ .result = result, .timed_out = true }; } + result.deinit(alloc); const poll_duration = std.Io.Duration.fromMilliseconds( inspect_wait_external_poll_ms, ); diff --git a/src/core/subagent/tool_provider.zig b/src/core/subagent/tool_provider.zig index 8b749ff36..baa882b2a 100644 --- a/src/core/subagent/tool_provider.zig +++ b/src/core/subagent/tool_provider.zig @@ -1,5 +1,5 @@ const std = @import("std"); -const domain = @import("domain.zig"); +const model_contract = @import("model_contract.zig"); const Allocator = std.mem.Allocator; @@ -18,12 +18,12 @@ pub const Result = struct { pub const ExecuteFn = *const fn ( ?*anyopaque, Allocator, - *domain.Command, + *model_contract.Request, []const u8, ) Allocator.Error!Result; -/// Host-facing executor for one validated registered subagent command. The -/// caller retains command ownership; the provider may normalize it during the +/// Host-facing executor for one validated registered subagent request. The +/// caller retains request ownership; the provider may inspect it during the /// synchronous call but must not retain the pointer. pub const Provider = struct { context: ?*anyopaque = null, @@ -32,33 +32,33 @@ pub const Provider = struct { pub fn execute( self: Provider, alloc: Allocator, - command: *domain.Command, + request: *model_contract.Request, invocation_id: []const u8, ) Allocator.Error!Result { return self.execute_fn( self.context, alloc, - command, + request, invocation_id, ); } }; -test "provider forwards the validated command and invocation identity" { +test "provider forwards the validated managed request and invocation identity" { const Fixture = struct { calls: usize = 0, - command: ?*domain.Command = null, + request: ?*model_contract.Request = null, invocation_id: ?[]const u8 = null, fn execute( raw_context: ?*anyopaque, alloc: Allocator, - command: *domain.Command, + request: *model_contract.Request, invocation_id: []const u8, ) Allocator.Error!Result { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.calls += 1; - self.command = command; + self.request = request; self.invocation_id = invocation_id; return .{ .status = .success, @@ -68,9 +68,8 @@ test "provider forwards the validated command and invocation identity" { }; var fixture = Fixture{}; - var command = domain.Command{ .lifecycle = .{ - .id = @constCast("child-1"), - .action = .cancel, + var request = model_contract.Request{ .stop = .{ + .child_id = @constCast("child-1"), } }; const provider = Provider{ .context = &fixture, @@ -79,7 +78,7 @@ test "provider forwards the validated command and invocation identity" { const result = try provider.execute( std.testing.allocator, - &command, + &request, "call-1", ); defer std.testing.allocator.free(result.body); @@ -87,6 +86,6 @@ test "provider forwards the validated command and invocation identity" { try std.testing.expectEqual(Status.success, result.status); try std.testing.expectEqualStrings("executed", result.body); try std.testing.expectEqual(@as(usize, 1), fixture.calls); - try std.testing.expect(fixture.command.? == &command); + try std.testing.expect(fixture.request.? == &request); try std.testing.expectEqualStrings("call-1", fixture.invocation_id.?); } diff --git a/src/core/tooling/tool_runtime.zig b/src/core/tooling/tool_runtime.zig index 2bf4bac28..34ac18ff8 100644 --- a/src/core/tooling/tool_runtime.zig +++ b/src/core/tooling/tool_runtime.zig @@ -32,6 +32,7 @@ const subagent_communication_store = @import("../subagent/communication_store.zi const subagent_control_store = @import("../subagent/control_store.zig"); const subagent_create_store = @import("../subagent/create_store.zig"); const subagent_domain = @import("../subagent/domain.zig"); +const subagent_model_contract = @import("../subagent/model_contract.zig"); const subagent_tool_host = @import("../subagent/tool_host.zig"); const subagent_tool_provider = @import("../subagent/tool_provider.zig"); const subagent_tool_result = @import("../subagent/tool_result.zig"); @@ -1783,59 +1784,33 @@ const SubagentProviderState = struct { fn subagentProviderFailure( alloc: Allocator, - operation_id: []const u8, + child_id: ?[]const u8, error_code: []const u8, retryable: bool, ) Allocator.Error!subagent_tool_provider.Result { - const body = subagent_tool_result.failureAlloc( - alloc, - operation_id, - null, - "rejected", - error_code, - retryable, - null, - ) catch |err| return switch (err) { - error.OutOfMemory, error.WriteFailed => error.OutOfMemory, - }; + const body = subagent_model_contract.encodeResultAlloc(alloc, .{ + .ok = false, + .child_id = child_id, + .status = "rejected", + .error_code = error_code, + .retryable = retryable, + }) catch return error.OutOfMemory; return .{ .status = .failure, .body = body }; } fn executeSubagentProvider( raw_context: ?*anyopaque, arena: Allocator, - command: *subagent_domain.Command, + request: *subagent_model_contract.Request, invocation_id: []const u8, ) Allocator.Error!subagent_tool_provider.Result { const state: *SubagentProviderState = @ptrCast(@alignCast(raw_context.?)); const ctx = state.runtime; const host = ctx.subagent_host orelse - return subagentProviderFailure(arena, invocation_id, "host_unavailable", false); + return subagentProviderFailure(arena, null, "host_unavailable", false); const caller_id = ctx.subagent_caller_id orelse - return subagentProviderFailure(arena, invocation_id, "caller_unavailable", false); - const permission_admitted = host.admitModelCommand( - arena, - command, - caller_id, - ctx.permission_mode, - ) catch |err| { - if (err == error.OutOfMemory) return error.OutOfMemory; - return subagentProviderFailure( - arena, - invocation_id, - "host_failure", - true, - ); - }; - if (!permission_admitted) { - return subagentProviderFailure( - arena, - invocation_id, - "permission_escalation", - false, - ); - } - const identity_epoch = if (command.* == .inspect) + return subagentProviderFailure(arena, null, "caller_unavailable", false); + const identity_epoch = if (request.* == .wait) 0 else switch (try persistedSubagentIdentity( arena, @@ -1851,7 +1826,7 @@ fn executeSubagentProvider( if (err == error.OutOfMemory) return error.OutOfMemory; return try subagentProviderFailure( arena, - invocation_id, + request.childId(), "host_failure", true, ); @@ -1859,12 +1834,12 @@ fn executeSubagentProvider( .replay => |epoch| epoch, .corrupt => return subagentProviderFailure( arena, - invocation_id, + request.childId(), "host_failure", true, ), }; - const output = host.execute(arena, command, .{ + const output = host.executeManaged(arena, request, .{ .caller_id = caller_id, .invocation_id = invocation_id, .parent_permission_mode = ctx.permission_mode, @@ -1883,23 +1858,16 @@ fn executeSubagentProvider( .identity_epoch = identity_epoch, }) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; - const operation_id = if (command.* == .inspect) - invocation_id - else - try subagent_tool_result.boundOperationIdAlloc( - arena, - invocation_id, - .model, - identity_epoch, - ); - return subagentProviderFailure(arena, operation_id, "host_failure", true); + return subagentProviderFailure( + arena, + request.childId(), + "host_failure", + true, + ); }; return .{ - .status = if (std.mem.find(u8, output, "\"ok\":false") == null) - .success - else - .failure, - .body = output, + .status = if (output.success) .success else .failure, + .body = output.body, }; } @@ -2097,11 +2065,6 @@ fn persistedSubagentEpoch( const retryable_value = parsed.value.object.get("retryable") orelse return null; if (retryable_value != .bool) return null; - const requested_value = parsed.value.object.get("requested") orelse - return null; - if (requested_value != .null and requested_value != .object) return null; - const cursor_value = parsed.value.object.get("cursor") orelse return null; - if (cursor_value != .null and cursor_value != .string) return null; const operation_id = operation_value.string; const identity = subagent_tool_result.parseBoundOperationId(operation_id) orelse return null; @@ -2653,22 +2616,7 @@ fn expectSingleSubagentCreateEffects( var record = try control.load(alloc); defer record.deinit(alloc); try std.testing.expectEqual(@as(usize, 1), record.operations.len); - try std.testing.expectEqual(@as(usize, 1), record.events.len); - try std.testing.expectEqual(@as(usize, 0), record.queue.len); - - const communications = subagent_communication_store.Store{ - .capability = &capability, - .expected_session_id = child_id, - }; - if (try communications.loadOptional(alloc)) |loaded| { - var ledger = loaded; - defer ledger.deinit(alloc); - return error.TestUnexpectedCommunicationEffect; - } - - var child = try env.store.loadReadOnly(alloc, child_id); - defer child.deinit(alloc); - try std.testing.expectEqual(@as(usize, 0), child.history.len); + try std.testing.expectEqualStrings(child_id, record.child_id); } test "subagent production identity inspections leave no mutation reservations" { @@ -2700,7 +2648,7 @@ test "subagent production identity inspections leave no mutation reservations" { .id = "inspect-fixture-create", .name = "subagent", .arguments_json = - \\{"command":{"create":{"name":"inspect-fixture","mode":"persistent"}}} + \\{"request":{"action":"run","task":"inspect fixture"}} , }); try std.testing.expectEqual(tool_contracts.ToolExecutionStatus.success, created.status); @@ -2709,7 +2657,7 @@ test "subagent production identity inspections leave no mutation reservations" { const inspect_args = try std.fmt.allocPrint( alloc, - "{{\"command\":{{\"inspect\":{{\"id\":\"{s}\",\"sections\":[\"status\"]}}}}}}", + "{{\"request\":{{\"action\":\"wait\",\"child_id\":\"{s}\"}}}}", .{child_id}, ); defer alloc.free(inspect_args); @@ -2737,26 +2685,26 @@ test "subagent production identity inspections leave no mutation reservations" { ); } - const configure_args = try std.fmt.allocPrint( + const send_args = try std.fmt.allocPrint( alloc, - "{{\"command\":{{\"configure\":{{\"id\":\"{s}\",\"name\":\"renamed\"}}}}}}", + "{{\"request\":{{\"action\":\"send\",\"child_id\":\"{s}\",\"message\":\"continue\"}}}}", .{child_id}, ); - defer alloc.free(configure_args); - var configure_arena = std.heap.ArenaAllocator.init(alloc); - defer configure_arena.deinit(); - const configured = try executeToolCall( + defer alloc.free(send_args); + var send_arena = std.heap.ArenaAllocator.init(alloc); + defer send_arena.deinit(); + const sent = try executeToolCall( runtime.context(), - configure_arena.allocator(), + send_arena.allocator(), .{ .id = "mutation-after-inspections", .name = "subagent", - .arguments_json = configure_args, + .arguments_json = send_args, }, ); try std.testing.expectEqual( tool_contracts.ToolExecutionStatus.success, - configured.status, + sent.status, ); var root_capability = try env.store.openSubagentControlCapabilityReadOnly( @@ -2806,7 +2754,7 @@ test "subagent production stable failures leave no mutation reservations" { .id = "stable-failure-fixture-create", .name = "subagent", .arguments_json = - \\{"command":{"create":{"name":"stable-failure-fixture","mode":"persistent"}}} + \\{"request":{"action":"run","task":"stable failure fixture"}} , }); try std.testing.expectEqual(tool_contracts.ToolExecutionStatus.success, created.status); @@ -2814,7 +2762,7 @@ test "subagent production stable failures leave no mutation reservations" { defer alloc.free(child_id); const missing_args = - \\{"command":{"configure":{"id":"01J00000000000000000009999","name":"never applied"}}} + \\{"request":{"action":"send","child_id":"01J00000000000000000009999","message":"never applied"}} ; for (0..3) |index| { var invocation_buffer: [64]u8 = undefined; @@ -2844,26 +2792,26 @@ test "subagent production stable failures leave no mutation reservations" { ); } - const configure_args = try std.fmt.allocPrint( + const send_args = try std.fmt.allocPrint( alloc, - "{{\"command\":{{\"configure\":{{\"id\":\"{s}\",\"name\":\"still writable\"}}}}}}", + "{{\"request\":{{\"action\":\"send\",\"child_id\":\"{s}\",\"message\":\"still writable\"}}}}", .{child_id}, ); - defer alloc.free(configure_args); - var configure_arena = std.heap.ArenaAllocator.init(alloc); - defer configure_arena.deinit(); - const configured = try executeToolCall( + defer alloc.free(send_args); + var send_arena = std.heap.ArenaAllocator.init(alloc); + defer send_arena.deinit(); + const sent = try executeToolCall( runtime.context(), - configure_arena.allocator(), + send_arena.allocator(), .{ .id = "model-mutation-after-stable-failures", .name = "subagent", - .arguments_json = configure_args, + .arguments_json = send_args, }, ); try std.testing.expectEqual( tool_contracts.ToolExecutionStatus.success, - configured.status, + sent.status, ); var root_capability = try env.store.openSubagentControlCapabilityReadOnly( @@ -2901,10 +2849,10 @@ test "subagent production identity replays one invocation within an active agent const root_id = "01J00000000000000000000000"; const invocation_id = "production-active-turn-replay"; const create_args = - \\{"command":{"create":{"name":"active-turn-worker","mode":"persistent"}}} + \\{"request":{"action":"run","task":"active turn worker"}} ; const changed_args = - \\{"command":{"create":{"name":"changed-active-turn-worker","mode":"persistent"}}} + \\{"request":{"action":"run","task":"changed active turn worker"}} ; var repeated_calls = [_]ToolCall{.{ .id = invocation_id, @@ -3041,7 +2989,7 @@ test "subagent production identity rejects malformed active-turn evidence before const root_id = "01J00000000000000000000000"; const invocation_id = "production-active-turn-corrupt"; const create_args = - \\{"command":{"create":{"name":"must-not-exist","mode":"persistent"}}} + \\{"request":{"action":"run","task":"must not exist"}} ; var calls = [_]ToolCall{.{ .id = invocation_id, @@ -3251,18 +3199,13 @@ test "subagent identity evidence prefers canonical active-turn results and authe } } - const invalid_requested_output = try subagent_tool_result.outcomeAlloc(alloc, .{ + const missing_operation_output = try subagent_model_contract.encodeResultAlloc(alloc, .{ .ok = true, - .operation_id = current_operation_id, .child_id = "invalid-child", - .status = "created", - .error_code = null, - .retryable = false, - .requested_json = "\"invalid\"", - .cursor = null, + .status = "idle", }); - defer alloc.free(invalid_requested_output); - current_messages[1].content = invalid_requested_output; + defer alloc.free(missing_operation_output); + current_messages[1].content = missing_operation_output; switch (try persistedSubagentIdentity( alloc, ¤t_messages, @@ -3291,7 +3234,7 @@ test "subagent production identity replays persisted invocation across restart w const root_id = "01J00000000000000000000000"; const invocation_id = "production-adapter-replay"; const create_args = - \\{"command":{"create":{"name":"replayed-worker","mode":"persistent"}}} + \\{"request":{"action":"run","task":"replayed worker"}} ; const call = ToolCall{ .id = invocation_id, @@ -3394,7 +3337,7 @@ test "subagent production identity replays persisted invocation across restart w .id = invocation_id, .name = "subagent", .arguments_json = - \\{"command":{"create":{"name":"different-worker","mode":"persistent"}}} + \\{"request":{"action":"run","task":"different worker"}} , }, ); diff --git a/src/tools/agent/subagent.zig b/src/tools/agent/subagent.zig index 6fce1b9d5..9dbd10b12 100644 --- a/src/tools/agent/subagent.zig +++ b/src/tools/agent/subagent.zig @@ -1,30 +1,26 @@ const std = @import("std"); -const domain = @import("../../core/subagent/domain.zig"); +const model_contract = @import("../../core/subagent/model_contract.zig"); const tool_provider = @import("../../core/subagent/tool_provider.zig"); -const tool_result = @import("../../core/subagent/tool_result.zig"); const tool_dispatch = @import("../../core/tooling/tool_dispatch.zig"); const types = @import("../../core/shared/types.zig"); const Allocator = std.mem.Allocator; pub const Input = struct { - command: domain.Command, + request: model_contract.Request, pub fn deinit(self: *Input, alloc: Allocator) void { - self.command.deinit(alloc); + self.request.deinit(alloc); self.* = undefined; } }; const DecodeError = error{ OutOfMemory, - InvalidRoot, - MissingCommand, - UnknownField, InvalidFieldType, MissingField, + UnknownField, InvalidEnum, - InvalidInteger, }; pub fn decode( @@ -40,21 +36,21 @@ pub fn decode( }; defer parsed.deinit(); - const command_input = parseRoot(arena, parsed.value) catch |err| { + const request_input = parseRoot(parsed.value) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return decodeFailure(ctx, decodeErrorCode(err)) catch return error.OutOfMemory; }; - const command = domain.validateCommand(ctx.allocator, command_input) catch |err| { + const request = model_contract.validateRequest(ctx.allocator, request_input) catch |err| { if (err == error.OutOfMemory) return error.OutOfMemory; return decodeFailure(ctx, validationErrorCode(err)) catch return error.OutOfMemory; }; errdefer { - var owned = command; + var owned = request; owned.deinit(ctx.allocator); } const input = try ctx.allocator.create(Input); - input.* = .{ .command = command }; + input.* = .{ .request = request }; return .{ .input = .{ .ptr = input, .deinit_fn = inputDeinit } }; } @@ -64,287 +60,76 @@ fn inputDeinit(ptr: *anyopaque, alloc: Allocator) void { alloc.destroy(input); } -fn decodeFailure(ctx: tool_dispatch.DispatchContext, code: []const u8) !tool_dispatch.DecodeResult { - return .{ .failure = try tool_result.failureAlloc( - ctx.allocator, - ctx.tool_call_id, - null, - "rejected", - code, - false, - null, - ) }; +fn decodeFailure( + ctx: tool_dispatch.DispatchContext, + code: []const u8, +) !tool_dispatch.DecodeResult { + return .{ .failure = try model_contract.encodeResultAlloc(ctx.allocator, .{ + .ok = false, + .child_id = null, + .status = "rejected", + .error_code = code, + }) }; } fn decodeErrorCode(err: DecodeError) []const u8 { return switch (err) { error.OutOfMemory => unreachable, - error.InvalidRoot => "invalid_root", - error.MissingCommand => "missing_command", - error.UnknownField => "unknown_field", error.InvalidFieldType => "invalid_field_type", error.MissingField => "missing_field", + error.UnknownField => "unknown_field", error.InvalidEnum => "invalid_enum", - error.InvalidInteger => "invalid_integer", }; } -fn validationErrorCode(err: domain.ValidationError) []const u8 { +fn validationErrorCode(err: model_contract.ValidationError) []const u8 { return switch (err) { - error.InvalidBranchSelection => "invalid_branch_selection", - error.InvalidNestedBranchSelection => "invalid_nested_branch_selection", - error.MissingName => "missing_name", - error.MissingMode => "missing_mode", - error.MissingOneOffPrompt => "missing_one_off_prompt", - error.MissingInspectId => "missing_inspect_id", - error.InvalidId => "invalid_id", - error.InvalidName => "invalid_name", + error.OutOfMemory => unreachable, + error.InvalidTask => "invalid_task", error.InvalidModel => "invalid_model", - error.InvalidPrompt => "invalid_prompt", + error.InvalidChildId => "invalid_child_id", error.InvalidMessage => "invalid_message", - error.InvalidOperationId => "invalid_operation_id", - error.InvalidNotificationPolicy => "invalid_notification_policy", - error.DuplicateMilestone => "duplicate_milestone", - error.DuplicateStopCondition => "duplicate_stop_condition", - error.InvalidInspectSections => "invalid_inspect_sections", - error.InvalidInspectWait => "invalid_inspect_wait", - error.InvalidCursor => "invalid_cursor", - error.InvalidPageLimit => "invalid_page_limit", - error.InvalidRelationship => "invalid_relationship", - error.EmptyConfiguration => "empty_configuration", - error.OutOfMemory => unreachable, }; } -fn parseRoot(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)!domain.CommandInput { +fn parseRoot(value: std.json.Value) DecodeError!model_contract.RequestInput { const root = try objectValue(value); - try rejectUnknown(root, &.{"command"}); - const command_value = root.get("command") orelse return error.MissingCommand; - const command = try objectValue(command_value); - try rejectUnknown(command, &.{ "create", "inspect", "message", "relationship", "configure", "lifecycle" }); - - return .{ - .create = if (command.get("create")) |branch| try parseCreate(arena, branch) else null, - .inspect = if (command.get("inspect")) |branch| try parseInspect(arena, branch) else null, - .message = if (command.get("message")) |branch| try parseMessage(arena, branch) else null, - .relationship = if (command.get("relationship")) |branch| try parseRelationship(branch) else null, - .configure = if (command.get("configure")) |branch| try parseConfigure(arena, branch) else null, - .lifecycle = if (command.get("lifecycle")) |branch| try parseLifecycle(branch) else null, - }; -} - -fn parseCreate(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)!domain.CreateInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "name", "mode", "prompt", "model", "effort", "permission_mode", "notifications" }); - return .{ - .name = try optionalString(object, "name"), - .mode = if (try optionalString(object, "mode")) |raw| try parseMode(raw) else null, - .prompt = try optionalString(object, "prompt"), - .model = try optionalString(object, "model"), - .effort = if (try optionalString(object, "effort")) |raw| - types.ReasoningEffort.parse(raw) orelse return error.InvalidEnum - else - null, - .permission_mode = if (try optionalString(object, "permission_mode")) |raw| - std.meta.stringToEnum(types.PermissionMode, raw) orelse - return error.InvalidEnum - else - null, - .notifications = if (object.get("notifications")) |raw| try parseNotifications(arena, raw) else null, - }; -} - -fn parseInspect(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)!domain.InspectInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "id", "sections", "cursor", "limit", "wait" }); - return .{ - .id = try optionalString(object, "id"), - .sections = if (object.get("sections")) |raw| try parseInspectSections(arena, raw) else &.{}, - .cursor = try optionalString(object, "cursor"), - .limit = if (object.get("limit")) |raw| try positiveUsize(raw) else null, - .wait = if (object.get("wait")) |raw| try parseInspectWait(raw) else null, - }; -} - -fn parseInspectWait(value: std.json.Value) DecodeError!domain.InspectWaitInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "until", "after_generation", "timeout_ms" }); - return .{ - .until = if (try optionalString(object, "until")) |raw| - if (std.mem.eql(u8, raw, "settled")) - .settled + const request = if (root.get("request")) |request_value| blk: { + try rejectUnknown(root, &.{"request"}); + break :blk try objectValue(request_value); + } else root; + const action = try requiredString(request, "action"); + + if (std.mem.eql(u8, action, "run")) { + try rejectUnknown(request, &.{ "action", "task", "model", "effort" }); + return .{ .run = .{ + .task = try requiredString(request, "task"), + .model = try optionalString(request, "model"), + .effort = if (try optionalString(request, "effort")) |raw| + types.ReasoningEffort.parse(raw) orelse return error.InvalidEnum else - return error.InvalidEnum - else - null, - .after_generation = if (object.get("after_generation")) |raw| - try nonNegativeU64(raw) - else - null, - .timeout_ms = if (object.get("timeout_ms")) |raw| - try positiveU64(raw) - else - null, - }; -} - -fn parseMessage(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)!domain.MessageInput { - _ = arena; - const object = try objectValue(value); - try rejectUnknown(object, &.{ "send", "milestone" }); - return .{ - .send = if (object.get("send")) |raw| try parseSend(raw) else null, - .milestone = if (object.get("milestone")) |raw| try parseMilestone(raw) else null, - }; -} - -fn parseSend(value: std.json.Value) DecodeError!domain.MessageSendInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "id", "content" }); - return .{ - .id = try requiredString(object, "id"), - .content = try requiredString(object, "content"), - }; -} - -fn parseMilestone(value: std.json.Value) DecodeError!domain.MessageMilestoneInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{"name"}); - return .{ .name = try requiredString(object, "name") }; -} - -fn parseRelationship(value: std.json.Value) DecodeError!domain.RelationshipInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "action", "id", "parent_id" }); - const action_raw = try requiredString(object, "action"); - const action: domain.RelationshipAction = if (std.mem.eql(u8, action_raw, "attach")) - .attach - else if (std.mem.eql(u8, action_raw, "detach")) - .detach - else if (std.mem.eql(u8, action_raw, "reparent")) - .reparent - else - return error.InvalidEnum; - return .{ - .action = action, - .id = try requiredString(object, "id"), - .parent_id = try optionalString(object, "parent_id"), - }; -} - -fn parseConfigure(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)!domain.ConfigureInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "id", "name", "model", "effort", "permission_mode", "notifications" }); - return .{ - .id = try requiredString(object, "id"), - .name = try optionalString(object, "name"), - .model = try optionalString(object, "model"), - .effort = if (try optionalString(object, "effort")) |raw| - types.ReasoningEffort.parse(raw) orelse return error.InvalidEnum - else - null, - .permission_mode = if (try optionalString(object, "permission_mode")) |raw| - std.meta.stringToEnum(types.PermissionMode, raw) orelse - return error.InvalidEnum - else - null, - .notifications = if (object.get("notifications")) |raw| try parseNotifications(arena, raw) else null, - }; -} - -fn parseLifecycle(value: std.json.Value) DecodeError!domain.LifecycleInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "id", "action" }); - const action_raw = try requiredString(object, "action"); - const action: domain.LifecycleAction = if (std.mem.eql(u8, action_raw, "cancel")) - .cancel - else if (std.mem.eql(u8, action_raw, "resume")) - .@"resume" - else if (std.mem.eql(u8, action_raw, "close")) - .close - else if (std.mem.eql(u8, action_raw, "reopen")) - .reopen - else - return error.InvalidEnum; - return .{ - .id = try requiredString(object, "id"), - .action = action, - }; -} - -fn parseNotifications( - arena: Allocator, - value: std.json.Value, -) (DecodeError || Allocator.Error)!domain.NotificationPolicyInput { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "terminal", "milestones", "report_interval_ms", "report_duration_ms", "stop_conditions" }); - var result = domain.NotificationPolicyInput{}; - if (object.get("terminal")) |raw| result.terminal = try parseTerminal(raw); - if (object.get("milestones")) |raw| result.milestones = try parseStringArray(arena, raw); - if (object.get("report_interval_ms")) |raw| result.report_interval_ms = try positiveU64(raw); - if (object.get("report_duration_ms")) |raw| result.report_duration_ms = try positiveU64(raw); - if (object.get("stop_conditions")) |raw| result.stop_conditions = try parseStopConditions(arena, raw); - return result; -} - -fn parseTerminal(value: std.json.Value) DecodeError!domain.TerminalEvents { - const object = try objectValue(value); - try rejectUnknown(object, &.{ "completed", "failed", "cancelled" }); - var result = domain.TerminalEvents{}; - if (object.get("completed")) |raw| result.completed = try boolValue(raw); - if (object.get("failed")) |raw| result.failed = try boolValue(raw); - if (object.get("cancelled")) |raw| result.cancelled = try boolValue(raw); - return result; -} - -fn parseInspectSections(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)![]domain.InspectSection { - if (value != .array) return error.InvalidFieldType; - const out = try arena.alloc(domain.InspectSection, value.array.items.len); - for (value.array.items, 0..) |item, index| { - const raw = try stringValue(item); - out[index] = if (std.mem.eql(u8, raw, "status")) - .status - else if (std.mem.eql(u8, raw, "messages")) - .messages - else if (std.mem.eql(u8, raw, "tool_activity")) - .tool_activity - else if (std.mem.eql(u8, raw, "events")) - .events - else if (std.mem.eql(u8, raw, "configuration")) - .configuration - else if (std.mem.eql(u8, raw, "relationship")) - .relationship - else - return error.InvalidEnum; + null, + } }; } - return out; -} - -fn parseStopConditions(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)![]domain.StopCondition { - if (value != .array) return error.InvalidFieldType; - const out = try arena.alloc(domain.StopCondition, value.array.items.len); - for (value.array.items, 0..) |item, index| { - const raw = try stringValue(item); - out[index] = if (std.mem.eql(u8, raw, "terminal")) - .terminal - else if (std.mem.eql(u8, raw, "duration_elapsed")) - .duration_elapsed - else - return error.InvalidEnum; + if (std.mem.eql(u8, action, "wait")) { + try rejectUnknown(request, &.{ "action", "child_id" }); + return .{ .wait = .{ + .child_id = try requiredString(request, "child_id"), + } }; + } + if (std.mem.eql(u8, action, "send")) { + try rejectUnknown(request, &.{ "action", "child_id", "message" }); + return .{ .send = .{ + .child_id = try requiredString(request, "child_id"), + .message = try requiredString(request, "message"), + } }; + } + if (std.mem.eql(u8, action, "stop") or std.mem.eql(u8, action, "cancel")) { + try rejectUnknown(request, &.{ "action", "child_id" }); + return .{ .stop = .{ + .child_id = try requiredString(request, "child_id"), + } }; } - return out; -} - -fn parseStringArray(arena: Allocator, value: std.json.Value) (DecodeError || Allocator.Error)![][]const u8 { - if (value != .array) return error.InvalidFieldType; - const out = try arena.alloc([]const u8, value.array.items.len); - for (value.array.items, 0..) |item, index| out[index] = try stringValue(item); - return out; -} - -fn parseMode(raw: []const u8) DecodeError!domain.Mode { - if (std.mem.eql(u8, raw, "one_off")) return .one_off; - if (std.mem.eql(u8, raw, "persistent")) return .persistent; return error.InvalidEnum; } @@ -356,36 +141,26 @@ fn stringValue(value: std.json.Value) DecodeError![]const u8 { return if (value == .string) value.string else error.InvalidFieldType; } -fn boolValue(value: std.json.Value) DecodeError!bool { - return if (value == .bool) value.bool else error.InvalidFieldType; -} - -fn requiredString(object: std.json.ObjectMap, key: []const u8) DecodeError![]const u8 { +fn requiredString( + object: std.json.ObjectMap, + key: []const u8, +) DecodeError![]const u8 { const value = object.get(key) orelse return error.MissingField; return stringValue(value); } -fn optionalString(object: std.json.ObjectMap, key: []const u8) DecodeError!?[]const u8 { +fn optionalString( + object: std.json.ObjectMap, + key: []const u8, +) DecodeError!?[]const u8 { const value = object.get(key) orelse return null; return try stringValue(value); } -fn positiveU64(value: std.json.Value) DecodeError!u64 { - if (value != .integer or value.integer <= 0) return error.InvalidInteger; - return std.math.cast(u64, value.integer) orelse error.InvalidInteger; -} - -fn nonNegativeU64(value: std.json.Value) DecodeError!u64 { - if (value != .integer or value.integer < 0) return error.InvalidInteger; - return std.math.cast(u64, value.integer) orelse error.InvalidInteger; -} - -fn positiveUsize(value: std.json.Value) DecodeError!usize { - const parsed = try positiveU64(value); - return std.math.cast(usize, parsed) orelse error.InvalidInteger; -} - -fn rejectUnknown(object: std.json.ObjectMap, allowed: []const []const u8) DecodeError!void { +fn rejectUnknown( + object: std.json.ObjectMap, + allowed: []const []const u8, +) DecodeError!void { var fields = object.iterator(); while (fields.next()) |entry| { for (allowed) |name| { @@ -406,22 +181,17 @@ pub fn call( erased: tool_dispatch.ToolInput, ) tool_dispatch.DispatchError!tool_dispatch.ToolResult { const provider = ctx.subagent_provider orelse { - const body = tool_result.failureAlloc( - ctx.allocator, - ctx.tool_call_id, - null, - "rejected", - "host_unavailable", - false, - null, - ) catch |err| return switch (err) { - error.OutOfMemory, error.WriteFailed => error.OutOfMemory, - }; + const body = model_contract.encodeResultAlloc(ctx.allocator, .{ + .ok = false, + .child_id = null, + .status = "rejected", + .error_code = "host_unavailable", + }) catch return error.OutOfMemory; return .{ .failure = body }; }; const result = try provider.execute( ctx.allocator, - &erased.as(Input).command, + &erased.as(Input).request, ctx.tool_call_id, ); return switch (result.status) { @@ -431,10 +201,7 @@ pub fn call( } pub fn readsOnly(input: tool_dispatch.ToolInput) bool { - return switch (input.as(Input).command) { - .inspect => true, - else => false, - }; + return input.as(Input).request == .wait; } pub fn isIrreversible(_: tool_dispatch.ToolInput) bool { @@ -458,7 +225,10 @@ fn expectDecodeFailure(args_json: []const u8, code: []const u8) !void { } } -fn expectCommandTag(args_json: []const u8, expected: std.meta.Tag(domain.Command)) !void { +fn expectRequestTag( + args_json: []const u8, + expected: model_contract.Action, +) !void { const alloc = std.testing.allocator; const result = try decode(.{ .allocator = alloc }, args_json); switch (result) { @@ -468,26 +238,26 @@ fn expectCommandTag(args_json: []const u8, expected: std.meta.Tag(domain.Command }, .input => |input| { defer input.deinit(alloc); - try std.testing.expectEqual(expected, std.meta.activeTag(input.as(Input).command)); + try std.testing.expectEqual(expected, input.as(Input).request.action()); }, } } -test "call executes a validated command through the registered provider" { +test "call executes a validated managed request through the provider" { const Fixture = struct { calls: usize = 0, - command: ?*domain.Command = null, + request: ?*model_contract.Request = null, invocation_id: ?[]const u8 = null, fn execute( raw_context: ?*anyopaque, alloc: Allocator, - command: *domain.Command, + request: *model_contract.Request, invocation_id: []const u8, ) Allocator.Error!tool_provider.Result { const self: *@This() = @ptrCast(@alignCast(raw_context.?)); self.calls += 1; - self.command = command; + self.request = request; self.invocation_id = invocation_id; return .{ .status = .success, @@ -499,7 +269,7 @@ test "call executes a validated command through the registered provider" { const alloc = std.testing.allocator; const decoded = try decode( .{ .allocator = alloc, .tool_call_id = "call-1" }, - "{\"command\":{\"inspect\":{\"id\":\"child-1\",\"sections\":[\"status\"]}}}", + "{\"request\":{\"action\":\"wait\",\"child_id\":\"01J00000000000000000000000\"}}", ); var fixture = Fixture{}; switch (decoded) { @@ -522,20 +292,36 @@ test "call executes a validated command through the registered provider" { .success => |body| try std.testing.expectEqualStrings("{\"ok\":true}", body), .failure => return error.TestUnexpectedResult, } - try std.testing.expect(fixture.command.? == &input.as(Input).command); - try std.testing.expectEqual(.inspect, std.meta.activeTag(fixture.command.?.*)); - try std.testing.expectEqualStrings("child-1", fixture.command.?.inspect.id); + try std.testing.expect(fixture.request.? == &input.as(Input).request); + try std.testing.expectEqual(model_contract.Action.wait, fixture.request.?.action()); try std.testing.expectEqualStrings("call-1", fixture.invocation_id.?); }, } try std.testing.expectEqual(@as(usize, 1), fixture.calls); } -test "call reports structured host unavailability without a provider" { +test "decode accepts managed actions and bounded canonical forms" { + try expectRequestTag("{\"request\":{\"action\":\"run\",\"task\":\"do it\"}}", .run); + try expectRequestTag("{\"request\":{\"action\":\"wait\",\"child_id\":\"01J00000000000000000000000\"}}", .wait); + try expectRequestTag("{\"action\":\"wait\",\"child_id\":\"01J00000000000000000000000\"}", .wait); + try expectRequestTag("{\"request\":{\"action\":\"send\",\"child_id\":\"01J00000000000000000000000\",\"message\":\"next\"}}", .send); + try expectRequestTag("{\"request\":{\"action\":\"stop\",\"child_id\":\"01J00000000000000000000000\"}}", .stop); + try expectRequestTag("{\"request\":{\"action\":\"cancel\",\"child_id\":\"01J00000000000000000000000\"}}", .stop); +} + +test "decode rejects manager input cross-action fields and unknown actions" { + try expectDecodeFailure("{\"command\":{\"create\":{\"name\":\"worker\"}}}", "missing_field"); + try expectDecodeFailure("{\"request\":{\"action\":\"wait\",\"child_id\":\"01J00000000000000000000000\",\"task\":\"wrong\"}}", "unknown_field"); + try expectDecodeFailure("{\"request\":{\"action\":\"inspect\",\"child_id\":\"01J00000000000000000000000\"}}", "invalid_enum"); + try expectDecodeFailure("{\"request\":{\"action\":\"wait\"}}", "missing_field"); + try expectDecodeFailure("{\"request\":null}", "invalid_field_type"); +} + +test "call reports compact host unavailability" { const alloc = std.testing.allocator; const decoded = try decode( - .{ .allocator = alloc, .tool_call_id = "call-1" }, - "{\"command\":{\"inspect\":{\"id\":\"child-1\",\"sections\":[\"status\"]}}}", + .{ .allocator = alloc }, + "{\"request\":{\"action\":\"run\",\"task\":\"do it\"}}", ); switch (decoded) { .failure => |reason| { @@ -544,10 +330,7 @@ test "call reports structured host unavailability without a provider" { }, .input => |input| { defer input.deinit(alloc); - const result = try call( - .{ .allocator = alloc, .tool_call_id = "call-1" }, - input, - ); + const result = try call(.{ .allocator = alloc }, input); defer result.deinit(alloc); switch (result) { .success => return error.TestUnexpectedResult, @@ -560,124 +343,3 @@ test "call reports structured host unavailability without a provider" { }, } } - -test "decode accepts every canonical command branch" { - try expectCommandTag("{\"command\":{\"create\":{\"name\":\"worker\",\"mode\":\"one_off\",\"prompt\":\"do it\"}}}", .create); - try expectCommandTag("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"status\"]}}}", .inspect); - try expectCommandTag("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"status\",\"messages\"],\"wait\":{\"until\":\"settled\",\"after_generation\":3,\"timeout_ms\":30000}}}}", .inspect); - try expectCommandTag("{\"command\":{\"message\":{\"send\":{\"id\":\"01J00000000000000000000000\",\"content\":\"hello\"}}}}", .message); - try expectCommandTag("{\"command\":{\"message\":{\"milestone\":{\"name\":\"compiled\"}}}}", .message); - try expectCommandTag("{\"command\":{\"relationship\":{\"action\":\"detach\",\"id\":\"01J00000000000000000000000\"}}}", .relationship); - try expectCommandTag("{\"command\":{\"configure\":{\"id\":\"01J00000000000000000000000\",\"name\":\"renamed\"}}}", .configure); - try expectCommandTag("{\"command\":{\"lifecycle\":{\"id\":\"01J00000000000000000000000\",\"action\":\"cancel\"}}}", .lifecycle); -} - -test "decode preserves configured child permission mode and defaults create to yolo" { - const alloc = std.testing.allocator; - const default_result = try decode( - .{ .allocator = alloc }, - "{\"command\":{\"create\":{\"name\":\"worker\",\"mode\":\"persistent\"}}}", - ); - switch (default_result) { - .failure => |message| { - defer alloc.free(message); - return error.TestUnexpectedResult; - }, - .input => |input| { - defer input.deinit(alloc); - try std.testing.expectEqual( - types.PermissionMode.yolo, - input.as(Input).command.create.configuration.permission_mode, - ); - }, - } - - const configured_result = try decode( - .{ .allocator = alloc }, - "{\"command\":{\"configure\":{\"id\":\"01J00000000000000000000000\",\"permission_mode\":\"ask\"}}}", - ); - switch (configured_result) { - .failure => |message| { - defer alloc.free(message); - return error.TestUnexpectedResult; - }, - .input => |input| { - defer input.deinit(alloc); - try std.testing.expectEqual( - types.PermissionMode.ask, - input.as(Input).command.configure.permission_mode.?, - ); - }, - } - try expectDecodeFailure( - "{\"command\":{\"create\":{\"name\":\"worker\",\"mode\":\"persistent\",\"permission_mode\":\"unsafe\"}}}", - "invalid_enum", - ); -} - -test "decode rejects branch ambiguity unknown fields and missing inspect id" { - try expectDecodeFailure("{\"command\":{}}", "invalid_branch_selection"); - try expectDecodeFailure("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"status\"]},\"lifecycle\":{\"id\":\"01J00000000000000000000000\",\"action\":\"cancel\"}}}", "invalid_branch_selection"); - try expectDecodeFailure("{\"command\":{\"message\":{\"send\":{\"id\":\"01J00000000000000000000000\",\"content\":\"hello\"},\"milestone\":{\"name\":\"compiled\"}}}}", "invalid_nested_branch_selection"); - try expectDecodeFailure("{\"command\":{\"inspect\":{\"sections\":[\"status\"]}}}", "missing_inspect_id"); - try expectDecodeFailure("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"status\"],\"extra\":true}}}", "unknown_field"); - try expectDecodeFailure("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"status\"],\"wait\":{\"until\":\"settled\"}}}}", "invalid_inspect_wait"); - try expectDecodeFailure("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"status\"],\"wait\":{\"timeout_ms\":30000}}}}", "invalid_inspect_wait"); - try expectDecodeFailure("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"messages\"],\"wait\":{\"until\":\"settled\",\"timeout_ms\":30000}}}}", "invalid_inspect_wait"); - try expectDecodeFailure("{\"command\":{\"inspect\":{\"id\":\"01J00000000000000000000000\",\"sections\":[\"status\"],\"wait\":{\"until\":\"settled\",\"timeout_ms\":30000,\"extra\":true}}}}", "unknown_field"); - try expectDecodeFailure("{\"command\":{\"create\":{\"mode\":\"persistent\"}}}", "missing_name"); - try expectDecodeFailure("{\"command\":{\"create\":{\"name\":\"worker\"}}}", "missing_mode"); - try expectDecodeFailure("{\"command\":{\"create\":{\"name\":\"worker\",\"mode\":\"one_off\"}}}", "missing_one_off_prompt"); - try expectDecodeFailure("{\"command\":{\"relationship\":{\"action\":\"adopt\",\"id\":\"01J00000000000000000000000\"}}}", "invalid_enum"); - try expectDecodeFailure("{\"command\":{\"lifecycle\":{\"id\":\"01J00000000000000000000000\",\"action\":\"delete\"}}}", "invalid_enum"); -} - -test "decode accepts every relationship and lifecycle action" { - inline for (.{ - "{\"command\":{\"relationship\":{\"action\":\"attach\",\"id\":\"01J00000000000000000000000\"}}}", - "{\"command\":{\"relationship\":{\"action\":\"detach\",\"id\":\"01J00000000000000000000000\"}}}", - "{\"command\":{\"relationship\":{\"action\":\"reparent\",\"id\":\"01J00000000000000000000000\",\"parent_id\":\"01J00000000000000000000001\"}}}", - }) |args| { - try expectCommandTag(args, .relationship); - } - inline for (.{ "cancel", "resume", "close", "reopen" }) |action| { - const args = try std.fmt.allocPrint( - std.testing.allocator, - "{{\"command\":{{\"lifecycle\":{{\"id\":\"01J00000000000000000000000\",\"action\":\"{s}\"}}}}}}", - .{action}, - ); - defer std.testing.allocator.free(args); - try expectCommandTag(args, .lifecycle); - } -} - -test "ordinary milestone-shaped content remains an ordinary send" { - const alloc = std.testing.allocator; - const result = try decode( - .{ .allocator = alloc }, - "{\"command\":{\"message\":{\"send\":{\"id\":\"01J00000000000000000000000\",\"content\":\"milestone: compiled\"}}}}", - ); - switch (result) { - .failure => |message| { - defer alloc.free(message); - return error.TestUnexpectedResult; - }, - .input => |input| { - defer input.deinit(alloc); - switch (input.as(Input).command.message) { - .send => |send| try std.testing.expectEqualStrings("milestone: compiled", send.content), - .milestone => return error.TestUnexpectedResult, - } - }, - } -} - -test "decode rejects oversized content before runtime effects" { - const alloc = std.testing.allocator; - const content = try alloc.alloc(u8, domain.max_message_bytes + 1); - defer alloc.free(content); - @memset(content, 'x'); - const args = try std.fmt.allocPrint(alloc, "{{\"command\":{{\"message\":{{\"send\":{{\"id\":\"01J00000000000000000000000\",\"content\":\"{s}\"}}}}}}}}", .{content}); - defer alloc.free(args); - try expectDecodeFailure(args, "invalid_message"); -} diff --git a/tests/e2e/acp.test.ts b/tests/e2e/acp.test.ts index a0ae9a149..81b24f76a 100644 --- a/tests/e2e/acp.test.ts +++ b/tests/e2e/acp.test.ts @@ -26,6 +26,7 @@ import { } from "./conditional-guidance-oracle"; import { expectPermissionModeContext } from "./permission-mode-context"; import { + canonicalSubagentIdForStore, FAKE_GATEWAY_MODEL, fakeGatewayFinalText as finalText, heldFakeGatewayFinalText, @@ -2662,7 +2663,7 @@ describe("acp: model-independent", () => { const created = JSON.parse(acpToolResultText(body, createId)) as { child_id: string; }; - childId = created.child_id; + childId = canonicalSubagentIdForStore(created.child_id); return finalText("ACP project MCP subagent started"); } if (acpPromptText(body).includes(childPrompt)) { @@ -2672,11 +2673,7 @@ describe("acp: model-independent", () => { } expect(acpPromptText(body)).toContain(parentPrompt); return fakeGatewayToolCall(createId, "subagent", { - command: { create: { - name: "project-mcp-reduction-child", - mode: "persistent", - prompt: childPrompt, - } }, + request: { action: "run", task: childPrompt }, }); }); try { @@ -7215,7 +7212,7 @@ describe("acp: model-independent", () => { if (body.includes('"toolCallId":"acp_create_1"') && body.includes('"type":"tool-result"')) { expect(acpToolResultText(body, "acp_create_1")).toContain( - '"status":"created"', + '"child_id":', ); return finalText("outer canonical subagent complete"); } @@ -7223,11 +7220,7 @@ describe("acp: model-independent", () => { }; const gateway = startFakeGateway([ fakeGatewayToolCall("acp_create_1", "subagent", { - command: { create: { - name: "workspace-inspector", - mode: "one_off", - prompt: childPrompt, - } }, + request: { action: "run", task: childPrompt }, }), routeChildAndParent, routeChildAndParent, @@ -7258,8 +7251,8 @@ describe("acp: model-independent", () => { TIMEOUT, ); - for (const childMode of ["one_off", "persistent"] as const) { - const label = childMode === "one_off" ? "one-off" : "persistent"; + for (const childMode of ["persistent"] as const) { + const label = "persistent"; test( `ACP ${label} child inherits only its supplied MCP session runtime`, async () => { @@ -7317,8 +7310,8 @@ describe("acp: model-independent", () => { const created = JSON.parse( acpToolResultText(body, parentCreateId), ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; + expect(created.status.length).toBeGreaterThan(0); + childId = canonicalSubagentIdForStore(created.child_id); parentCompleted = true; return finalText(`ACP_${childMode.toUpperCase()}_MCP_PARENT_DONE`); } @@ -7329,11 +7322,7 @@ describe("acp: model-independent", () => { } expect(acpPromptText(body)).toContain(parentPrompt); return fakeGatewayToolCall(parentCreateId, "subagent", { - command: { create: { - name: `acp-${label}-mcp-child`, - mode: childMode, - prompt: childPrompt, - } }, + request: { action: "run", task: childPrompt }, }); }; const gateway = startFakeGateway( @@ -7383,8 +7372,7 @@ describe("acp: model-independent", () => { await waitForCondition( `ACP ${label} child terminal state`, () => - acpSubagentState(root, childId) === - (childMode === "one_off" ? "completed" : "idle"), + acpSubagentState(root, childId) === "idle", TIMEOUT, ); expect(client.stderr).toBe(""); @@ -7406,487 +7394,6 @@ describe("acp: model-independent", () => { ); } - test( - "session/load denies pending one-off then returns not found after retirement", - async () => { - const root = createIsolatedRoot("fx-acp-one-off-load-"); - const childName = "acp-readonly-child"; - const childPrompt = "ACP_ONE_OFF_LOAD_CHILD"; - const childCompletion = heldFakeGatewayFinalText(); - const gateway = startDynamicFakeGateway((body) => { - if (body.includes("Acknowledge the completed one-off result.")) { - return finalText("ACP_ONE_OFF_RETIREMENT_ACK_DONE"); - } - if (body.includes('"toolCallId":"acp_one_off_load_create"')) { - return finalText("ACP_ONE_OFF_LOAD_PARENT_DONE"); - } - if (body.includes(childPrompt)) { - return childCompletion.response; - } - return fakeGatewayToolCall("acp_one_off_load_create", "subagent", { - command: { create: { - name: childName, - mode: "one_off", - prompt: childPrompt, - } }, - }); - }); - try { - client = await AcpClient.create({ - cwd: root.workspace, - env: fakeGatewayEnv(root, gateway), - }); - const parentId = await startCodeSession(client); - const result = await runPrompt( - client, - "Create the ACP one-off load fixture.", - TIMEOUT, - ); - expect(result.promptResult.result.stopReason).toBe("end_turn"); - childCompletion.release("ACP_ONE_OFF_LOAD_CHILD_DONE"); - const sessionsDir = join(root.home, ".fx", "sessions"); - let control: { id: string; path: string } | undefined; - await waitForCondition( - "ACP one-off child completion", - () => { - if (gateway.requests.length !== 3) return false; - control = readdirSync(sessionsDir) - .map((id) => ({ - id, - path: join(sessionsDir, id, "subagent", "control.json"), - })) - .filter((entry) => existsSync(entry.path)) - .find((entry) => { - const record = JSON.parse(readFileSync(entry.path, "utf8")) as { - state: string; - configuration: { name: string }; - }; - return record.configuration.name === childName && - record.state === "completed"; - }); - return control !== undefined; - }, - TIMEOUT, - ); - if (!control) throw new Error("ACP one-off control was not persisted"); - await waitForPersistedAcpDeliveryId( - root, - control.id, - "ACP_ONE_OFF_LOAD_CHILD_DONE", - ); - await client.close(); - - const controlBefore = readFileSync(control.path, "utf8"); - - client = await AcpClient.create({ - cwd: root.workspace, - env: fakeGatewayEnv(root, gateway), - }); - await client.request("initialize", { protocolVersion: 1 }, 10); - const denied = await client.request( - "session/load", - { sessionId: control.id, mcpServers: [] }, - 11, - ) as any; - expect(denied.error).toEqual({ - code: -32602, - message: "One-off child sessions cannot accept additional prompts", - }); - expect(gateway.requests).toHaveLength(3); - expect(readFileSync(control.path, "utf8")).toBe(controlBefore); - - client.send({ - jsonrpc: "2.0", - id: 12, - method: "session/load", - params: { sessionId: parentId, mcpServers: [] }, - }); - const parent = await readResponse(client, 12); - expect(parent.error).toBeUndefined(); - expect(Array.isArray(parent.result?.configOptions)).toBe(true); - - await client.close(); - client = null; - const acknowledged = await runFx([ - "ask", - "--json", - "--auto", - "--resume-id", - parentId, - "Acknowledge the completed one-off result.", - ], { - cwd: root.workspace, - env: fakeGatewayEnv(root, gateway), - timeoutMs: TIMEOUT, - }); - expect(acknowledged.code).toBe(0); - expect(gateway.requests.at(-1)?.body).toContain( - "ACP_ONE_OFF_LOAD_CHILD_DONE", - ); - await waitForCondition( - "ACP one-off child retirement", - () => !existsSync(control.path), - ); - client = await AcpClient.create({ - cwd: root.workspace, - env: fakeGatewayEnv(root, gateway), - }); - await client.request("initialize", { protocolVersion: 1 }, 20); - const retired = await client.request( - "session/load", - { sessionId: control.id, mcpServers: [] }, - 21, - ) as any; - expect(retired.error).toEqual({ - code: -32602, - message: "Session not found", - }); - expect(gateway.requests).toHaveLength(4); - expect(client.stderr).toBe(""); - } finally { - childCompletion.dispose(); - await client?.close(); - gateway.stop(); - rmSync(root.root, { recursive: true, force: true }); - } - }, - LIVE_TIMEOUT, - ); - - test( - "ACP delivers periodic child notifications at the next available parent step", - async () => { - const root = createIsolatedRoot("fx-acp-parent-delivery-"); - const childPrompt = "ACP_PARENT_DELIVERY_CHILD_PROMPT"; - const intervalPayload = "coalesced_ticks"; - let intervalEventIds: string[] = []; - let childId = ""; - let sameTurnEventIds: string[] = []; - let parentContinuationChecked = false; - let secondInitialChecked = false; - let secondContinuationChecked = false; - let thirdChecked = false; - let parentCompletion: Promise | null = null; - let childRequestObserved = false; - let resolveChildStarted!: () => void; - const childStarted = new Promise((resolve) => { - resolveChildStarted = resolve; - }); - let parentPhase: - | "create_prompt" - | "create_result" - | "second_prompt" - | "inspect_result" - | "third_prompt" - | "complete" = "create_prompt"; - const unexpectedRequests: string[] = []; - const childCompletion = heldFakeGatewayFinalText(); - const route = (body: string) => { - const text = acpPromptText(body); - const latestText = acpLatestPromptText(body); - if (latestText.includes(childPrompt)) { - if (!childRequestObserved) { - childRequestObserved = true; - resolveChildStarted(); - } - return childCompletion.response; - } - if (parentPhase === "third_prompt" && text.includes("ACP_PARENT_THIRD_PROMPT")) { - expectNoAcpParentDeliveries(body); - thirdChecked = true; - parentPhase = "complete"; - return finalText("ACP_PARENT_NO_REDELIVERY"); - } - if (parentPhase === "inspect_result" && - body.includes('"toolCallId":"acp_delivery_inspect_1"') && - body.includes('"type":"tool-result"')) { - expectNoAcpParentDeliveries(body); - secondContinuationChecked = true; - parentPhase = "third_prompt"; - return finalText("ACP_PARENT_DELIVERY_CONSUMED"); - } - if (parentPhase === "second_prompt" && text.includes("ACP_PARENT_SECOND_PROMPT")) { - const pendingEventIds = intervalEventIds.filter( - (eventId) => !sameTurnEventIds.includes(eventId), - ); - expectAcpParentDeliveriesOrNone( - body, - childId, - pendingEventIds, - intervalPayload, - ); - secondInitialChecked = true; - parentPhase = "inspect_result"; - return fakeGatewayToolCall("acp_delivery_inspect_1", "subagent", { - command: { - inspect: { - id: childId, - sections: ["status", "configuration", "relationship"], - }, - }, - }); - } - if (parentPhase === "create_result" && - body.includes('"toolCallId":"acp_delivery_create_1"') && - body.includes('"type":"tool-result"')) { - if (!parentCompletion) { - const created = JSON.parse( - acpToolResultText(body, "acp_delivery_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - sameTurnEventIds = acpParentDeliveryIds(body); - expectAcpParentDeliveriesOrNone( - body, - childId, - sameTurnEventIds, - intervalPayload, - ); - parentContinuationChecked = true; - parentPhase = "second_prompt"; - parentCompletion = childStarted - .then(() => waitForPersistedAcpDeliveryIds(root, childId, intervalPayload)) - .then(async () => { - childCompletion.release("ACP_CHILD_PRIVATE_TRANSCRIPT_DONE"); - await waitForCondition( - "ACP delivery child idle before parent boundary", - () => acpSubagentState(root, childId) === "idle", - TIMEOUT, - ); - intervalEventIds = findPersistedAcpDeliveryIds(root, childId, intervalPayload); - expect(intervalEventIds.length).toBeGreaterThan(0); - for (const eventId of sameTurnEventIds) { - expect(intervalEventIds).toContain(eventId); - } - return finalText("ACP_PARENT_FIRST_TURN_COMPLETE"); - }); - } - return parentCompletion; - } - if (parentPhase === "create_prompt" && - text.includes("Create the ACP delivery fixture.")) { - parentPhase = "create_result"; - return fakeGatewayToolCall("acp_delivery_create_1", "subagent", { - command: { create: { - name: "acp-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - report_interval_ms: 50, - stop_conditions: ["terminal"], - }, - } }, - }); - } - unexpectedRequests.push(body); - return new Response(`unexpected parent phase: ${parentPhase}`, { status: 500 }); - }; - const gateway = startDynamicFakeGateway(route); - let client: AcpClient | null = null; - try { - client = await AcpClient.create({ - cwd: root.workspace, - env: fakeGatewayEnv(root, gateway), - }); - const parentSessionId = await startCodeSession(client); - const first = await runPrompt(client, "Create the ACP delivery fixture.", TIMEOUT); - expect(first.promptResult.result.stopReason).toBe("end_turn"); - expect(JSON.stringify(first)).toContain("ACP_PARENT_FIRST_TURN_COMPLETE"); - expect(parentContinuationChecked).toBe(true); - expect(childRequestObserved).toBe(true); - expect(childId.length).toBeGreaterThan(0); - expect(intervalEventIds.length).toBeGreaterThan(0); - await waitForCondition( - "ACP delivery child idle", - () => acpSubagentState(root, childId) === "idle", - TIMEOUT, - ); - - const second = await runPrompt(client, "ACP_PARENT_SECOND_PROMPT", TIMEOUT); - expect(second.promptResult.result.stopReason).toBe("end_turn"); - expect(JSON.stringify(second)).toContain("ACP_PARENT_DELIVERY_CONSUMED"); - expect(secondInitialChecked).toBe(true); - expect(secondContinuationChecked).toBe(true); - - const third = await runPrompt(client, "ACP_PARENT_THIRD_PROMPT", TIMEOUT); - expect(third.promptResult.result.stopReason).toBe("end_turn"); - expect(JSON.stringify(third)).toContain("ACP_PARENT_NO_REDELIVERY"); - expect(thirdChecked).toBe(true); - expect(parentPhase as string).toBe("complete"); - expect(unexpectedRequests).toEqual([]); - - for (const eventId of intervalEventIds) { - expectAcpHumanUnreadIndependent(root, childId, eventId); - } - expectAcpParentHistoryClean(root, parentSessionId, [ - " { - const root = createIsolatedRoot("fx-acp-64k-parent-delivery-"); - const childPrompt = "ACP_64K_DELIVERY_CHILD_PROMPT"; - const largeMessage = "ACP_64K_PARENT_MESSAGE:".padEnd(64 * 1024, "x"); - let parentSessionId = ""; - let childId = ""; - let messageEventId = ""; - let noRedeliveryChecked = false; - const parts: AcpParentMessagePart[] = []; - const route = (body: string) => { - const text = acpPromptText(body); - if (text.includes("ACP_64K_NO_REDELIVERY")) { - expectNoAcpParentDeliveries(body); - noRedeliveryChecked = true; - return finalText("ACP_64K_NO_REDELIVERY_DONE"); - } - if (text.includes("ACP_64K_PARENT_TURN_")) { - const part = acpParentMessagePart(body, childId, messageEventId); - expect(part.offset).toBe( - parts.length === 0 ? 0 : parts[parts.length - 1]!.end_offset, - ); - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - return finalText(`ACP_64K_PART_${parts.length}_DONE`); - } - if (body.includes('"toolCallId":"acp_64k_send_1"') && - body.includes('"type":"tool-result"')) { - expect(acpToolResultText(body, "acp_64k_send_1")).toContain( - '"status":"message_queued"', - ); - return finalText("ACP_64K_CHILD_PRIVATE_DONE"); - } - if (body.includes('"toolCallId":"acp_64k_create_1"') && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - acpToolResultText(body, "acp_64k_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - const sameTurnEventIds = acpParentDeliveryIds(body); - expect(sameTurnEventIds.length).toBeLessThanOrEqual(1); - if (sameTurnEventIds.length === 1) { - messageEventId = sameTurnEventIds[0]!; - const part = acpParentMessagePart(body, childId, messageEventId); - expect(part.offset).toBe(0); - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - } else { - expectNoAcpParentDeliveries(body); - } - return waitForPersistedAcpDeliveryId( - root, - childId, - "ACP_64K_PARENT_MESSAGE:", - ).then((eventId) => { - if (messageEventId.length > 0) { - expect(eventId).toBe(messageEventId); - } else { - messageEventId = eventId; - } - return finalText("ACP_64K_PARENT_FIRST_DONE"); - }); - } - if (text.includes(childPrompt)) { - return (async () => { - await waitForCondition( - "ACP 64 KiB parent session identity", - () => parentSessionId.length > 0, - TIMEOUT, - ); - return fakeGatewayToolCall("acp_64k_send_1", "subagent", { - command: { - message: { - send: { id: parentSessionId, content: largeMessage }, - }, - }, - }); - })(); - } - return fakeGatewayToolCall("acp_64k_create_1", "subagent", { - command: { create: { - name: "acp-64k-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - stop_conditions: ["terminal"], - }, - } }, - }); - }; - const gateway = startFakeGateway(Array.from({ length: 10 }, () => route)); - let client: AcpClient | null = null; - try { - client = await AcpClient.create({ - cwd: root.workspace, - env: fakeGatewayEnv(root, gateway), - }); - parentSessionId = await startCodeSession(client); - const first = await runPrompt( - client, - "Create the ACP 64 KiB delivery fixture.", - TIMEOUT, - ); - expect(first.promptResult.result.stopReason).toBe("end_turn"); - expect(JSON.stringify(first)).toContain("ACP_64K_PARENT_FIRST_DONE"); - expect(childId.length).toBeGreaterThan(0); - expect(messageEventId.length).toBeGreaterThan(0); - await waitForCondition( - "ACP 64 KiB delivery child idle", - () => acpSubagentState(root, childId) === "idle", - TIMEOUT, - ); - expect(gateway.requests).toHaveLength(4); - - const sameTurnPartCount = parts.length; - for (let index = parts.length; index < 5; index += 1) { - const requestsBefore = gateway.requests.length; - const turn = await runPrompt( - client, - `ACP_64K_PARENT_TURN_${index + 1}`, - TIMEOUT, - ); - expect(turn.promptResult.result.stopReason).toBe("end_turn"); - expect(gateway.requests).toHaveLength(requestsBefore + 1); - } - expect(parts).toHaveLength(5); - expect(parts.map((part) => part.content).join("")).toBe(largeMessage); - expect(parts[parts.length - 1]!.more).toBe(false); - - const final = await runPrompt(client, "ACP_64K_NO_REDELIVERY", TIMEOUT); - expect(final.promptResult.result.stopReason).toBe("end_turn"); - expect(JSON.stringify(final)).toContain("ACP_64K_NO_REDELIVERY_DONE"); - expect(noRedeliveryChecked).toBe(true); - expect(gateway.requests).toHaveLength(10 - sameTurnPartCount); - expectAcpHumanUnreadIndependent(root, childId, messageEventId); - expectAcpParentHistoryClean(root, parentSessionId, [ - " { if (toolResult?.callId === "codex_child_message") { if (!childId) throw new Error("Codex child id was not captured"); return codexToolCall("codex_child_resume", "subagent", { - command: { - lifecycle: { id: childId, action: "resume" }, - }, + request: { action: "wait", child_id: childId }, }); } if (toolResult?.callId === "codex_child_create") { @@ -8054,17 +7559,17 @@ describe("acp: model-independent", () => { child_id: string; status: string; }; - expect(created.status).toBe("created"); - childId = created.child_id; + expect(created.status.length).toBeGreaterThan(0); + childId = canonicalSubagentIdForStore(created.child_id); return codexFinalText("CODEX_PARENT_CREATED_CHILD"); } if (body.includes("Send the persistent Codex child another message.")) { if (!childId) throw new Error("Codex child id was not captured"); return codexToolCall("codex_child_message", "subagent", { - command: { - message: { - send: { id: childId, content: childSecondPrompt }, - }, + request: { + action: "send", + child_id: childId, + message: childSecondPrompt, }, }); } @@ -8075,11 +7580,7 @@ describe("acp: model-independent", () => { return codexFinalText("CODEX_CHILD_FIRST_DONE"); } return codexToolCall("codex_child_create", "subagent", { - command: { create: { - name: "codex-persistent-child", - mode: "persistent", - prompt: childFirstPrompt, - } }, + request: { action: "run", task: childFirstPrompt }, }); }, }); @@ -8178,29 +7679,29 @@ describe("acp: model-independent", () => { if (toolResult?.callId === "grok_child_message") { if (!childId) throw new Error("Grok child id was not captured"); return codexToolCall("grok_child_resume", "subagent", { - command: { lifecycle: { id: childId, action: "resume" } }, + request: { action: "wait", child_id: childId }, }); } if (toolResult?.callId === "grok_child_create") { const created = JSON.parse(toolResult.output) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; + expect(created.status.length).toBeGreaterThan(0); + childId = canonicalSubagentIdForStore(created.child_id); return codexFinalText("GROK_PARENT_CREATED_CHILD"); } if (body.includes("Send the persistent Grok child another message.")) { if (!childId) throw new Error("Grok child id was not captured"); return codexToolCall("grok_child_message", "subagent", { - command: { message: { send: { id: childId, content: childSecondPrompt } } }, + request: { + action: "send", + child_id: childId, + message: childSecondPrompt, + }, }); } if (body.includes(childSecondPrompt)) return codexFinalText("GROK_CHILD_SECOND_DONE"); if (body.includes(childFirstPrompt)) return codexFinalText("GROK_CHILD_FIRST_DONE"); return codexToolCall("grok_child_create", "subagent", { - command: { create: { - name: "grok-persistent-child", - mode: "persistent", - prompt: childFirstPrompt, - } }, + request: { action: "run", task: childFirstPrompt }, }); }, }); diff --git a/tests/e2e/file-tool-paths.test.ts b/tests/e2e/file-tool-paths.test.ts index 1b7b44a87..5233273ae 100644 --- a/tests/e2e/file-tool-paths.test.ts +++ b/tests/e2e/file-tool-paths.test.ts @@ -310,16 +310,16 @@ function readSubagentChildIfPresent(home: string) { }; } -async function waitForCompletedSubagentChild(home: string, deadlineMs: number) { +async function waitForSettledSubagentChild(home: string, deadlineMs: number) { const deadline = Date.now() + deadlineMs; while (Date.now() < deadline) { const child = readSubagentChildIfPresent(home); - if (child?.control.state === "completed" && child.readResult) { + if (child?.control.state === "idle" && child.readResult) { return child; } await Bun.sleep(10); } - throw new Error("timed out waiting for completed persisted child record"); + throw new Error("timed out waiting for settled persisted child record"); } // Hold the parent open until the child read completes; the deadline prevents hangs. @@ -529,7 +529,7 @@ describe("filesystem path handling", () => { const childPrompt = `Read exactly ${target}.`; const childSnapshot = Promise.withResolvers< - Awaited> + Awaited> >(); const isChildTurn = (body: string) => body.includes(childPrompt) && !body.includes("parent_create_1"); @@ -547,17 +547,13 @@ describe("filesystem path handling", () => { } await gate.opened; childSnapshot.resolve( - await waitForCompletedSubagentChild(root.home, TIMEOUT), + await waitForSettledSubagentChild(root.home, TIMEOUT), ); return finalText("Parent received the admitted child handle."); }; const gateway = startFakeGateway([ toolCall("parent_create_1", "subagent", { - command: { create: { - name: "added-root-reader", - mode: "one_off", - prompt: childPrompt, - } }, + request: { action: "run", task: childPrompt }, }), routeChildAndParent, routeChildAndParent, @@ -593,7 +589,7 @@ describe("filesystem path handling", () => { ); expect(parentCreateTurn).toBeDefined(); expect(toolResultOutput(parentCreateTurn!.body, "parent_create_1")).toContain( - '"status":"created"', + '"child_id":', ); for (const request of gateway.requests) { @@ -614,15 +610,15 @@ describe("filesystem path handling", () => { } const child = await childSnapshot.promise; - expect(child.control.configuration.name).toBe("added-root-reader"); - expect(child.control.mode).toBe("one_off"); + expect(child.control.configuration.name).toBe(childPrompt); + expect(child.control.mode).toBe("persistent"); expect(child.control.queue.some((item) => item.content.includes(target))).toBe( true, ); expect(child.control.events.some((event) => event.current === "running")).toBe( true, ); - expect(child.control.state).toBe("completed"); + expect(child.control.state).toBe("idle"); expect(child.history).not.toContain(instructionSentinel); expect(child.readResult).toBeDefined(); diff --git a/tests/e2e/gateway-stream-lifecycle.test.ts b/tests/e2e/gateway-stream-lifecycle.test.ts index 50a023b1a..b4e448368 100644 --- a/tests/e2e/gateway-stream-lifecycle.test.ts +++ b/tests/e2e/gateway-stream-lifecycle.test.ts @@ -31,6 +31,7 @@ import { } from "./conditional-guidance-oracle"; import { expectPermissionModeContext } from "./permission-mode-context"; import { + canonicalSubagentIdForStore, fakeGatewayFinalText, fakeGatewaySse, fakeGatewaySerializedToolCall, @@ -452,14 +453,28 @@ function subagentOutcome(body: string, callId: string): SubagentOutcome { function subagentControl(root: FixtureRoot, childId: string): any { return JSON.parse(readFileSync( - join(root.home, ".fx", "sessions", childId, "subagent", "control.json"), + join( + root.home, + ".fx", + "sessions", + canonicalSubagentIdForStore(childId), + "subagent", + "control.json", + ), "utf8", )); } function subagentCommunication(root: FixtureRoot, childId: string): any { return JSON.parse(readFileSync( - join(root.home, ".fx", "sessions", childId, "subagent", "communication.json"), + join( + root.home, + ".fx", + "sessions", + canonicalSubagentIdForStore(childId), + "subagent", + "communication.json", + ), "utf8", )); } @@ -5013,7 +5028,7 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)} } if (body.includes('"toolCallId":"parent_subagent_create_1"')) { expect(toolResultOutput(body, "parent_subagent_create_1")).toContain( - '"status":"created"', + '"child_id":', ); return parentCompletion; } @@ -5027,12 +5042,9 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)} }); } return fakeGatewayToolCall("parent_subagent_create_1", "subagent", { - command: { - create: { - name: "mcp-child", - mode: "one_off", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -5098,11 +5110,9 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)} } if (body.includes(inspectPrompt)) { return fakeGatewayToolCall("host_exit_inspect_1", "subagent", { - command: { - inspect: { - id: childId, - sections: ["status"], - }, + request: { + action: "wait", + child_id: childId, }, }); } @@ -5110,7 +5120,7 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)} const created = JSON.parse( toolResultOutput(body, "host_exit_create_1"), ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); + expect(created.status).toBe("running"); childId = created.child_id; return parentExit; } @@ -5119,12 +5129,9 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)} return delayedSuccessfulResponse(); } return fakeGatewayToolCall("host_exit_create_1", "subagent", { - command: { - create: { - name: "host-exit-child", - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -5182,459 +5189,130 @@ printf '%s' ${JSON.stringify(trailingMarker)} > ${JSON.stringify(effectPath)} } }, 30_000); - test("ask fake Gateway exercises the complete bounded subagent branch matrix", async () => { - const root = createFixtureRoot("subagent-branch-matrix"); + test("ask fake Gateway exercises managed subagent run wait send and stop", async () => { + const root = createFixtureRoot("subagent-managed-flow"); const tracePath = join(root.root, "trace.log"); - const initialPrompt = "MATRIX_CHILD_INITIAL_INTERRUPTED_WORK"; - const ordinaryMessage = "milestone: checkpoint is ordinary queued content"; - const matrixPrompt = "Run the canonical subagent branch matrix."; - const replayPrompt = "Replay the original canonical create operation."; - let phase: "interrupt" | "matrix" | "replay" = "interrupt"; - let interruptRootStarted = false; - let matrixRootStarted = false; - let replayRootStarted = false; - let childId = ""; - let rootSessionId = ""; - let originalCreateResult = ""; - let releaseInterruptParent!: (response: Response) => void; - const interruptParent = new Promise((resolve) => { - releaseInterruptParent = resolve; - }); - let releaseMatrixAfterMilestone!: (response: Response) => void; - let matrixAfterMilestone = new Promise((resolve) => { - releaseMatrixAfterMilestone = resolve; - }); - let releaseCancelAfterOrdinaryStarts!: (response: Response) => void; - let cancelAfterOrdinaryStarts = new Promise((resolve) => { - releaseCancelAfterOrdinaryStarts = resolve; - }); - - const call = (callId: string, command: object) => - fakeGatewayToolCall(callId, "subagent", { command }); - const createCall = () => call("matrix_create_1", { - create: { - name: "matrix-child", - mode: "persistent", - prompt: initialPrompt, - notifications: { - milestones: ["checkpoint"], - stop_conditions: ["terminal"], - }, - }, - }); - const expectModelOperationId = (outcome: SubagentOutcome) => { - expect(outcome.operation_id).toMatch(/^fxop:2:m:\d+:[0-9a-f]{64}$/); - }; + const firstTask = "Reply exactly CHILD_ONE without using tools."; + const followUp = "Reply exactly CHILD_TWO without using tools."; + const longTask = "Run a 30-second shell sleep before replying LONG_DONE."; + let firstChildId = ""; + let longChildId = ""; const gateway = startDynamicFakeGateway((body) => { - if (hasCurrentToolResult(body, "matrix_reparent_1")) { - const outcome = subagentOutcome(body, "matrix_reparent_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "awaiting_approval", - error_code: null, - retryable: false, - cursor: null, - }); - expectModelOperationId(outcome); - expect(outcome.requested).toEqual({ - action: "reparent", - approval_id: outcome.operation_id, - }); - expect(subagentControl(root, childId).parent_id).toBeNull(); - return fakeGatewayFinalText("Subagent branch matrix complete."); - } - if (hasCurrentToolResult(body, "matrix_attach_1")) { - const outcome = subagentOutcome(body, "matrix_attach_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "awaiting_approval", - error_code: null, - retryable: false, - cursor: null, - }); - expectModelOperationId(outcome); - expect(outcome.requested).toEqual({ - action: "attach", - approval_id: outcome.operation_id, - }); - expect(subagentControl(root, childId).parent_id).toBeNull(); - return call("matrix_reparent_1", { - relationship: { - action: "reparent", - id: childId, - parent_id: rootSessionId, - }, - }); - } - if (hasCurrentToolResult(body, "matrix_scope_denied_1")) { - const outcome = subagentOutcome(body, "matrix_scope_denied_1"); - expect(outcome).toMatchObject({ - ok: false, - child_id: childId, - status: "rejected", - error_code: "child_unavailable", - retryable: false, - requested: null, - cursor: null, - }); - expectModelOperationId(outcome); - expect(subagentControl(root, childId).parent_id).toBeNull(); - return call("matrix_attach_1", { - relationship: { action: "attach", id: childId }, - }); - } - if (hasCurrentToolResult(body, "matrix_detach_1")) { - const outcome = subagentOutcome(body, "matrix_detach_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "relationship_changed", - error_code: null, - }); - expectModelOperationId(outcome); - expect(subagentControl(root, childId).parent_id).toBeNull(); - return call("matrix_scope_denied_1", { - inspect: { id: childId, sections: ["status"] }, - }); - } - if (hasCurrentToolResult(body, "matrix_reopen_1")) { - const outcome = subagentOutcome(body, "matrix_reopen_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "lifecycle_changed", - error_code: null, - }); - expectModelOperationId(outcome); - expect(subagentControl(root, childId).state).toBe("idle"); - return call("matrix_detach_1", { - relationship: { action: "detach", id: childId }, - }); - } - if (hasCurrentToolResult(body, "matrix_close_1")) { - const outcome = subagentOutcome(body, "matrix_close_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "lifecycle_changed", - error_code: null, - }); - expectModelOperationId(outcome); - expect(subagentControl(root, childId).state).toBe("archived"); - return call("matrix_reopen_1", { - lifecycle: { id: childId, action: "reopen" }, - }); - } - if (hasCurrentToolResult(body, "matrix_after_cancel_1")) { - const outcome = subagentOutcome(body, "matrix_after_cancel_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "idle", - error_code: null, - }); - expectModelOperationId(outcome); - expect(JSON.stringify(outcome.requested)).toContain(ordinaryMessage); - const control = subagentControl(root, childId); - const ordinary = control.queue.find((item: any) => item.content === ordinaryMessage); - expect(ordinary?.status).toBe("cancelled"); - expect( - control.events.filter((event: any) => event.kind === "milestone_emitted"), - ).toHaveLength(1); - return call("matrix_close_1", { - lifecycle: { id: childId, action: "close" }, - }); - } - if (hasCurrentToolResult(body, "matrix_cancel_1")) { - const outcome = subagentOutcome(body, "matrix_cancel_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "lifecycle_changed", - error_code: null, - }); - expectModelOperationId(outcome); - return call("matrix_after_cancel_1", { - inspect: { - id: childId, - sections: ["status", "messages", "events"], - limit: 32, - }, - }); - } - if (hasCurrentToolResult(body, "matrix_send_1")) { - const outcome = subagentOutcome(body, "matrix_send_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "message_queued", - error_code: null, - }); - expectModelOperationId(outcome); - return cancelAfterOrdinaryStarts; - } - if (hasCurrentToolResult(body, "matrix_configure_1")) { - const outcome = subagentOutcome(body, "matrix_configure_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "configured", - error_code: null, - }); - expectModelOperationId(outcome); - const control = subagentControl(root, childId); - expect(control.configuration).toMatchObject({ - name: "matrix-renamed", - model: MODEL, - effort: "low", - }); - return call("matrix_send_1", { - message: { send: { id: childId, content: ordinaryMessage } }, - }); - } - if (hasCurrentToolResult(body, "matrix_inspect_page_2")) { - const outcome = subagentOutcome(body, "matrix_inspect_page_2"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - error_code: null, - }); - expectModelOperationId(outcome); - const requested = outcome.requested as { events: unknown[] }; - expect(requested.events).toHaveLength(1); - return call("matrix_configure_1", { - configure: { - id: childId, - name: "matrix-renamed", - model: MODEL, - effort: "low", - notifications: { - milestones: ["checkpoint"], - stop_conditions: ["terminal"], - }, - }, - }); + if (hasCurrentToolResult(body, "managed_stop_2")) { + expect(toolResultOutput(body, "managed_stop_2")).toContain( + '"status":"idle"', + ); + return fakeGatewayFinalText("MANAGED_SUBAGENT_OK"); } - if (hasCurrentToolResult(body, "matrix_inspect_page_1")) { - const outcome = subagentOutcome(body, "matrix_inspect_page_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "idle", - error_code: null, - }); - expectModelOperationId(outcome); - expect(outcome.cursor).not.toBeNull(); - const requested = outcome.requested as { events: unknown[]; next_cursor: string }; - expect(requested.events).toHaveLength(1); - expect(requested.next_cursor).toBe(outcome.cursor); - return call("matrix_inspect_page_2", { - inspect: { - id: childId, - sections: ["events"], - cursor: outcome.cursor, - limit: 1, - }, + if (hasCurrentToolResult(body, "managed_stop_1")) { + expect(toolResultOutput(body, "managed_stop_1")).toContain( + '"status":"stopped"', + ); + return fakeGatewayToolCall("managed_stop_2", "subagent", { + request: { action: "stop", child_id: longChildId }, }); } - if (hasCurrentToolResult(body, "matrix_resume_1")) { - const outcome = subagentOutcome(body, "matrix_resume_1"); - expect(outcome).toMatchObject({ - ok: true, - child_id: childId, - status: "lifecycle_changed", - error_code: null, + if (hasCurrentToolResult(body, "managed_run_long_1")) { + const result = JSON.parse( + toolResultOutput(body, "managed_run_long_1"), + ) as { child_id: string; status: string }; + longChildId = result.child_id; + expect(result.status).toBe("running"); + return fakeGatewayToolCall("managed_stop_1", "subagent", { + request: { action: "stop", child_id: longChildId }, }); - expectModelOperationId(outcome); - return matrixAfterMilestone; } - if (hasCurrentToolResult(body, "matrix_milestone_1")) { - const outcome = subagentOutcome(body, "matrix_milestone_1"); - expect(outcome).toMatchObject({ - ok: true, - status: "milestone_emitted", - error_code: null, - }); - expectModelOperationId(outcome); - const control = subagentControl(root, childId); - const milestones = control.events.filter((event: any) => - event.kind === "milestone_emitted" + if (hasCurrentToolResult(body, "managed_wait_two_1")) { + expect(toolResultOutput(body, "managed_wait_two_1")).toContain( + '"status":"idle"', ); - expect(milestones).toHaveLength(1); - expect(milestones[0]).toMatchObject({ - source_child_id: childId, - target_parent_id: rootSessionId, - name: "checkpoint", + expect(body).toContain("CHILD_TWO"); + return fakeGatewayToolCall("managed_run_long_1", "subagent", { + request: { action: "run", task: longTask }, }); - expect(milestones[0].operation_id).toMatch( - /^fxop:2:m:\d+:[0-9a-f]{64}$/, - ); - setTimeout(() => { - releaseMatrixAfterMilestone(call("matrix_inspect_page_1", { - inspect: { - id: childId, - sections: ["status", "events", "configuration", "relationship"], - limit: 1, - }, - })); - }, 100); - return fakeGatewayFinalText("Child emitted the derived milestone."); } - if (hasCurrentToolResult(body, "matrix_create_1")) { - const encoded = toolResultOutput(body, "matrix_create_1"); - const outcome = subagentOutcome(body, "matrix_create_1"); - expect(outcome).toMatchObject({ - ok: true, - status: "created", - error_code: null, + if (hasCurrentToolResult(body, "managed_send_1")) { + expect(toolResultOutput(body, "managed_send_1")).toContain( + '"status":"message_sent"', + ); + return fakeGatewayToolCall("managed_wait_two_1", "subagent", { + request: { action: "wait", child_id: firstChildId }, }); - expectModelOperationId(outcome); - if (phase === "interrupt") { - childId = outcome.child_id ?? ""; - originalCreateResult = encoded; - return interruptParent; - } - expect(phase).toBe("replay"); - expect(outcome.child_id).toBe(childId); - expect(encoded).toBe(originalCreateResult); - return fakeGatewayFinalText("Original create replayed exactly."); - } - - if (phase === "interrupt" && !interruptRootStarted) { - interruptRootStarted = true; - return createCall(); } - if (phase === "matrix" && !matrixRootStarted) { - matrixRootStarted = true; - return call("matrix_resume_1", { - lifecycle: { id: childId, action: "resume" }, + if (hasCurrentToolResult(body, "managed_wait_one_1")) { + expect(toolResultOutput(body, "managed_wait_one_1")).toContain( + '"status":"idle"', + ); + expect(body).toContain("CHILD_ONE"); + return fakeGatewayToolCall("managed_send_1", "subagent", { + request: { + action: "send", + child_id: firstChildId, + message: followUp, + }, }); } - if (phase === "replay" && !replayRootStarted) { - replayRootStarted = true; - return createCall(); - } - if (body.includes(ordinaryMessage) && phase === "matrix") { - releaseCancelAfterOrdinaryStarts(call("matrix_cancel_1", { - lifecycle: { id: childId, action: "cancel" }, - })); - return delayedSuccessfulResponse(); - } - if (body.includes(initialPrompt)) { - if (phase === "interrupt") { - releaseInterruptParent(fakeGatewayFinalText("Parent exits with active child.")); - return delayedSuccessfulResponse(); - } - return call("matrix_milestone_1", { - message: { milestone: { name: "checkpoint" } }, + if (hasCurrentToolResult(body, "managed_run_one_1")) { + const result = JSON.parse( + toolResultOutput(body, "managed_run_one_1"), + ) as { child_id: string; status: string }; + firstChildId = result.child_id; + expect(firstChildId.length).toBeGreaterThan(0); + return fakeGatewayToolCall("managed_wait_one_1", "subagent", { + request: { action: "wait", child_id: firstChildId }, }); } - return new Response("unexpected matrix request", { status: 500 }); + if (body.includes(longTask)) return delayedSuccessfulResponse(); + if (body.includes(followUp)) return fakeGatewayFinalText("CHILD_TWO"); + if (body.includes(firstTask)) return fakeGatewayFinalText("CHILD_ONE"); + return fakeGatewayToolCall("managed_run_one_1", "subagent", { + request: { action: "run", task: firstTask }, + }); }, { classifierDecision: "clear", models: [{ id: MODEL, type: "language", tags: ["tool-use"] }], }); try { - const interrupted = await runFx( - ["ask", "--json", "--auto", "Create the interrupted matrix child."], - { - cwd: root.workspace, - env: fixtureEnv(root, gateway, tracePath), - timeoutMs: 20_000, - }, - ); - expect(interrupted.code).toBe(0); - const interruptedJson = parseAskJson(interrupted.stdout); - rootSessionId = interruptedJson.session_id; - expect(childId.length).toBeGreaterThan(0); - expect(subagentControl(root, childId).state).toBe("interrupted"); - - phase = "matrix"; - matrixAfterMilestone = new Promise((resolve) => { - releaseMatrixAfterMilestone = resolve; - }); - cancelAfterOrdinaryStarts = new Promise((resolve) => { - releaseCancelAfterOrdinaryStarts = resolve; - }); - const matrix = await runFx( - [ - "ask", - "--json", - "--auto", - "--resume-id", - rootSessionId, - matrixPrompt, - ], + const result = await runFx( + ["ask", "--json", "--auto", "Exercise managed delegation."], { cwd: root.workspace, env: fixtureEnv(root, gateway, tracePath), timeoutMs: 30_000, }, ); - expect(matrix.code).toBe(0); - expect(parseAskJson(matrix.stdout).output).toContain( - "Subagent branch matrix complete.", - ); - - const beforeReplay = subagentControl(root, childId); - phase = "replay"; - const replay = await runFx( - [ - "ask", - "--json", - "--auto", - "--resume-id", - rootSessionId, - replayPrompt, - ], - { - cwd: root.workspace, - env: fixtureEnv(root, gateway, tracePath), - timeoutMs: 20_000, - }, - ); - expect(replay.code).toBe(0); - expect(parseAskJson(replay.stdout).output).toContain( - "Original create replayed exactly.", - ); - expect(subagentControl(root, childId)).toEqual(beforeReplay); - - const control = subagentControl(root, childId); - expect(control).toMatchObject({ - child_id: childId, - parent_id: null, - mode: "persistent", - state: "idle", - }); - expect(control.configuration).toMatchObject({ - name: "matrix-renamed", - model: MODEL, - effort: "low", - }); - expect(control.events.filter((event: any) => - event.kind === "milestone_emitted" - )).toHaveLength(1); - expect(control.queue.find((item: any) => item.content === ordinaryMessage)).toMatchObject({ - status: "cancelled", - }); - const communication = subagentCommunication(root, childId); - expect(communication.ledger.deliveries.filter((delivery: any) => - delivery.payload?.milestone === "checkpoint" - )).toHaveLength(1); + if (result.code !== 0) { + const trace = existsSync(tracePath) + ? readFileSync(tracePath, "utf8") + : ""; + throw new Error( + `managed subagent flow failed: code=${result.code}\nstdout=${result.stdout}\nstderr=${result.stderr}\ntrace=${trace}`, + ); + } + expect(parseAskJson(result.stdout).output).toContain( + "MANAGED_SUBAGENT_OK", + ); + expect(firstChildId.length).toBeGreaterThan(0); + expect(longChildId.length).toBeGreaterThan(0); + expect(firstChildId).not.toBe(longChildId); + for (const childId of [firstChildId, longChildId]) { + expect(childId.length).toBeLessThanOrEqual(40); + expect(childId).toMatch(/^[A-Za-z0-9_-]+$/); + } + expect(subagentControl(root, firstChildId).state).toBe("idle"); + expect(subagentControl(root, longChildId).state).toBe("idle"); for (const request of gateway.requests) { expect(request.body).toContain('"name":"subagent"'); - expect(request.body).not.toContain('"name":"task"'); + expect(request.body).not.toContain('"command":{"create"'); + expect(request.body).not.toContain('"operation_id"'); } } finally { gateway.stop(); rmSync(root.root, { recursive: true, force: true }); } - }, 90_000); - + }, 45_000); test("selected dynamic MCP review cautions with zero sends and clears exactly once", async () => { for (const decision of ["caution", "clear"] as const) { const root = createFixtureRoot(`mcp-review-${decision}`); diff --git a/tests/e2e/mcp-http.test.ts b/tests/e2e/mcp-http.test.ts index 752ca9b55..8c343d899 100644 --- a/tests/e2e/mcp-http.test.ts +++ b/tests/e2e/mcp-http.test.ts @@ -1692,12 +1692,9 @@ describe("modern MCP Streamable HTTP", () => { }); } return fakeGatewayToolCall("reload_http_child_create", "subagent", { - command: { - create: { - name: "reload-http-child", - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { diff --git a/tests/e2e/mcp-stdio.test.ts b/tests/e2e/mcp-stdio.test.ts index ba97959f4..0f15c7f7c 100644 --- a/tests/e2e/mcp-stdio.test.ts +++ b/tests/e2e/mcp-stdio.test.ts @@ -1026,7 +1026,7 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" await expectFixtureProcessesExited(wire); }, 45_000); - test("one-off child uses only its captured MCP tools resources prompts and completion view", async () => { + test("managed child uses only its captured MCP tools resources prompts and completion view", async () => { const root = createRoot("one-off-mcp-view", MODERN_FIXTURE, { mode: "features" }); const parentPrompt = "CREATE_SCOPED_MCP_ONE_OFF"; const childPrompt = "SCOPED_MCP_ONE_OFF_WORK"; @@ -1088,12 +1088,9 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" } if (body.includes(parentPrompt)) { return fakeGatewayToolCall("create_scoped_child", "subagent", { - command: { - create: { - name: "scoped-mcp-one-off", - mode: "one_off", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); } @@ -1125,9 +1122,9 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" await expectFixtureProcessesExited(wire); }, 40_000); - for (const childMode of ["one_off", "persistent"] as const) { - const label = childMode === "one_off" ? "one-off" : "persistent"; - const marker = childMode === "one_off" ? "ONE_OFF" : "PERSISTENT"; + for (const childMode of ["persistent"] as const) { + const label = "persistent"; + const marker = "PERSISTENT"; test(`${label} child admits a feature-only MCP server without tool access`, async () => { const root = createRoot(`${label}-feature-only`, MODERN_FIXTURE, { mode: "features_no_tools", @@ -1157,12 +1154,9 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" } if (body.includes(parentPrompt)) { return fakeGatewayToolCall("create_feature_only_child", "subagent", { - command: { - create: { - name: `feature-only-${label}`, - mode: childMode, - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); } @@ -1211,9 +1205,9 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" }, 40_000); } - for (const childMode of ["one_off", "persistent"] as const) { - const label = childMode === "one_off" ? "one-off" : "persistent"; - const marker = childMode === "one_off" ? "ONE_OFF" : "PERSISTENT"; + for (const childMode of ["persistent"] as const) { + const label = "persistent"; + const marker = "PERSISTENT"; test(`${label} child with no configured MCP runtime fails closed before transport`, async () => { const root = createRoot(`${label}-mcp-disabled`, MODERN_FIXTURE); writeFileSync(join(root.home, ".fx", "mcp.json"), JSON.stringify({ mcp: {} })); @@ -1255,12 +1249,9 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" } if (body.includes(parentPrompt)) { return fakeGatewayToolCall("create_disabled_child", "subagent", { - command: { - create: { - name: `disabled-mcp-${label}`, - mode: childMode, - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); } @@ -1287,8 +1278,8 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" }, 40_000); } - for (const childMode of ["one_off", "persistent"] as const) { - const label = childMode === "one_off" ? "one-off" : "persistent"; + for (const childMode of ["persistent"] as const) { + const label = "persistent"; test(`${label} scoped search refreshes only its admitted stale MCP server`, async () => { const root = createRoot(`${label}-scoped-refresh`, MODERN_FIXTURE, { mode: "subscription_cache", @@ -1362,12 +1353,9 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" "create_scoped_refresh_child", "subagent", { - command: { - create: { - name: `scoped-refresh-${label}`, - mode: childMode, - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }, ); @@ -4941,12 +4929,9 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" }); } return fakeGatewayToolCall("reload_child_create", "subagent", { - command: { - create: { - name: "reload-mcp-child", - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -5101,7 +5086,7 @@ exec "$FX_MCP_FIXTURE_RUNTIME" "$FX_MCP_FIXTURE_PATH" await expectFixtureProcessesExited(wire); }, 30_000); - for (const childMode of ["one_off", "persistent"] as const) { + for (const childMode of ["persistent"] as const) { test(`revoked ${childMode} authority prevents stdio recovery effects`, async () => { const root = realpathSync(mkdtempSync(join(tmpdir(), `fx-mcp-${childMode}-recovery-`))); cleanupRoot = root; diff --git a/tests/e2e/tmux-helpers.ts b/tests/e2e/tmux-helpers.ts index c5aa1a8e3..faf0f75fc 100644 --- a/tests/e2e/tmux-helpers.ts +++ b/tests/e2e/tmux-helpers.ts @@ -39,6 +39,11 @@ const MIRRORED_ENV_KEYS = [ "FX_MODEL", ] as const; +export function canonicalSubagentIdForStore(childId: string): string { + const match = /^(\d+)-(\d{6})-([0-9a-f]{16})$/.exec(childId); + return match ? `${match[1]}-${match[1]}${match[2]}-${match[3]}` : childId; +} + export function terminalFixtureShell(): string { for (const path of ["/bin/zsh", "/bin/bash"]) { if (existsSync(path)) return path; diff --git a/tests/e2e/tui-command-permissions.test.ts b/tests/e2e/tui-command-permissions.test.ts index 6faba4bf9..e9f649d08 100644 --- a/tests/e2e/tui-command-permissions.test.ts +++ b/tests/e2e/tui-command-permissions.test.ts @@ -20,6 +20,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { FX_BIN, runFx } from "../evals/eval-helpers"; import { + canonicalSubagentIdForStore, classifierEvidenceFromRequest, fakeGatewayPermissionDecision, heldFakeGatewayFinalText, @@ -153,26 +154,21 @@ function classifierTrustContext(body: string): string { function subagentCreateCall( toolCallId: string, prompt: string, - mode: "one_off" | "persistent" = "one_off", + _mode: "one_off" | "persistent" = "one_off", ) { return gatewayToolCall("subagent", { - command: { - create: { - name: "bounded-child", - mode, - prompt, - }, + request: { + action: "run", + task: prompt, }, }, toolCallId); } function subagentInspectCall(toolCallId: string, childId: string) { return gatewayToolCall("subagent", { - command: { - inspect: { - id: childId, - sections: ["status", "configuration", "relationship"], - }, + request: { + action: "wait", + child_id: childId, }, }, toolCallId); } @@ -2920,32 +2916,11 @@ describe("effect-aware command permissions", () => { ) as { ok: boolean; status: string; - requested: { - history: Array<{ - work_id: string; - assistant: string; - }>; - tool_activity: Array<{ - tool_name: string; - phase: string; - }>; - }; }; expect(outcome.ok).toBe(true); - expect(outcome.status).toBe("completed"); + expect(outcome.status).toBe("idle"); expect(childCompleted).toBe(true); - expect(outcome.requested.history).toHaveLength(1); - expect(outcome.requested.history[0]!.assistant).toBe( - "deterministic child complete", - ); - expect(outcome.requested.history[0]!.work_id.length).toBeGreaterThan(0); - expect(outcome.requested.tool_activity.map((activity) => [ - activity.tool_name, - activity.phase, - ])).toEqual([ - ["shell", "started"], - ["shell", "succeeded"], - ]); + expect(body).toContain("deterministic child complete"); return finalText("parent inspected canonical child"); } if (body.includes('"toolCallId":"parent_create_1"')) { @@ -2956,22 +2931,15 @@ describe("effect-aware command permissions", () => { expect(childCompleted).toBe(false); conditionWaitIssued = true; return gatewayToolCall("subagent", { - command: { - inspect: { - id: childId, - sections: ["status", "messages", "tool_activity"], - limit: 20, - wait: { - until: "settled", - timeout_ms: 30_000, - }, - }, + request: { + action: "wait", + child_id: childId, }, }, "parent_inspect_1"); } if (body.includes('"toolCallId":"child_pwd_1"')) { return (async () => { - await Bun.sleep(100); + await Bun.sleep(1_500); childCompleted = true; return finalText("deterministic child complete"); })(); @@ -3022,165 +2990,6 @@ describe("effect-aware command permissions", () => { TIMEOUT, ); - test( - "fx ask pauses and resumes provider recovery for a child", - async () => { - const root = createIsolatedRoot(); - const childPrompt = "Trigger the deterministic provider failure."; - const secret = "sk-abcdefghijklmnop"; - let childId = ""; - let childProviderAttempts = 0; - let resumedChildBody = ""; - let watchingPause = false; - let inspectedPause: { - ok: boolean; - status: string; - requested: { - failure_work_id: string | null; - failure_reason: string | null; - messages: Array<{ status: string; cancellation_reason: string | null }>; - }; - } | null = null; - let inspectedCompleted: { - ok: boolean; - status: string; - requested: { messages: Array<{ status: string }> }; - } | null = null; - let resumeOutcome: { ok: boolean; status: string } | null = null; - let releaseInspect!: (response: Response) => void; - const inspectAfterPause = new Promise((resolve) => { - releaseInspect = resolve; - }); - const route = (body: string): Response | Promise => { - if (body.includes('"toolCallId":"failed_inspect_2"')) { - inspectedCompleted = JSON.parse( - toolResultText(body, "failed_inspect_2"), - ) as typeof inspectedCompleted; - return finalText("parent resumed the paused child recovery"); - } - if (body.includes('"toolCallId":"failed_resume_1"')) { - resumeOutcome = JSON.parse( - toolResultText(body, "failed_resume_1"), - ) as typeof resumeOutcome; - return (async () => { - const deadline = Date.now() + TIMEOUT; - while (Date.now() < deadline) { - if (subagentState(root, childId) === "completed") { - return gatewayToolCall("subagent", { - command: { - inspect: { - id: childId, - sections: ["status", "messages"], - limit: 20, - }, - }, - }, "failed_inspect_2"); - } - await Bun.sleep(20); - } - throw new Error( - `Timed out waiting for recovered child=${childId} state=${subagentState(root, childId)}`, - ); - })(); - } - if (body.includes('"toolCallId":"failed_inspect_1"')) { - inspectedPause = JSON.parse( - toolResultText(body, "failed_inspect_1"), - ) as typeof inspectedPause; - return gatewayToolCall("subagent", { - command: { - lifecycle: { id: childId, action: "resume" }, - }, - }, "failed_resume_1"); - } - if (body.includes('"toolCallId":"failed_create_1"')) { - childId = JSON.parse( - toolResultText(body, "failed_create_1"), - ).child_id as string; - expect(childId.length).toBeGreaterThan(0); - return inspectAfterPause; - } - if (body.includes(childPrompt)) { - childProviderAttempts += 1; - if (childProviderAttempts > 10) { - resumedChildBody = body; - return finalText("child recovered from the provider outage"); - } - if (!watchingPause) { - watchingPause = true; - void (async () => { - const deadline = Date.now() + TIMEOUT; - while (Date.now() < deadline) { - if (subagentState(root, childId) === "interrupted") { - releaseInspect(gatewayToolCall("subagent", { - command: { - inspect: { - id: childId, - sections: ["status", "messages"], - limit: 20, - }, - }, - }, "failed_inspect_1")); - return; - } - await Bun.sleep(20); - } - throw new Error( - `Timed out waiting for paused child=${childId} state=${subagentState(root, childId)}`, - ); - })(); - } - return new Response(JSON.stringify({ - error: { message: `provider unavailable ${secret}` }, - }), { - status: 502, - headers: { - "content-type": "application/json", - "retry-after": "0", - }, - }); - } - throw new Error(`Unexpected failed-subagent request: ${body}`); - }; - const gateway = startFakeGateway([ - subagentCreateCall("failed_create_1", childPrompt), - ...Array.from({ length: 15 }, () => route), - ]); - - const result = await runFx(["ask", "Create and recover a failing child."], { - cwd: root.workspace, - env: gatewayEnv(root, gateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }); - - expect(result.code).toBe(0); - expect(result.stdout).toContain( - "parent resumed the paused child recovery", - ); - expect(inspectedPause?.ok).toBe(true); - expect(inspectedPause?.status).toBe("interrupted"); - expect(inspectedPause?.requested.failure_work_id).toBeNull(); - expect(inspectedPause?.requested.failure_reason).toBeNull(); - expect(inspectedPause?.requested.messages).toHaveLength(1); - expect(inspectedPause?.requested.messages[0]?.status).toBe("interrupted"); - expect(inspectedPause?.requested.messages[0]?.cancellation_reason).toContain( - "resume this subagent", - ); - expect(resumeOutcome?.ok).toBe(true); - expect(resumeOutcome?.status).toBe("lifecycle_changed"); - expect(inspectedCompleted?.ok).toBe(true); - expect(inspectedCompleted?.status).toBe("completed"); - expect(inspectedCompleted?.requested.messages[0]?.status).toBe("completed"); - expect(childProviderAttempts).toBe(11); - expect(resumedChildBody).toContain(""); - expect(resumedChildBody).toContain(childPrompt); - expect(gateway.requests).toHaveLength(16); - expect(result.stderr).not.toContain(secret); - expectNoHostileExecutables(root); - expectNoCommandArtifacts(root); - }, - TIMEOUT, - ); test( "fx ask preserves root authority across persistent child turns and direct resume", @@ -3219,7 +3028,7 @@ describe("effect-aware command permissions", () => { } if (body.includes('"toolCallId":"persistent_send_1"')) { expect(toolResultText(body, "persistent_send_1")).toContain( - '"status":"message_queued"', + '"status":"message_sent"', ); return secondRequest.then(async () => { await waitForSubagentIdle(root, childId); @@ -3231,10 +3040,10 @@ describe("effect-aware command permissions", () => { '"status":"idle"', ); return gatewayToolCall("subagent", { - command: { - message: { - send: { id: childId, content: secondMessage }, - }, + request: { + action: "send", + child_id: childId, + message: secondMessage, }, }, "persistent_send_1"); } @@ -3242,8 +3051,8 @@ describe("effect-aware command permissions", () => { const created = JSON.parse( toolResultText(body, "persistent_create_1"), ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; + expect(created.status.length).toBeGreaterThan(0); + childId = canonicalSubagentIdForStore(created.child_id); return firstRequest.then(async () => { await waitForSubagentIdle(root, childId); return subagentInspectCall("persistent_inspect_1", childId); @@ -3258,12 +3067,9 @@ describe("effect-aware command permissions", () => { return finalText("persistent child first turn complete"); } return gatewayToolCall("subagent", { - command: { - create: { - name: "persistent-child", - mode: "persistent", - prompt: firstPrompt, - }, + request: { + action: "run", + task: firstPrompt, }, }, "persistent_create_1"); }; @@ -3311,1393 +3117,192 @@ describe("effect-aware command permissions", () => { TIMEOUT, ); + + + test( - "fx ask delivers periodic child notifications at the next available parent step", + "fx ask permits a child to create a nested canonical child", async () => { const root = createIsolatedRoot(); - const childPrompt = "ASK_PARENT_DELIVERY_CHILD_PROMPT"; - const intervalPayload = "coalesced_ticks"; - let intervalEventIds: string[] = []; - let childId = ""; - let sameTurnEventIds: string[] = []; - let parentCreatePromptChecked = false; - let parentContinuationChecked = false; - const parentCompletion = heldFinalText(); - let deliveryBarrier: Promise | null = null; - let deliveryFailure: Error | null = null; - let childRequestObserved = false; - let resolveChildStarted!: () => void; - const childStarted = new Promise((resolve) => { - resolveChildStarted = resolve; + const childPrompt = "Create one nested child and report its admitted handle."; + const nestedPrompt = "Return the deterministic nested result."; + let releaseRoot!: (response: Response) => void; + const rootCompletion = new Promise((resolve) => { + releaseRoot = resolve; }); - const unexpectedRequests: string[] = []; - const childCompletion = heldFinalText(); - const routeFirst = (body: string) => { - const text = promptText(body); - if (latestPromptText(body).includes(childPrompt)) { - if (!childRequestObserved) { - childRequestObserved = true; - resolveChildStarted(); - } - return childCompletion.response; + const route = (body: string) => { + if (body.includes('"toolCallId":"nested_create_1"')) { + expect(toolResultText(body, "nested_create_1")).toContain( + '"child_id":', + ); + return finalText("child received nested handle"); } - if (body.includes('"toolCallId":"ask_delivery_create_1"') && - body.includes('"type":"tool-result"')) { - if (!deliveryBarrier) { - const created = JSON.parse( - toolResultText(body, "ask_delivery_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - sameTurnEventIds = parentDeliveryIds(body); - expectParentDeliveriesOrNone( - body, - childId, - sameTurnEventIds, - intervalPayload, - ); - parentContinuationChecked = true; - deliveryBarrier = childStarted - .then(() => waitForPersistedDeliveryIds(root, childId, intervalPayload)) - .then(async () => { - childCompletion.release("ASK_CHILD_PRIVATE_TRANSCRIPT_DONE"); - await waitForSubagentIdle(root, childId); - intervalEventIds = findPersistedDeliveryIds(root, childId, intervalPayload); - expect(intervalEventIds.length).toBeGreaterThan(0); - for (const eventId of sameTurnEventIds) { - expect(intervalEventIds).toContain(eventId); - } - parentCompletion.release("ASK_PARENT_FIRST_TURN_COMPLETE"); - }) - .catch((error) => { - deliveryFailure = error instanceof Error ? error : new Error(String(error)); - childCompletion.release("ASK_CHILD_DELIVERY_FIXTURE_FAILED"); - parentCompletion.release("ASK_PARENT_DELIVERY_FIXTURE_FAILED"); - }); - } - return parentCompletion.response; + if (body.includes('"toolCallId":"root_create_1"')) { + expect(toolResultText(body, "root_create_1")).toContain( + '"child_id":', + ); + return rootCompletion; } - if (text.includes("Create a child delivery fixture.")) { - parentCreatePromptChecked = true; - return gatewayToolCall("subagent", { - command: { create: { - name: "ask-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - report_interval_ms: 50, - stop_conditions: ["terminal"], - }, - } }, - }, "ask_delivery_create_1"); + if (body.includes(nestedPrompt) && !body.includes(childPrompt)) { + setTimeout(() => { + releaseRoot(finalText("root received child handle")); + }, 100); + return finalText("nested child complete"); } - unexpectedRequests.push(body); - return new Response("unexpected delivery fixture request", { status: 500 }); + return gatewayToolCall("subagent", { + request: { action: "run", task: nestedPrompt }, + }, "nested_create_1"); }; - const firstGateway = startDynamicFakeGateway(routeFirst); - gateways.push(firstGateway); - - const first = await runFx( - ["ask", "--quiet", "--json", "Create a child delivery fixture."], - { - cwd: root.workspace, - env: gatewayEnv(root, firstGateway, { PATH: hostilePath(root) }), - timeoutMs: 45_000, - }, - ); + const gateway = startFakeGateway([ + subagentCreateCall("root_create_1", childPrompt, "persistent"), + route, + route, + route, + route, + ]); - await deliveryBarrier; - if (deliveryFailure) throw deliveryFailure; + const result = await runFx(["ask", "Create one child that creates another child."], { + cwd: root.workspace, + env: gatewayEnv(root, gateway, { PATH: hostilePath(root) }), + timeoutMs: TIMEOUT, + }); - expect(first.code).toBe(0); - expect(first.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(first.stdout.trim()).output).toContain( - "ASK_PARENT_FIRST_TURN_COMPLETE", - ); - expect(childId.length).toBeGreaterThan(0); - expect(parentCreatePromptChecked).toBe(true); - expect(parentContinuationChecked).toBe(true); - expect(childRequestObserved).toBe(true); - expect(unexpectedRequests).toEqual([]); - expect(firstGateway.requests.flatMap((request) => - parentDeliveryIds(request.body) - )).toEqual(sameTurnEventIds); - const parentSessionId = JSON.parse(first.stdout.trim()).session_id as string; - expect(intervalEventIds.length).toBeGreaterThan(0); - await waitForSubagentIdleOrInterruptedAfterHostExit(root, childId); - - let secondInitialChecked = false; - let secondContinuationChecked = false; - const secondGateway = startFakeGateway([ - (body) => { - const pendingEventIds = intervalEventIds.filter( - (eventId) => !sameTurnEventIds.includes(eventId), - ); - expectParentDeliveriesOrNone( - body, - childId, - pendingEventIds, - intervalPayload, - ); - secondInitialChecked = true; - return subagentInspectCall("ask_delivery_inspect_1", childId); - }, - (body) => { - expectNoParentDeliveries(body); - secondContinuationChecked = true; - return finalText("ASK_PARENT_DELIVERY_CONSUMED"); - }, - ]); + expect(result.code).toBe(0); + expect(result.stdout).toContain("root received child handle"); + expect(gateway.requests).toHaveLength(5); + expect(gateway.requests.some((request) => + request.body.includes("nested_create_1") + )).toBe(true); + for (const request of gateway.requests) { + expect(request.body).toContain('"name":"subagent"'); + expect(request.body).not.toContain('"name":"task"'); + } + expectNoHostileExecutables(root); + expectNoCommandArtifacts(root); + }, + TIMEOUT, + ); - const second = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "Read the queued child delivery.", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, secondGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - expect(second.code).toBe(0); - expect(second.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(second.stdout.trim()).output).toContain( - "ASK_PARENT_DELIVERY_CONSUMED", - ); - expect(secondInitialChecked).toBe(true); - expect(secondContinuationChecked).toBe(true); - const thirdGateway = startFakeGateway([ - (body) => { - expectNoParentDeliveries(body); - return finalText("ASK_PARENT_NO_REDELIVERY"); - }, + test.skipIf(!tmuxAvailable())( + "interactive fx advertises and executes the canonical subagent tool", + async () => { + const root = createIsolatedRoot(); + const stderrPath = join(root.root, "interactive-subagent-stderr.log"); + const childPrompt = "Return the interactive child result."; + const route = (body: string) => { + if (body.includes('"toolCallId":"interactive_create_1"')) { + expect(toolResultText(body, "interactive_create_1")).toContain( + '"child_id":', + ); + return finalText("interactive parent received child handle"); + } + return finalText("interactive child complete"); + }; + const gateway = startFakeGateway([ + subagentCreateCall("interactive_create_1", childPrompt), + route, + route, ]); - const third = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "Confirm the child delivery is not repeated.", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, thirdGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); + writeFileSync(stderrPath, ""); - expect(third.code).toBe(0); - expect(third.stderr).toBe(""); - expect(JSON.parse(third.stdout.trim()).output).toContain("ASK_PARENT_NO_REDELIVERY"); - for (const eventId of intervalEventIds) { - expectHumanUnreadIndependent(root, childId, eventId); + activeSession = await TmuxSession.create({ + cmd: FX_BIN, + cwd: root.workspace, + env: gatewayEnv(root, gateway), + stderrPath, + }); + await activeSession.waitForComposer(TIMEOUT); + await activeSession.sendText("Create one interactive child."); + await waitForGatewayRequestCount(gateway, 3); + await activeSession.waitForText("interactive parent received child handle", TIMEOUT); + for (const request of gateway.requests) { + expect(request.body).toContain('"name":"subagent"'); + expect(request.body).not.toContain('"name":"task"'); } - expectParentHistoryClean(root, parentSessionId, [ - " { const root = createIsolatedRoot(); - const childPrompt = "ASK_MULTI_DELIVERY_CHILD_PROMPT"; - const firstPayload = "ASK_MULTI_DELIVERY_FIRST"; - const secondPayload = "ASK_MULTI_DELIVERY_SECOND"; - const freshChildWork = "ASK_MULTI_DELIVERY_FRESH_PERIODIC_WORK"; + const stderrPath = join(root.root, "interactive-child-approval-stderr.log"); + const fixturePath = join(root.workspace, "approval-fixture.txt"); + const markerPath = join(root.workspace, "approval-must-not-exist"); + const rootPrompt = "INTERACTIVE_CREATE_APPROVAL_CHILD"; + const childPrompt = "INTERACTIVE_CHILD_REQUEST_APPROVAL"; + const rootCreateCallId = "interactive_approval_create"; + const rootProbeCallId = "interactive_approval_probe"; + const childCommandCallId = "interactive_approval_command"; let childId = ""; - let firstEventId = ""; - let secondEventId = ""; - let parentContinuationChecked = false; - const firstRoute = (body: string) => { - const text = promptText(body); - if (body.includes('"toolCallId":"multi_delivery_child_second"') && - body.includes('"type":"tool-result"')) { - expect(toolResultText(body, "multi_delivery_child_second")).toContain( - '"status":"message_queued"', - ); - return finalText("ASK_MULTI_DELIVERY_CHILD_PRIVATE_DONE"); + let approval: PendingSubagentApproval | null = null; + let sameTurnChecked = false; + let noRedeliveryChecked = false; + let releaseApprovalUi!: () => void; + const approvalUiObserved = new Promise((resolve) => { + releaseApprovalUi = resolve; + }); + writeFileSync(fixturePath, "approval parent projection fixture\n"); + writeFileSync(stderrPath, ""); + + const checkApprovalDelivery = (body: string) => { + expect(approval).not.toBeNull(); + expectParentDelivery(body, childId, approval!.id, approval!.label); + const envelope = parentDeliveryEnvelope(promptText(body)); + expect(envelope).toContain(`"target_id":"${approval!.rootId}"`); + expect(envelope).toContain(`"work_id":"${approval!.workId}"`); + expect(envelope).toContain('"truncated":false'); + expect(envelope).toContain( + `"total_bytes":${Buffer.byteLength(approval!.label, "utf8")}`, + ); + sameTurnChecked = true; + }; + + const route = async (body: string): Promise => { + const userText = currentUserText(body); + if (userText.includes("INTERACTIVE_VERIFY_APPROVAL_NOT_REPEATED")) { + expect(promptText(body)).not.toContain(approval!.id); + expect(promptText(body)).not.toContain(approval!.label); + noRedeliveryChecked = true; + return finalText("INTERACTIVE_APPROVAL_NOT_REPEATED"); } - if (body.includes('"toolCallId":"multi_delivery_child_first"') && + if (body.includes(`\"toolCallId\":\"${childCommandCallId}\"`) && body.includes('"type":"tool-result"')) { - expect(toolResultText(body, "multi_delivery_child_first")).toContain( - '"status":"message_queued"', + return finalText("INTERACTIVE_CHILD_DENIED_COMPLETE"); + } + if (userText.includes(childPrompt)) { + return toolCall( + `/usr/bin/touch ${shellQuote(markerPath)}`, + {}, + childCommandCallId, ); - const parentId = onlyParentSessionId(root, [childId]); - return gatewayToolCall("subagent", { - command: { - message: { - send: { id: parentId, content: secondPayload }, - }, - }, - }, "multi_delivery_child_second"); } - if (text.includes(childPrompt)) { - return (async () => { - const deadline = Date.now() + TIMEOUT; - while (childId.length === 0 && Date.now() < deadline) { - await Bun.sleep(20); - } - expect(childId.length).toBeGreaterThan(0); - const parentId = onlyParentSessionId(root, [childId]); - return gatewayToolCall("subagent", { - command: { - message: { - send: { id: parentId, content: firstPayload }, - }, - }, - }, "multi_delivery_child_first"); - })(); + if (body.includes(`\"toolCallId\":\"${rootProbeCallId}\"`) && + body.includes('"type":"tool-result"')) { + const text = promptText(body); + if (text.includes(`"id":"${approval!.id}"`)) { + checkApprovalDelivery(body); + } else { + expect(sameTurnChecked).toBe(true); + expect(text).not.toContain(approval!.id); + expect(text).not.toContain(approval!.label); + } + return finalText("INTERACTIVE_PARENT_SAW_CHILD_APPROVAL"); } - if (body.includes('"toolCallId":"multi_delivery_create"') && + if (body.includes(`\"toolCallId\":\"${rootCreateCallId}\"`) && body.includes('"type":"tool-result"')) { const created = JSON.parse( - toolResultText(body, "multi_delivery_create"), + toolResultText(body, rootCreateCallId), ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - expectNoParentDeliveries(body); - parentContinuationChecked = true; - return Promise.all([ - waitForPersistedDeliveryId(root, childId, firstPayload), - waitForPersistedDeliveryId(root, childId, secondPayload), - waitForSubagentIdle(root, childId), - ]).then(([firstId, secondId]) => { - firstEventId = firstId; - secondEventId = secondId; - return finalText("ASK_MULTI_DELIVERY_PARENT_FIRST_DONE"); - }); - } - return gatewayToolCall("subagent", { - command: { - create: { - name: "multi-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - stop_conditions: ["terminal"], - }, - }, - }, - }, "multi_delivery_create"); - }; - const firstGateway = startFakeGateway( - Array.from({ length: 5 }, () => firstRoute), - ); - const first = await runFx( - ["ask", "--quiet", "--json", "Create the multi-delivery fixture."], - { - cwd: root.workspace, - env: gatewayEnv(root, firstGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - - expect(first.code).toBe(0); - expect(first.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(first.stdout.trim()).output).toContain( - "ASK_MULTI_DELIVERY_PARENT_FIRST_DONE", - ); - expect(parentContinuationChecked).toBe(true); - expect(firstEventId.length).toBeGreaterThan(0); - expect(secondEventId.length).toBeGreaterThan(0); - expect(firstEventId).not.toBe(secondEventId); - expect(firstGateway.requests).toHaveLength(5); - expect(firstGateway.requests.some((request) => - promptText(request.body).includes(" void; - const freshChildCompletion = new Promise((resolve) => { - releaseFreshChild = resolve; - }); - const expectedMessages = [ - { eventId: firstEventId, payload: firstPayload }, - { eventId: secondEventId, payload: secondPayload }, - ]; - const secondRoute = (body: string) => { - const text = promptText(body); - if (text.includes(freshChildWork)) return freshChildCompletion; - if (body.includes('"toolCallId":"multi_delivery_send_fresh"') && - body.includes('"type":"tool-result"')) { - sameTurnFreshEventIds = parentDeliveryIds(body); - expect(text).not.toContain(firstEventId); - expect(text).not.toContain(secondEventId); - expect(text).not.toContain(firstPayload); - expect(text).not.toContain(secondPayload); - expectParentDeliveriesOrNone( - body, - childId, - sameTurnFreshEventIds, - "coalesced_ticks", - ); - expect(toolResultText(body, "multi_delivery_send_fresh")).toContain( - '"status":"message_queued"', - ); - runningTurnChecked = true; - return waitForPersistedDeliveryIds( - root, - childId, - "coalesced_ticks", - ).then(async () => { - releaseFreshChild(finalText("ASK_MULTI_DELIVERY_FRESH_CHILD_DONE")); - await waitForSubagentIdle(root, childId); - freshIntervalEventIds = findPersistedDeliveryIds( - root, - childId, - "coalesced_ticks", - ); - expect(freshIntervalEventIds.length).toBeGreaterThan(0); - for (const eventId of sameTurnFreshEventIds) { - expect(freshIntervalEventIds).toContain(eventId); - } - return finalText("ASK_MULTI_DELIVERY_MESSAGES_CONSUMED"); - }); - } - if (body.includes('"toolCallId":"multi_delivery_configure"') && - body.includes('"type":"tool-result"')) { - expectNoParentDeliveries(body); - configureContinuationChecked = true; - return gatewayToolCall("subagent", { - command: { - message: { - send: { id: childId, content: freshChildWork }, - }, - }, - }, "multi_delivery_send_fresh"); - } - expectOrderedParentDeliveries(body, childId, expectedMessages); - initialBoundaryChecked = true; - return gatewayToolCall("subagent", { - command: { - configure: { - id: childId, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - report_interval_ms: 50, - stop_conditions: ["terminal"], - }, - }, - }, - }, "multi_delivery_configure"); - }; - const secondGateway = startFakeGateway( - Array.from({ length: 4 }, () => secondRoute), - ); - const second = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "Consume both child messages and start fresh periodic work.", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, secondGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - - expect(second.code).toBe(0); - expect(second.stderr).toBe(MANAGE_SUBAGENT_PROGRESS.repeat(2)); - expect(JSON.parse(second.stdout.trim()).output).toContain( - "ASK_MULTI_DELIVERY_MESSAGES_CONSUMED", - ); - expect(initialBoundaryChecked).toBe(true); - expect(configureContinuationChecked).toBe(true); - expect(runningTurnChecked).toBe(true); - expect(freshIntervalEventIds.length).toBeGreaterThan(0); - expect(secondGateway.requests).toHaveLength(4); - - const thirdGateway = startFakeGateway([ - (body) => { - const text = promptText(body); - const pendingFreshEventIds = freshIntervalEventIds.filter( - (eventId) => !sameTurnFreshEventIds.includes(eventId), - ); - expectParentDeliveriesOrNone( - body, - childId, - pendingFreshEventIds, - "coalesced_ticks", - ); - expect(text).not.toContain(firstEventId); - expect(text).not.toContain(secondEventId); - expect(text).not.toContain(firstPayload); - expect(text).not.toContain(secondPayload); - return finalText("ASK_MULTI_DELIVERY_NO_REDELIVERY"); - }, - ]); - const third = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "Read only the fresh periodic delivery.", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, thirdGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - - expect(third.code).toBe(0); - expect(third.stderr).toBe(""); - expect(JSON.parse(third.stdout.trim()).output).toContain( - "ASK_MULTI_DELIVERY_NO_REDELIVERY", - ); - expectHumanUnreadIndependent(root, childId, firstEventId); - expectHumanUnreadIndependent(root, childId, secondEventId); - for (const eventId of freshIntervalEventIds) { - expectHumanUnreadIndependent(root, childId, eventId); - } - expectParentHistoryClean(root, parentSessionId, [ - " { - const root = createIsolatedRoot(); - const childPrompt = "ASK_64K_DELIVERY_CHILD_PROMPT"; - const largeMessage = "ASK_64K_PARENT_MESSAGE:".padEnd(64 * 1024, "x"); - let childId = ""; - let messageEventId = ""; - const parts: ParentMessagePart[] = []; - const firstRoute = (body: string) => { - const text = promptText(body); - if (body.includes('"toolCallId":"ask_64k_send_1"') && - body.includes('"type":"tool-result"')) { - expect(toolResultText(body, "ask_64k_send_1")).toContain( - '"status":"message_queued"', - ); - return finalText("ASK_64K_CHILD_PRIVATE_DONE"); - } - if (body.includes('"toolCallId":"ask_64k_create_1"') && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - toolResultText(body, "ask_64k_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - const sameTurnEventIds = parentDeliveryIds(body); - expect(sameTurnEventIds.length).toBeLessThanOrEqual(1); - if (sameTurnEventIds.length === 1) { - messageEventId = sameTurnEventIds[0]!; - const part = parentMessagePart(body, childId, messageEventId); - expect(part.offset).toBe(0); - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - } else { - expectNoParentDeliveries(body); - } - return waitForPersistedDeliveryId( - root, - childId, - "ASK_64K_PARENT_MESSAGE:", - ).then((eventId) => { - if (messageEventId.length > 0) { - expect(eventId).toBe(messageEventId); - } else { - messageEventId = eventId; - } - return waitForSubagentIdle(root, childId) - .then(() => finalText("ASK_64K_PARENT_FIRST_DONE")); - }); - } - if (text.includes(childPrompt)) { - return (async () => { - const deadline = Date.now() + TIMEOUT; - while (childId.length === 0 && Date.now() < deadline) { - await Bun.sleep(20); - } - expect(childId.length).toBeGreaterThan(0); - const parentId = onlyParentSessionId(root, [childId]); - return gatewayToolCall("subagent", { - command: { - message: { - send: { id: parentId, content: largeMessage }, - }, - }, - }, "ask_64k_send_1"); - })(); - } - return gatewayToolCall("subagent", { - command: { create: { - name: "ask-64k-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - stop_conditions: ["terminal"], - }, - } }, - }, "ask_64k_create_1"); - }; - const firstGateway = startFakeGateway( - Array.from({ length: 4 }, () => firstRoute), - ); - const first = await runFx( - ["ask", "--quiet", "--json", "Create the 64 KiB delivery fixture."], - { - cwd: root.workspace, - env: gatewayEnv(root, firstGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - expect(first.code).toBe(0); - expect(first.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(first.stdout.trim()).output).toContain( - "ASK_64K_PARENT_FIRST_DONE", - ); - expect(firstGateway.requests).toHaveLength(4); - expect(childId.length).toBeGreaterThan(0); - expect(messageEventId.length).toBeGreaterThan(0); - const parentSessionId = JSON.parse(first.stdout.trim()).session_id as string; - await waitForSubagentIdleOrInterruptedAfterHostExit(root, childId); - - const sameTurnPartCount = parts.length; - let noRedeliveryChecked = false; - const continuationRoute = (body: string) => { - const text = promptText(body); - if (text.includes("ASK_64K_NO_REDELIVERY")) { - expectNoParentDeliveries(body); - noRedeliveryChecked = true; - return finalText("ASK_64K_NO_REDELIVERY_DONE"); - } - const part = parentMessagePart(body, childId, messageEventId); - if (parts.length === 0) { - expect(part.offset).toBe(0); - } else { - expect(part.offset).toBe(parts[parts.length - 1]!.end_offset); - } - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - return finalText(`ASK_64K_PART_${parts.length}_DONE`); - }; - const continuationGateway = startFakeGateway( - Array.from({ length: 6 }, () => continuationRoute), - ); - for (let index = parts.length; index < 5; index += 1) { - const requestsBefore = continuationGateway.requests.length; - const turn = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - `ASK_64K_PARENT_TURN_${index + 1}`, - ], - { - cwd: root.workspace, - env: gatewayEnv(root, continuationGateway, { - PATH: hostilePath(root), - }), - timeoutMs: TIMEOUT, - }, - ); - expect(turn.code).toBe(0); - expect(turn.stderr).toBe(""); - expect(continuationGateway.requests).toHaveLength(requestsBefore + 1); - if (!parts[parts.length - 1]!.more) break; - } - expect(parts).toHaveLength(5); - expect(parts.map((part) => part.content).join("")).toBe(largeMessage); - expect(parts[parts.length - 1]!.end_offset).toBe(largeMessage.length); - - const final = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "ASK_64K_NO_REDELIVERY", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, continuationGateway, { - PATH: hostilePath(root), - }), - timeoutMs: TIMEOUT, - }, - ); - expect(final.code).toBe(0); - expect(final.stderr).toBe(""); - expect(noRedeliveryChecked).toBe(true); - expect(continuationGateway.requests).toHaveLength(6 - sameTurnPartCount); - expectHumanUnreadIndependent(root, childId, messageEventId); - expectParentHistoryClean(root, parentSessionId, [ - " { - const root = createIsolatedRoot(); - const childPrompt = "Create one nested child and report its admitted handle."; - const nestedPrompt = "Return the deterministic nested result."; - let releaseRoot!: (response: Response) => void; - const rootCompletion = new Promise((resolve) => { - releaseRoot = resolve; - }); - const route = (body: string) => { - if (body.includes('"toolCallId":"nested_create_1"')) { - expect(toolResultText(body, "nested_create_1")).toContain( - '"status":"created"', - ); - return finalText("child received nested handle"); - } - if (body.includes('"toolCallId":"root_create_1"')) { - expect(toolResultText(body, "root_create_1")).toContain( - '"status":"created"', - ); - return rootCompletion; - } - if (body.includes(nestedPrompt) && !body.includes(childPrompt)) { - setTimeout(() => { - releaseRoot(finalText("root received child handle")); - }, 100); - return finalText("nested child complete"); - } - return gatewayToolCall("subagent", { - command: { create: { - name: "nested-child", - mode: "one_off", - prompt: nestedPrompt, - } }, - }, "nested_create_1"); - }; - const gateway = startFakeGateway([ - subagentCreateCall("root_create_1", childPrompt, "persistent"), - route, - route, - route, - route, - ]); - - const result = await runFx(["ask", "Create one child that creates another child."], { - cwd: root.workspace, - env: gatewayEnv(root, gateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }); - - expect(result.code).toBe(0); - expect(result.stdout).toContain("root received child handle"); - expect(gateway.requests).toHaveLength(5); - expect(gateway.requests.some((request) => - request.body.includes("nested_create_1") - )).toBe(true); - for (const request of gateway.requests) { - expect(request.body).toContain('"name":"subagent"'); - expect(request.body).not.toContain('"name":"task"'); - } - expectNoHostileExecutables(root); - expectNoCommandArtifacts(root); - }, - TIMEOUT, - ); - - test( - "fx ask nested child receives periodic grandchild delivery at the next available step", - async () => { - const root = createIsolatedRoot(); - const childPrompt = "NESTED_DELIVERY_CHILD_PROMPT"; - const grandchildPrompt = "NESTED_DELIVERY_GRANDCHILD_PROMPT"; - const intervalPayload = "coalesced_ticks"; - let grandchildEventIds: string[] = []; - const childSecondMessage = "NESTED_CHILD_SECOND_MESSAGE"; - const childThirdMessage = "NESTED_CHILD_THIRD_MESSAGE"; - let childId = ""; - let grandchildId = ""; - let sameTurnGrandchildEventIds: string[] = []; - let childContinuationChecked = false; - let grandchildFinalObserved = false; - const grandchildCompletion = heldFinalText(); - const childCompletion = heldFinalText(); - const rootCompletion = heldFinalText(); - let deliveryBarrier: Promise | null = null; - let deliveryFailure: Error | null = null; - let grandchildRequestObserved = false; - let resolveGrandchildStarted!: () => void; - const grandchildStarted = new Promise((resolve) => { - resolveGrandchildStarted = resolve; - }); - let firstRootReleased = false; - const maybeReleaseFirstRoot = () => { - if (!grandchildFinalObserved || !childContinuationChecked || firstRootReleased) return; - firstRootReleased = true; - void waitForSubagentIdle(root, childId) - .then(() => rootCompletion.release("NESTED_ROOT_FIRST_DONE")) - .catch((error) => { - deliveryFailure = error instanceof Error ? error : new Error(String(error)); - rootCompletion.release("NESTED_ROOT_FIRST_CHILD_TIMEOUT"); - }); - }; - const firstRoute = (body: string) => { - const text = promptText(body); - if (text.includes(grandchildPrompt)) { - if (!grandchildRequestObserved) { - grandchildRequestObserved = true; - resolveGrandchildStarted(); - } - return grandchildCompletion.response; - } - if (body.includes('"toolCallId":"nested_grandchild_create_1"') && - body.includes('"type":"tool-result"')) { - if (!deliveryBarrier) { - const created = JSON.parse( - toolResultText(body, "nested_grandchild_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - grandchildId = created.child_id; - sameTurnGrandchildEventIds = parentDeliveryIds(body); - expectParentDeliveriesOrNone( - body, - grandchildId, - sameTurnGrandchildEventIds, - intervalPayload, - ); - childContinuationChecked = true; - deliveryBarrier = grandchildStarted - .then(() => waitForPersistedDeliveryIds(root, grandchildId, intervalPayload)) - .then(async () => { - grandchildCompletion.release("NESTED_GRANDCHILD_PRIVATE_DONE"); - await waitForSubagentIdle(root, grandchildId); - grandchildEventIds = findPersistedDeliveryIds( - root, - grandchildId, - intervalPayload, - ); - expect(grandchildEventIds.length).toBeGreaterThan(0); - for (const eventId of sameTurnGrandchildEventIds) { - expect(grandchildEventIds).toContain(eventId); - } - grandchildFinalObserved = true; - childCompletion.release("NESTED_CHILD_FIRST_PRIVATE_DONE"); - maybeReleaseFirstRoot(); - }) - .catch((error) => { - deliveryFailure = error instanceof Error ? error : new Error(String(error)); - grandchildCompletion.release("NESTED_GRANDCHILD_DELIVERY_FIXTURE_FAILED"); - childCompletion.release("NESTED_CHILD_DELIVERY_FIXTURE_FAILED"); - rootCompletion.release("NESTED_ROOT_DELIVERY_FIXTURE_FAILED"); - }); - } - return childCompletion.response; - } - if (text.includes(childPrompt)) { - return gatewayToolCall("subagent", { - command: { create: { - name: "nested-grandchild", - mode: "persistent", - prompt: grandchildPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - report_interval_ms: 50, - stop_conditions: ["terminal"], - }, - } }, - }, "nested_grandchild_create_1"); - } - if (body.includes('"toolCallId":"nested_root_create_1"') && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - toolResultText(body, "nested_root_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - expectNoParentDeliveries(body); - return rootCompletion.response; - } - return gatewayToolCall("subagent", { - command: { create: { - name: "nested-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - stop_conditions: ["terminal"], - }, - } }, - }, "nested_root_create_1"); - }; - const firstGateway = startFakeGateway(Array.from({ length: 5 }, () => firstRoute)); - - const first = await runFx( - ["ask", "--quiet", "--json", "Create the nested delivery fixture."], - { - cwd: root.workspace, - env: gatewayEnv(root, firstGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - - await deliveryBarrier; - if (deliveryFailure) throw deliveryFailure; - - expect(first.code).toBe(0); - expect(first.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(first.stdout.trim()).output).toContain("NESTED_ROOT_FIRST_DONE"); - expect(childId.length).toBeGreaterThan(0); - expect(grandchildId.length).toBeGreaterThan(0); - expect(childContinuationChecked).toBe(true); - expect(firstGateway.requests.some((request) => - request.body.includes('"toolCallId":"nested_root_create_1"') && - request.body.includes('"type":"tool-result"') - )).toBe(true); - expect(firstGateway.requests.some((request) => - request.body.includes('"toolCallId":"nested_grandchild_create_1"') && - request.body.includes('"type":"tool-result"') - )).toBe(true); - const parentSessionId = JSON.parse(first.stdout.trim()).session_id as string; - expect(grandchildEventIds.length).toBeGreaterThan(0); - - let childInitialChecked = false; - let childContinuationDeliveryChecked = false; - const secondSeen: string[] = []; - // Only the root session writes progress to this process's stderr. - const rootSubagentCallIds = new Set(); - const childSubagentCallIds = new Set(); - const secondRoute = (body: string) => { - const text = promptText(body); - for (const id of completedToolCallIds(body)) { - if (id === "nested_send_child_1") { - rootSubagentCallIds.add(id); - } else if (id === "nested_child_inspect_grandchild_1") { - childSubagentCallIds.add(id); - } - } - secondSeen.push([ - text.includes(childSecondMessage) ? "child-message" : "", - text.includes(" !sameTurnGrandchildEventIds.includes(eventId), - ); - expectParentDeliveriesOrNone( - body, - grandchildId, - pendingEventIds, - intervalPayload, - ); - childInitialChecked = true; - return subagentInspectCall("nested_child_inspect_grandchild_1", grandchildId); - } - if (body.includes('"toolCallId":"nested_send_child_1"') && - body.includes('"type":"tool-result"')) { - expectNoParentDeliveries(body); - return waitForSubagentIdle(root, childId).then(() => { - expect(childContinuationDeliveryChecked).toBe(true); - return finalText("NESTED_ROOT_SECOND_DONE"); - }); - } - return gatewayToolCall("subagent", { - command: { message: { send: { id: childId, content: childSecondMessage } } }, - }, "nested_send_child_1"); - }; - const secondGateway = startFakeGateway(Array.from({ length: 4 }, () => secondRoute)); - const second = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "Send the child a second message.", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, secondGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - - if (second.code !== 0) { - throw new Error(`nested second failed code=${second.code} seen=${secondSeen.join("|")} requests=${secondGateway.requests.length} stdout=${second.stdout} stderr=${second.stderr}`); - } - expect(rootSubagentCallIds.has("nested_send_child_1")).toBe(true); - expect(childSubagentCallIds.has("nested_child_inspect_grandchild_1")).toBe(true); - expect(rootSubagentCallIds.size).toBe(1); - expect(childSubagentCallIds.size).toBe(1); - expect(secondGateway.requests).toHaveLength(4); - expect(second.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(second.stdout.trim()).output).toContain("NESTED_ROOT_SECOND_DONE"); - expect(childInitialChecked).toBe(true); - expect(childContinuationDeliveryChecked).toBe(true); - - let childNoRedeliveryChecked = false; - const thirdRoute = (body: string) => { - const text = promptText(body); - if (text.includes(childThirdMessage)) { - expectNoParentDeliveries(body); - childNoRedeliveryChecked = true; - return finalText("NESTED_CHILD_NO_REDELIVERY"); - } - if (body.includes('"toolCallId":"nested_send_child_2"') && - body.includes('"type":"tool-result"')) { - return waitForSubagentIdle(root, childId).then(() => { - expect(childNoRedeliveryChecked).toBe(true); - return finalText("NESTED_ROOT_THIRD_DONE"); - }); - } - return gatewayToolCall("subagent", { - command: { message: { send: { id: childId, content: childThirdMessage } } }, - }, "nested_send_child_2"); - }; - const thirdGateway = startFakeGateway(Array.from({ length: 3 }, () => thirdRoute)); - const third = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "Send the child a third message.", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, thirdGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - - expect(third.code).toBe(0); - expect(third.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(third.stdout.trim()).output).toContain("NESTED_ROOT_THIRD_DONE"); - expect(childNoRedeliveryChecked).toBe(true); - for (const eventId of grandchildEventIds) { - expectHumanUnreadIndependent(root, grandchildId, eventId); - } - expectParentHistoryClean(root, childId, [ - " { - const root = createIsolatedRoot(); - const childPrompt = "NESTED_64K_DELIVERY_CHILD_PROMPT"; - const grandchildPrompt = "NESTED_64K_DELIVERY_GRANDCHILD_PROMPT"; - const largeMessage = "NESTED_64K_CHILD_MESSAGE:".padEnd(64 * 1024, "x"); - let childId = ""; - let grandchildId = ""; - let messageEventId = ""; - const parts: ParentMessagePart[] = []; - const firstRoute = (body: string) => { - const text = promptText(body); - if (body.includes('"toolCallId":"nested_64k_send_1"') && - body.includes('"type":"tool-result"')) { - expect(toolResultText(body, "nested_64k_send_1")).toContain( - '"status":"message_queued"', - ); - return finalText("NESTED_64K_GRANDCHILD_PRIVATE_DONE"); - } - if (body.includes('"toolCallId":"nested_64k_grandchild_create_1"') && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - toolResultText(body, "nested_64k_grandchild_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - grandchildId = created.child_id; - const sameTurnEventIds = parentDeliveryIds(body); - expect(sameTurnEventIds.length).toBeLessThanOrEqual(1); - if (sameTurnEventIds.length === 1) { - messageEventId = sameTurnEventIds[0]!; - const part = parentMessagePart(body, grandchildId, messageEventId); - expect(part.offset).toBe(0); - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - } else { - expectNoParentDeliveries(body); - } - return waitForPersistedDeliveryId( - root, - grandchildId, - "NESTED_64K_CHILD_MESSAGE:", - ).then((eventId) => { - if (messageEventId.length > 0) { - expect(eventId).toBe(messageEventId); - } else { - messageEventId = eventId; - } - return waitForSubagentIdle(root, grandchildId) - .then(() => finalText("NESTED_64K_CHILD_FIRST_PRIVATE_DONE")); - }); - } - if (body.includes('"toolCallId":"nested_64k_root_create_1"') && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - toolResultText(body, "nested_64k_root_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - expectNoParentDeliveries(body); - return waitForSubagentIdle(root, childId) - .then(() => finalText("NESTED_64K_ROOT_FIRST_DONE")); - } - if (text.includes(grandchildPrompt)) { - return (async () => { - const deadline = Date.now() + TIMEOUT; - while (childId.length === 0 && Date.now() < deadline) { - await Bun.sleep(20); - } - expect(childId.length).toBeGreaterThan(0); - return gatewayToolCall("subagent", { - command: { - message: { - send: { id: childId, content: largeMessage }, - }, - }, - }, "nested_64k_send_1"); - })(); - } - if (text.includes(childPrompt)) { - return gatewayToolCall("subagent", { - command: { create: { - name: "nested-64k-grandchild", - mode: "persistent", - prompt: grandchildPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - stop_conditions: ["terminal"], - }, - } }, - }, "nested_64k_grandchild_create_1"); - } - return gatewayToolCall("subagent", { - command: { create: { - name: "nested-64k-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - stop_conditions: ["terminal"], - }, - } }, - }, "nested_64k_root_create_1"); - }; - const firstGateway = startFakeGateway( - Array.from({ length: 6 }, () => firstRoute), - ); - const first = await runFx( - ["ask", "--quiet", "--json", "Create the nested 64 KiB fixture."], - { - cwd: root.workspace, - env: gatewayEnv(root, firstGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - expect(first.code).toBe(0); - expect(first.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(first.stdout.trim()).output).toContain( - "NESTED_64K_ROOT_FIRST_DONE", - ); - expect(firstGateway.requests).toHaveLength(6); - expect(childId.length).toBeGreaterThan(0); - expect(grandchildId.length).toBeGreaterThan(0); - expect(messageEventId.length).toBeGreaterThan(0); - const parentSessionId = JSON.parse(first.stdout.trim()).session_id as string; - - for (let index = parts.length; index < 5; index += 1) { - const childMessage = `NESTED_64K_CHILD_TURN_${index + 1}`; - const sendCallId = `nested_64k_root_send_${index + 1}`; - const route = (body: string) => { - const text = promptText(body); - if (text.includes(childMessage)) { - const part = parentMessagePart(body, grandchildId, messageEventId); - expect(part.offset).toBe( - parts.length === 0 ? 0 : parts[parts.length - 1]!.end_offset, - ); - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - return finalText(`NESTED_64K_CHILD_PART_${index + 1}_DONE`); - } - if (body.includes(`"toolCallId":"${sendCallId}"`) && - body.includes('"type":"tool-result"')) { - expect(toolResultText(body, sendCallId)).toContain( - '"status":"message_queued"', - ); - return waitForSubagentIdle(root, childId) - .then(() => finalText(`NESTED_64K_ROOT_PART_${index + 1}_DONE`)); - } - return gatewayToolCall("subagent", { - command: { - message: { - send: { id: childId, content: childMessage }, - }, - }, - }, sendCallId); - }; - const gateway = startFakeGateway(Array.from({ length: 3 }, () => route)); - const turn = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - `NESTED_64K_ROOT_TURN_${index + 1}`, - ], - { - cwd: root.workspace, - env: gatewayEnv(root, gateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - expect(turn.code).toBe(0); - expect(turn.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(turn.stdout.trim()).output).toContain( - `NESTED_64K_ROOT_PART_${index + 1}_DONE`, - ); - expect(gateway.requests).toHaveLength(3); - } - expect(parts).toHaveLength(5); - expect(parts.map((part) => part.content).join("")).toBe(largeMessage); - expect(parts[parts.length - 1]!.more).toBe(false); - - const finalChildMessage = "NESTED_64K_CHILD_NO_REDELIVERY"; - const finalCallId = "nested_64k_root_send_final"; - let noRedeliveryChecked = false; - const finalRoute = (body: string) => { - const text = promptText(body); - if (text.includes(finalChildMessage)) { - expectNoParentDeliveries(body); - noRedeliveryChecked = true; - return finalText("NESTED_64K_CHILD_NO_REDELIVERY_DONE"); - } - if (body.includes(`"toolCallId":"${finalCallId}"`) && - body.includes('"type":"tool-result"')) { - return waitForSubagentIdle(root, childId) - .then(() => finalText("NESTED_64K_ROOT_NO_REDELIVERY_DONE")); - } - return gatewayToolCall("subagent", { - command: { - message: { - send: { id: childId, content: finalChildMessage }, - }, - }, - }, finalCallId); - }; - const finalGateway = startFakeGateway( - Array.from({ length: 3 }, () => finalRoute), - ); - const final = await runFx( - [ - "ask", - "--quiet", - "--json", - "--resume-id", - parentSessionId, - "Verify the nested 64 KiB message is complete.", - ], - { - cwd: root.workspace, - env: gatewayEnv(root, finalGateway, { PATH: hostilePath(root) }), - timeoutMs: TIMEOUT, - }, - ); - expect(final.code).toBe(0); - expect(final.stderr).toBe(MANAGE_SUBAGENT_PROGRESS); - expect(JSON.parse(final.stdout.trim()).output).toContain( - "NESTED_64K_ROOT_NO_REDELIVERY_DONE", - ); - expect(noRedeliveryChecked).toBe(true); - expect(finalGateway.requests).toHaveLength(3); - expectHumanUnreadIndependent(root, grandchildId, messageEventId); - expectParentHistoryClean(root, childId, [ - " { - const root = createIsolatedRoot(); - const stderrPath = join(root.root, "interactive-subagent-stderr.log"); - const childPrompt = "Return the interactive child result."; - const route = (body: string) => { - if (body.includes('"toolCallId":"interactive_create_1"')) { - expect(toolResultText(body, "interactive_create_1")).toContain( - '"status":"created"', - ); - return finalText("interactive parent received child handle"); - } - return finalText("interactive child complete"); - }; - const gateway = startFakeGateway([ - subagentCreateCall("interactive_create_1", childPrompt), - route, - route, - ]); - writeFileSync(stderrPath, ""); - - activeSession = await TmuxSession.create({ - cmd: FX_BIN, - cwd: root.workspace, - env: gatewayEnv(root, gateway), - stderrPath, - }); - await activeSession.waitForComposer(TIMEOUT); - await activeSession.sendText("Create one interactive child."); - await waitForGatewayRequestCount(gateway, 3); - await activeSession.waitForText("interactive parent received child handle", TIMEOUT); - for (const request of gateway.requests) { - expect(request.body).toContain('"name":"subagent"'); - expect(request.body).not.toContain('"name":"task"'); - } - await activeSession.sendText("/quit"); - expect(await activeSession.waitForSessionEnd(5_000)).toBe(true); - activeSession = null; - expect(readFileSync(stderrPath, "utf8")).toBe(""); - }, - TIMEOUT, - ); - - test.skipIf(!tmuxAvailable())( - "interactive fx delivers a child approval to the next same-turn parent step", - async () => { - const root = createIsolatedRoot(); - const stderrPath = join(root.root, "interactive-child-approval-stderr.log"); - const fixturePath = join(root.workspace, "approval-fixture.txt"); - const markerPath = join(root.workspace, "approval-must-not-exist"); - const rootPrompt = "INTERACTIVE_CREATE_APPROVAL_CHILD"; - const childPrompt = "INTERACTIVE_CHILD_REQUEST_APPROVAL"; - const rootCreateCallId = "interactive_approval_create"; - const rootProbeCallId = "interactive_approval_probe"; - const childCommandCallId = "interactive_approval_command"; - let childId = ""; - let approval: PendingSubagentApproval | null = null; - let sameTurnChecked = false; - let noRedeliveryChecked = false; - let releaseApprovalUi!: () => void; - const approvalUiObserved = new Promise((resolve) => { - releaseApprovalUi = resolve; - }); - writeFileSync(fixturePath, "approval parent projection fixture\n"); - writeFileSync(stderrPath, ""); - - const checkApprovalDelivery = (body: string) => { - expect(approval).not.toBeNull(); - expectParentDelivery(body, childId, approval!.id, approval!.label); - const envelope = parentDeliveryEnvelope(promptText(body)); - expect(envelope).toContain(`"target_id":"${approval!.rootId}"`); - expect(envelope).toContain(`"work_id":"${approval!.workId}"`); - expect(envelope).toContain('"truncated":false'); - expect(envelope).toContain( - `"total_bytes":${Buffer.byteLength(approval!.label, "utf8")}`, - ); - sameTurnChecked = true; - }; - - const route = async (body: string): Promise => { - const userText = currentUserText(body); - if (userText.includes("INTERACTIVE_VERIFY_APPROVAL_NOT_REPEATED")) { - expect(promptText(body)).not.toContain(approval!.id); - expect(promptText(body)).not.toContain(approval!.label); - noRedeliveryChecked = true; - return finalText("INTERACTIVE_APPROVAL_NOT_REPEATED"); - } - if (body.includes(`\"toolCallId\":\"${childCommandCallId}\"`) && - body.includes('"type":"tool-result"')) { - return finalText("INTERACTIVE_CHILD_DENIED_COMPLETE"); - } - if (userText.includes(childPrompt)) { - return toolCall( - `/usr/bin/touch ${shellQuote(markerPath)}`, - {}, - childCommandCallId, - ); - } - if (body.includes(`\"toolCallId\":\"${rootProbeCallId}\"`) && - body.includes('"type":"tool-result"')) { - const text = promptText(body); - if (text.includes(`"id":"${approval!.id}"`)) { - checkApprovalDelivery(body); - } else { - expect(sameTurnChecked).toBe(true); - expect(text).not.toContain(approval!.id); - expect(text).not.toContain(approval!.label); - } - return finalText("INTERACTIVE_PARENT_SAW_CHILD_APPROVAL"); - } - if (body.includes(`\"toolCallId\":\"${rootCreateCallId}\"`) && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - toolResultText(body, rootCreateCallId), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; + expect(created.status.length).toBeGreaterThan(0); + childId = canonicalSubagentIdForStore(created.child_id); approval = await waitForPendingSubagentApproval(root, childId); expect(subagentState(root, childId)).toBe("awaiting_approval"); await approvalUiObserved; @@ -4712,14 +3317,7 @@ describe("effect-aware command permissions", () => { } if (userText.includes(rootPrompt)) { return gatewayToolCall("subagent", { - command: { - create: { - name: "interactive-approval-child", - mode: "persistent", - prompt: childPrompt, - permission_mode: "ask", - }, - }, + request: { action: "run", task: childPrompt }, }, rootCreateCallId); } throw new Error(`Unexpected approval projection request: ${body}`); @@ -4733,7 +3331,7 @@ describe("effect-aware command permissions", () => { cmd: FX_BIN, cwd: root.workspace, env: gatewayEnv(root, gateway, { - FX_PERMISSION_MODE: "auto", + FX_PERMISSION_MODE: "ask", PATH: hostilePath(root), }), stderrPath, @@ -4801,7 +3399,6 @@ describe("effect-aware command permissions", () => { }, 60_000, ); - test.skipIf(!tmuxAvailable())( "interactive fx lets a child continue after repeated advisory cautions", async () => { @@ -4838,20 +3435,13 @@ describe("effect-aware command permissions", () => { child_id: string; status: string; }; - expect(created.status).toBe("created"); - childId = created.child_id; + expect(created.status.length).toBeGreaterThan(0); + childId = canonicalSubagentIdForStore(created.child_id); return finalText("INTERACTIVE_AUTO_APPROVAL_PARENT_CREATED"); } if (userText.includes(rootPrompt)) { return gatewayToolCall("subagent", { - command: { - create: { - name: "interactive-auto-approval-child", - mode: "one_off", - prompt: childPrompt, - permission_mode: "auto", - }, - }, + request: { action: "run", task: childPrompt }, }, rootCreateCallId); } throw new Error(`Unexpected child advisory request: ${body}`); @@ -4878,10 +3468,10 @@ describe("effect-aware command permissions", () => { expect(childId).not.toBe(""); const deadline = Date.now() + TIMEOUT; - while (subagentState(root, childId) !== "completed" && Date.now() < deadline) { + while (subagentState(root, childId) !== "idle" && Date.now() < deadline) { await Bun.sleep(20); } - expect(subagentState(root, childId)).toBe("completed"); + expect(subagentState(root, childId)).toBe("idle"); expect(childRequestCount).toBe(5); expect(gateway.classifierRequests).toHaveLength(1); expect(existsSync(markerPath)).toBe(false); @@ -4906,314 +3496,7 @@ describe("effect-aware command permissions", () => { 60_000, ); - test.skipIf(!tmuxAvailable())( - "interactive fx delivers periodic child notifications at the next available parent step", - async () => { - const root = createIsolatedRoot(); - const stderrPath = join(root.root, "interactive-parent-delivery-stderr.log"); - const childPrompt = "INTERACTIVE_PARENT_DELIVERY_CHILD_PROMPT"; - const intervalPayload = "coalesced_ticks"; - let intervalEventIds: string[] = []; - let childId = ""; - let parentContinuationChecked = false; - let secondInitialChecked = false; - let secondContinuationChecked = false; - let thirdChecked = false; - let sameTurnEventIds: string[] = []; - let parentPhase: - | "create_prompt" - | "create_result" - | "second_prompt" - | "inspect_result" - | "third_prompt" - | "complete" = "create_prompt"; - const unexpectedRequests: string[] = []; - const childCompletion = heldFinalText(); - const route = (body: string) => { - const text = promptText(body); - if (latestPromptText(body).includes(childPrompt)) { - return childCompletion.response; - } - if (parentPhase === "third_prompt" && - text.includes("INTERACTIVE_PARENT_THIRD_PROMPT")) { - expectNoParentDeliveries(body); - thirdChecked = true; - parentPhase = "complete"; - return finalText("INTERACTIVE_PARENT_NO_REDELIVERY"); - } - if (parentPhase === "inspect_result" && - body.includes('"toolCallId":"interactive_delivery_inspect_1"') && - body.includes('"type":"tool-result"')) { - expectNoParentDeliveries(body); - secondContinuationChecked = true; - parentPhase = "third_prompt"; - return finalText("INTERACTIVE_PARENT_DELIVERY_CONSUMED"); - } - if (parentPhase === "second_prompt" && - text.includes("INTERACTIVE_PARENT_SECOND_PROMPT")) { - const pendingEventIds = intervalEventIds.filter( - (eventId) => !sameTurnEventIds.includes(eventId), - ); - expectParentDeliveriesOrNone( - body, - childId, - pendingEventIds, - intervalPayload, - ); - secondInitialChecked = true; - parentPhase = "inspect_result"; - return subagentInspectCall("interactive_delivery_inspect_1", childId); - } - if (parentPhase === "create_result" && - body.includes('"toolCallId":"interactive_delivery_create_1"') && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - toolResultText(body, "interactive_delivery_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - sameTurnEventIds = parentDeliveryIds(body); - expectParentDeliveriesOrNone( - body, - childId, - sameTurnEventIds, - intervalPayload, - ); - parentContinuationChecked = true; - parentPhase = "second_prompt"; - return waitForPersistedDeliveryIds(root, childId, intervalPayload) - .then(async () => { - childCompletion.release("INTERACTIVE_CHILD_PRIVATE_TRANSCRIPT_DONE"); - await waitForSubagentIdle(root, childId); - intervalEventIds = findPersistedDeliveryIds(root, childId, intervalPayload); - expect(intervalEventIds.length).toBeGreaterThan(0); - for (const eventId of sameTurnEventIds) { - expect(intervalEventIds).toContain(eventId); - } - return finalText("INTERACTIVE_PARENT_FIRST_TURN_COMPLETE"); - }); - } - if (parentPhase === "create_prompt" && - text.includes("Create interactive parent delivery fixture.")) { - parentPhase = "create_result"; - return gatewayToolCall("subagent", { - command: { create: { - name: "interactive-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - report_interval_ms: 50, - stop_conditions: ["terminal"], - }, - } }, - }, "interactive_delivery_create_1"); - } - unexpectedRequests.push(body); - return new Response(`unexpected parent phase: ${parentPhase}`, { status: 500 }); - }; - const gateway = startDynamicFakeGateway(route); - gateways.push(gateway); - writeFileSync(stderrPath, ""); - - activeSession = await TmuxSession.create({ - cmd: FX_BIN, - cwd: root.workspace, - env: gatewayEnv(root, gateway), - stderrPath, - width: 96, - height: 28, - }); - await activeSession.waitForComposer(TIMEOUT); - await activeSession.sendText("Create interactive parent delivery fixture."); - await activeSession.waitForText("INTERACTIVE_PARENT_FIRST_TURN_COMPLETE", TIMEOUT); - expect(parentContinuationChecked).toBe(true); - expect(childId.length).toBeGreaterThan(0); - expect(intervalEventIds.length).toBeGreaterThan(0); - await waitForSubagentIdle(root, childId); - - await activeSession.sendText("INTERACTIVE_PARENT_SECOND_PROMPT"); - await activeSession.waitForText("INTERACTIVE_PARENT_DELIVERY_CONSUMED", TIMEOUT); - expect(secondInitialChecked).toBe(true); - expect(secondContinuationChecked).toBe(true); - - await activeSession.sendText("INTERACTIVE_PARENT_THIRD_PROMPT"); - await activeSession.waitForText("INTERACTIVE_PARENT_NO_REDELIVERY", TIMEOUT); - expect(thirdChecked).toBe(true); - expect(parentPhase as string).toBe("complete"); - expect(unexpectedRequests).toEqual([]); - - await activeSession.sendText("/quit"); - expect(await activeSession.waitForSessionEnd(5_000)).toBe(true); - activeSession = null; - const parentSessionId = onlyParentSessionId(root, [childId]); - for (const eventId of intervalEventIds) { - expectHumanUnreadIndependent(root, childId, eventId); - } - expectParentHistoryClean(root, parentSessionId, [ - " { - const root = createIsolatedRoot(); - const stderrPath = join(root.root, "interactive-64k-delivery-stderr.log"); - const childPrompt = "INTERACTIVE_64K_DELIVERY_CHILD_PROMPT"; - const largeMessage = "INTERACTIVE_64K_PARENT_MESSAGE:".padEnd( - 64 * 1024, - "x", - ); - let childId = ""; - let messageEventId = ""; - let noRedeliveryChecked = false; - const parts: ParentMessagePart[] = []; - const route = (body: string) => { - const text = promptText(body); - if (text.includes("INTERACTIVE_64K_NO_REDELIVERY")) { - expectNoParentDeliveries(body); - noRedeliveryChecked = true; - return finalText("INTERACTIVE_64K_NO_REDELIVERY_DONE"); - } - if (text.includes("INTERACTIVE_64K_PARENT_TURN_")) { - const part = parentMessagePart(body, childId, messageEventId); - expect(part.offset).toBe( - parts.length === 0 ? 0 : parts[parts.length - 1]!.end_offset, - ); - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - return finalText(`INTERACTIVE_64K_PART_${parts.length}_DONE`); - } - if (body.includes('"toolCallId":"interactive_64k_send_1"') && - body.includes('"type":"tool-result"')) { - expect(toolResultText(body, "interactive_64k_send_1")).toContain( - '"status":"message_queued"', - ); - return finalText("INTERACTIVE_64K_CHILD_PRIVATE_DONE"); - } - if (body.includes('"toolCallId":"interactive_64k_create_1"') && - body.includes('"type":"tool-result"')) { - const created = JSON.parse( - toolResultText(body, "interactive_64k_create_1"), - ) as { child_id: string; status: string }; - expect(created.status).toBe("created"); - childId = created.child_id; - const sameTurnEventIds = parentDeliveryIds(body); - expect(sameTurnEventIds.length).toBeLessThanOrEqual(1); - if (sameTurnEventIds.length === 1) { - messageEventId = sameTurnEventIds[0]!; - const part = parentMessagePart(body, childId, messageEventId); - expect(part.offset).toBe(0); - expect(part.total_bytes).toBe(largeMessage.length); - parts.push(part); - } else { - expectNoParentDeliveries(body); - } - return waitForPersistedDeliveryId( - root, - childId, - "INTERACTIVE_64K_PARENT_MESSAGE:", - ).then((eventId) => { - if (messageEventId.length > 0) { - expect(eventId).toBe(messageEventId); - } else { - messageEventId = eventId; - } - return finalText("INTERACTIVE_64K_PARENT_FIRST_DONE"); - }); - } - if (text.includes(childPrompt)) { - return (async () => { - const deadline = Date.now() + TIMEOUT; - while (childId.length === 0 && Date.now() < deadline) { - await Bun.sleep(20); - } - expect(childId.length).toBeGreaterThan(0); - return gatewayToolCall("subagent", { - command: { - message: { - send: { - id: onlyParentSessionId(root, [childId]), - content: largeMessage, - }, - }, - }, - }, "interactive_64k_send_1"); - })(); - } - return gatewayToolCall("subagent", { - command: { create: { - name: "interactive-64k-delivery-child", - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { completed: false, failed: false, cancelled: false }, - stop_conditions: ["terminal"], - }, - } }, - }, "interactive_64k_create_1"); - }; - const gateway = startFakeGateway(Array.from({ length: 10 }, () => route)); - writeFileSync(stderrPath, ""); - - activeSession = await TmuxSession.create({ - cmd: FX_BIN, - cwd: root.workspace, - env: gatewayEnv(root, gateway), - stderrPath, - width: 96, - height: 28, - }); - await activeSession.waitForComposer(TIMEOUT); - await activeSession.sendText("Create interactive 64 KiB delivery fixture."); - await activeSession.waitForText("INTERACTIVE_64K_PARENT_FIRST_DONE", TIMEOUT); - await waitForGatewayRequestCount(gateway, 4); - expect(gateway.requests).toHaveLength(4); - expect(childId.length).toBeGreaterThan(0); - expect(messageEventId.length).toBeGreaterThan(0); - await waitForSubagentIdle(root, childId); - - const sameTurnPartCount = parts.length; - for (let index = parts.length; index < 5; index += 1) { - const requestsBefore = gateway.requests.length; - await activeSession.sendText(`INTERACTIVE_64K_PARENT_TURN_${index + 1}`); - await activeSession.waitForText( - `INTERACTIVE_64K_PART_${index + 1}_DONE`, - TIMEOUT, - ); - expect(gateway.requests).toHaveLength(requestsBefore + 1); - } - expect(parts).toHaveLength(5); - expect(parts.map((part) => part.content).join("")).toBe(largeMessage); - expect(parts[parts.length - 1]!.more).toBe(false); - - await activeSession.sendText("INTERACTIVE_64K_NO_REDELIVERY"); - await activeSession.waitForText( - "INTERACTIVE_64K_NO_REDELIVERY_DONE", - TIMEOUT, - ); - expect(noRedeliveryChecked).toBe(true); - expect(gateway.requests).toHaveLength(10 - sameTurnPartCount); - await activeSession.sendText("/quit"); - expect(await activeSession.waitForSessionEnd(5_000)).toBe(true); - activeSession = null; - const parentSessionId = onlyParentSessionId(root, [childId]); - expectHumanUnreadIndependent(root, childId, messageEventId); - expectParentHistoryClean(root, parentSessionId, [ - " { } if (body.includes(parentPrompt)) { return fakeGatewayToolCall(parentCreateId, "subagent", { - command: { - create: { - name: `fxc194-${decision}-child`, - mode: "one_off", - prompt: childPrompt, - }, - }, + request: { action: "run", task: childPrompt }, }); } return new Response("unexpected Gateway request", { status: 500 }); diff --git a/tests/e2e/tui-subagent-manager.test.ts b/tests/e2e/tui-subagent-manager.test.ts index 5a9bf655b..5e643aa89 100644 --- a/tests/e2e/tui-subagent-manager.test.ts +++ b/tests/e2e/tui-subagent-manager.test.ts @@ -568,12 +568,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { }); } return fakeGatewayToolCall("isolated_child_create", "subagent", { - command: { - create: { - name: "isolated-child", - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -607,7 +604,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { const manager = await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("isolated-child") && + pane.includes(childPrompt) && pane.includes("idle"), TIMEOUT, ); @@ -620,7 +617,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { TIMEOUT, ); expect(child).not.toContain("PARENT_BACKGROUND_0"); - const childId = child.match(/isolated-child\s+·\s+([^\s]+)/)?.[1]; + const childId = child.match(/ISOLATED_CHILD_PROMPT\s+·\s+([^\s]+)/)?.[1]; if (!childId) throw new Error("isolated child did not expose its immutable ID"); const control = JSON.parse(readFileSync( join(fixture.home, ".fx", "sessions", childId, "subagent", "control.json"), @@ -716,204 +713,6 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { 60_000, ); - test( - "selected child renders terminal-safe identity across restart", - async () => { - const fixture = createFixture(); - const resumedStderrPath = join(root!, "terminal-safe-child-resumed.stderr"); - const rawName = "lf\ncr\rred\x1b[31mchild\x1b[0m-c1-\u{0080}-δοκιμή"; - const visibleName = - "lf\\x0acr\\x0dred\\x1b[31mchild\\x1b[0m-c1-\\u{0080}-δοκιμή"; - const parentPrompt = "TERMINAL_SAFE_CHILD_CREATE"; - const childPrompt = "TERMINAL_SAFE_CHILD_INITIAL"; - const routedMessage = "TERMINAL_SAFE_CHILD_ROUTED_MESSAGE"; - const parentComplete = "TERMINAL_SAFE_PARENT_COMPLETE"; - const childComplete = "TERMINAL_SAFE_CHILD_COMPLETE"; - const routedComplete = "TERMINAL_SAFE_ROUTED_COMPLETE"; - writeFileSync(resumedStderrPath, ""); - - const gateway = startDynamicFakeGateway((body) => { - if (body.includes('"toolCallId":"terminal_safe_child_create"')) { - return fakeGatewayFinalText(parentComplete); - } - if (body.includes(routedMessage)) { - return fakeGatewayFinalText(routedComplete); - } - if (body.includes(childPrompt)) { - return fakeGatewayFinalText(childComplete); - } - if (body.includes(parentPrompt)) { - return fakeGatewayToolCall( - "terminal_safe_child_create", - "subagent", - { - command: { - create: { - name: rawName, - mode: "persistent", - prompt: childPrompt, - }, - }, - }, - ); - } - return fakeGatewayFinalText("unexpected terminal-safe child request"); - }, { - classifierDecision: "clear", - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - const env = { - HOME: fixture.home, - AI_GATEWAY_API_KEY: "terminal-safe-child-key", - VERCEL_OIDC_TOKEN: undefined, - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - FX_AUTO_UPGRADE: "0", - FX_DISABLE_KEYCHAIN: "1", - FX_SKIP_ONBOARDING: "1", - FX_SOUND: "0", - NO_COLOR: "1", - }; - - try { - session = await TmuxSession.create({ - cmd: FX_BIN, - cwd: fixture.workspace, - env, - width: 180, - height: 40, - stderrPath: fixture.stderrPath, - remainOnExit: true, - }); - const active = session; - await active.waitForComposer(TIMEOUT); - await active.sendText(parentPrompt); - await active.waitForText(parentComplete, TIMEOUT); - - await active.sendKeys("C-x"); - const manager = await active.waitForPane( - (pane) => - pane.includes("Agents & processes") && - pane.includes(visibleName) && - pane.includes("idle"), - TIMEOUT, - ); - expect(manager).not.toContain("redchild"); - expect( - (await active.capturePaneGrid()).filter((row) => - row.includes(visibleName) - ), - ).toHaveLength(1); - expect(await active.capturePaneEscapes()).not.toContain( - "red\x1b[31mchild", - ); - - await active.sendKeys("Enter"); - const selected = await active.waitForPane( - (pane) => - pane.includes(childComplete) && - pane.includes("status: idle") && - countOccurrences(pane, visibleName) >= 2, - TIMEOUT, - ); - expect(selected).not.toContain("redchild"); - expect( - (await active.capturePaneGrid()).filter((row) => - row.includes(visibleName) - ), - ).toHaveLength(2); - expect(await active.capturePaneEscapes()).not.toContain( - "red\x1b[31mchild", - ); - - await active.sendText(routedMessage); - await active.waitForText(routedComplete, TIMEOUT); - - type Control = { - child_id: string; - parent_id: string | null; - configuration: { name: string }; - }; - const sessionsDir = join(fixture.home, ".fx", "sessions"); - const control = readdirSync(sessionsDir) - .map((id) => join(sessionsDir, id, "subagent", "control.json")) - .filter((path) => existsSync(path)) - .map((path) => JSON.parse(readFileSync(path, "utf8")) as Control) - .find((candidate) => candidate.configuration.name === rawName); - if (!control) throw new Error("terminal-safe child control was not persisted"); - if (!control.parent_id) throw new Error("terminal-safe child lost its root"); - expect(control.configuration.name).toBe(rawName); - expect( - readFileSync(join(sessionsDir, control.child_id, "events.jsonl"), "utf8"), - ).toContain(routedMessage); - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - - await active.sendKeys("C-x"); - await active.waitForText(parentComplete, TIMEOUT); - await active.sendText("/quit"); - await active.waitForPane(() => active.paneStatus().dead, TIMEOUT); - expect(paneExitMatches(active.paneStatus(), 0)).toBe(true); - await active.kill(); - session = null; - - session = await TmuxSession.create({ - cmd: `${FX_BIN} resume ${control.parent_id}`, - cwd: fixture.workspace, - env, - width: 180, - height: 40, - stderrPath: resumedStderrPath, - remainOnExit: true, - }); - const resumed = session; - await resumed.waitForComposer(TIMEOUT); - await resumed.sendKeys("C-x"); - await resumed.waitForPane( - (pane) => - pane.includes("Agents & processes") && - pane.includes(visibleName) && - pane.includes("idle"), - TIMEOUT, - ); - await resumed.sendKeys("Enter"); - const reopened = await resumed.waitForPane( - (pane) => - pane.includes(routedComplete) && - pane.includes("status: idle") && - countOccurrences(pane, visibleName) >= 2, - TIMEOUT, - ); - expect(reopened).not.toContain("redchild"); - expect(await resumed.capturePaneEscapes()).not.toContain( - "red\x1b[31mchild", - ); - - await resumed.resizeWindow(96, 28); - const narrow = await resumed.waitForPane( - (pane) => pane.includes("lf\\x0a") && pane.includes("status: idle"), - TIMEOUT, - ); - expect(narrow).not.toContain("redchild"); - expect(await resumed.capturePaneEscapes()).not.toContain( - "red\x1b[31mchild", - ); - expect(resumed.paneStatus()).toEqual({ dead: false, status: null }); - expect(readFileSync(resumedStderrPath, "utf8")).toBe(""); - - await resumed.sendKeys("C-x"); - await resumed.waitForComposer(TIMEOUT); - await resumed.sendText("/quit"); - await resumed.waitForPane(() => resumed.paneStatus().dead, TIMEOUT); - expect(paneExitMatches(resumed.paneStatus(), 0)).toBe(true); - await resumed.kill(); - session = null; - } finally { - gateway.stop(); - } - }, - 90_000, - ); test( "manager-created children default to yolo and execute tools without approval", @@ -1133,7 +932,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { "one Ctrl-C cancels a streaming persistent child without exiting fx", async () => { const fixture = createFixture(); - const childName = "ctrl-c-child"; + const childName = "CTRL_C_CHILD_STREAM"; const parentPrompt = "CREATE_CTRL_C_CHILD"; const parentReady = "CTRL_C_PARENT_READY"; const childPrompt = "CTRL_C_CHILD_STREAM"; @@ -1147,20 +946,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { if (body.includes(childPrompt)) return stream.response; if (body.includes(parentPrompt)) { return fakeGatewayToolCall("ctrl_c_child_create", "subagent", { - command: { - create: { - name: childName, - mode: "persistent", - prompt: childPrompt, - notifications: { - terminal: { - completed: false, - failed: false, - cancelled: true, - }, - stop_conditions: ["terminal"], - }, - }, + request: { + action: "run", + task: childPrompt, }, }); } @@ -1239,7 +1027,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { TIMEOUT, ); const childId = running.match( - /ctrl-c-child\s+·\s+([^\s]+)/, + /CTRL_C_CHILD_STREAM\s+·\s+([^\s]+)/, )?.[1]; if (!childId) throw new Error("Ctrl-C child did not expose its ID"); @@ -1247,7 +1035,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForPane( (pane) => pane.includes(childName) && - pane.includes("status: idle"), + pane.includes("idle"), TIMEOUT, ); expect(active.paneStatus()).toEqual({ dead: false, status: null }); @@ -1292,7 +1080,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { pane.includes("Agents & processes") && pane.includes(childName) && pane.includes("idle") && - pane.includes("unread 1"), + pane.includes("unread 2"), TIMEOUT, ); expect(cancelledDeliveries(control.child_id)).toHaveLength(1); @@ -1317,88 +1105,6 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { 60_000, ); - test( - "ask root rejects model-created auto child before any file write", - async () => { - const fixture = createFixture(); - writeFileSync( - join(fixture.home, ".fx", "settings.json"), - JSON.stringify({ sandbox: "none", permission_mode: "ask", permission: {} }), - ); - const childPrompt = "ASK_WRITE_CHILD_PROMPT"; - const marker = join(fixture.workspace, "ask-child-created.txt"); - let childWriteIssued = false; - const gateway = startDynamicFakeGateway((body) => { - if (body.includes('"toolCallId":"ask_write_create"')) { - return fakeGatewayFinalText("ASK_WRITE_PARENT_READY"); - } - if (body.includes('"toolCallId":"ask_write_file"')) { - return fakeGatewayFinalText("ASK_WRITE_CHILD_COMPLETE"); - } - if (body.includes(childPrompt)) { - childWriteIssued = true; - return fakeGatewayToolCall("ask_write_file", "write_file", { - path: "ask-child-created.txt", - content: "approved child write\n", - }); - } - return fakeGatewayToolCall("ask_write_create", "subagent", { - command: { - create: { - name: "ask-write-child", - mode: "persistent", - prompt: childPrompt, - permission_mode: "auto", - }, - }, - }); - }, { - classifierDecision: "caution", - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - try { - session = await TmuxSession.create({ - cmd: FX_BIN, - cwd: fixture.workspace, - env: { - HOME: fixture.home, - AI_GATEWAY_API_KEY: "ask-write-child-key", - VERCEL_OIDC_TOKEN: undefined, - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - FX_AUTO_UPGRADE: "0", - FX_DISABLE_KEYCHAIN: "1", - FX_SKIP_ONBOARDING: "1", - FX_SOUND: "0", - NO_COLOR: "1", - }, - width: 112, - height: 32, - stderrPath: fixture.stderrPath, - }); - const active = session; - await active.waitForComposer(TIMEOUT); - await active.sendText("Create the ask-write child."); - await active.waitForText("ASK_WRITE_PARENT_READY", TIMEOUT); - expect(existsSync(marker)).toBe(false); - expect(gateway.requests.some((request) => - request.body.includes("permission_escalation") - )).toBe(true); - const sessionsDir = join(fixture.home, ".fx", "sessions"); - const controls = readdirSync(sessionsDir) - .map((id) => join(sessionsDir, id, "subagent", "control.json")) - .filter((path) => existsSync(path)); - expect(controls).toHaveLength(0); - expect(childWriteIssued).toBe(false); - expect(gateway.classifierRequests).toHaveLength(0); - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - } finally { - gateway.stop(); - } - }, - 60_000, - ); test( "persistent auto child bypasses review for its first new-file write", @@ -1424,13 +1130,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { }); } return fakeGatewayToolCall("auto_write_create", "subagent", { - command: { - create: { - name: "auto-write-child", - mode: "persistent", - prompt: childPrompt, - permission_mode: "auto", - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -1503,13 +1205,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { ); } return fakeGatewayToolCall("auto_terminal_create", "subagent", { - command: { - create: { - name: "auto-terminal-child", - mode: "persistent", - prompt: childPrompt, - permission_mode: "auto", - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -1569,7 +1267,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { join(fixture.home, ".fx", "settings.json"), JSON.stringify({ sandbox: "none", permission_mode: "ask", permission: {} }), ); - const childName = "always-write-child"; + const childName = "ALWAYS_WRITE_CHILD_INITIAL"; const childPrompt = "ALWAYS_WRITE_CHILD_INITIAL"; const secondPrompt = "ALWAYS_WRITE_CHILD_SECOND"; const externalPrompt = "ALWAYS_WRITE_CHILD_EXTERNAL"; @@ -1612,12 +1310,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { return fakeGatewayFinalText("ALWAYS_WRITE_PARENT_READY"); } return fakeGatewayToolCall(createId, "subagent", { - command: { - create: { - name: childName, - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -1741,7 +1436,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { "selecting a command-running persistent child remains stable across surface switches", async () => { const fixture = createFixture(); - const childName = "command-stream-child"; + const childName = "COMMAND_STREAM_CHILD_PROMPT"; const childPrompt = "COMMAND_STREAM_CHILD_PROMPT"; const commandCount = 10; const gateway = startDynamicFakeGateway((body) => { @@ -1776,13 +1471,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { ); } return fakeGatewayToolCall("command_stream_create", "subagent", { - command: { - create: { - name: childName, - mode: "persistent", - prompt: childPrompt, - }, - }, + request: { action: "run", task: childPrompt }, }); }, { classifierDecision: "clear", @@ -1994,171 +1683,6 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { 90_000, ); - test( - "same active turn replays one subagent create identity and rejects changed arguments", - async () => { - const fixture = createFixture(); - const invocationId = "same_active_turn_create"; - const original = { - command: { - create: { - name: "same-active-turn-child", - mode: "persistent", - }, - }, - }; - const changed = { - command: { - create: { - name: "changed-active-turn-child", - mode: "persistent", - }, - }, - }; - const responses = [ - fakeGatewayToolCall(invocationId, "subagent", original), - fakeGatewayToolCall(invocationId, "subagent", original), - fakeGatewayToolCall(invocationId, "subagent", changed), - fakeGatewayFinalText("SAME_ACTIVE_TURN_REPLAY_COMPLETE"), - ]; - let responseIndex = 0; - const gateway = startDynamicFakeGateway(() => { - const response = responses[responseIndex]; - responseIndex += 1; - if (!response) return new Response("unexpected request", { status: 500 }); - return response; - }, { - classifierDecision: "clear", - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - try { - session = await TmuxSession.create({ - cwd: fixture.workspace, - env: { - HOME: fixture.home, - AI_GATEWAY_API_KEY: "same-active-turn-key", - VERCEL_OIDC_TOKEN: undefined, - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - FX_AUTO_UPGRADE: "0", - NO_COLOR: "1", - }, - width: 96, - height: 28, - stderrPath: fixture.stderrPath, - }); - const active = session; - await active.waitForComposer(TIMEOUT); - await active.sendText("Exercise one same-turn subagent replay."); - await active.waitForText("SAME_ACTIVE_TURN_REPLAY_COMPLETE", TIMEOUT); - await active.waitForComposer(TIMEOUT); - expect(responseIndex).toBe(4); - - type ToolResult = { - tool_call_id: string; - tool_name: string; - status: "success" | "failure"; - output: string; - }; - type HistoryFrame = { - kind: string; - payload: { - turn?: { - kind: string; - execution?: { - tool_steps: Array<{ tool_results: ToolResult[] }>; - }; - }; - }; - }; - const sessionsDir = join(fixture.home, ".fx", "sessions"); - let sessionIds: string[] = []; - let rootState: { id: string; frames: HistoryFrame[] } | undefined; - const persistenceDeadline = Date.now() + TIMEOUT; - while (Date.now() < persistenceDeadline && !rootState) { - sessionIds = readdirSync(sessionsDir).filter((id) => - existsSync(join(sessionsDir, id, "session.json")) - ); - for (const id of sessionIds) { - let frames: HistoryFrame[] = []; - try { - const eventText = readFileSync( - join(sessionsDir, id, "events.jsonl"), - "utf8", - ).trim(); - frames = eventText.length === 0 - ? [] - : eventText.split("\n").map((line) => JSON.parse(line) as HistoryFrame); - } catch { - continue; - } - if (frames.some((frame) => frame.kind === "history_turn_committed")) { - rootState = { id, frames }; - break; - } - } - if (!rootState) await Bun.sleep(25); - } - expect(sessionIds).toHaveLength(2); - if (!rootState) throw new Error("root session history was not persisted"); - const turn = rootState.frames.findLast( - (frame) => frame.kind === "history_turn_committed", - )?.payload.turn; - if (!turn?.execution) throw new Error("root turn execution was not persisted"); - expect(turn.execution.tool_steps).toHaveLength(3); - const results = turn.execution.tool_steps.map((step) => step.tool_results[0]); - expect(results[0].output).toBe(results[1].output); - const first = JSON.parse(results[0].output) as { - operation_id: string; - child_id: string; - }; - const replay = JSON.parse(results[1].output) as { - operation_id: string; - child_id: string; - }; - const conflict = JSON.parse(results[2].output) as { - error_code: string; - }; - expect(replay.operation_id).toBe(first.operation_id); - expect(replay.child_id).toBe(first.child_id); - expect(results.map((result) => result.status)).toEqual([ - "success", - "success", - "failure", - ]); - expect(conflict.error_code).toBe("operation_conflict"); - - const identities = JSON.parse(readFileSync( - join( - sessionsDir, - rootState.id, - "subagent", - "create-operations.json", - ), - "utf8", - )) as { entries: unknown[]; outstanding_operations: unknown[] }; - expect(identities.entries).toHaveLength(1); - expect(identities.outstanding_operations).toHaveLength(0); - const control = JSON.parse(readFileSync( - join( - sessionsDir, - first.child_id, - "subagent", - "control.json", - ), - "utf8", - )) as { operations: unknown[]; events: unknown[]; queue: unknown[] }; - expect(control.operations).toHaveLength(1); - expect(control.events).toHaveLength(1); - expect(control.queue).toHaveLength(0); - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - } finally { - gateway.stop(); - } - }, - 60_000, - ); test( "human create configure attach detach close and reopen routes preserve the main composer", @@ -2465,208 +1989,14 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { 90_000, ); - test( - "model approval reparent applies the reviewed relationship exactly once", - async () => { - const fixture = createFixture(); - const tapePath = join(root!, "model-approved-reparent.fxtape"); - const sessionsDir = join(fixture.home, ".fx", "sessions"); - const parentName = "approved-reparent-parent"; - const childName = "approved-reparent-child"; - const createParentCallId = "approved_reparent_create_parent"; - const createChildCallId = "approved_reparent_create_child"; - const reparentCallId = "approved_reparent_child"; - const inspectCallId = "approved_reparent_inspect"; - type Control = { - child_id: string; - parent_id: string | null; - generation: number; - operations: Array<{ - code: string; - identity_source?: string; - target_id: string; - }>; - events: unknown[]; - configuration: { name: string }; - }; - type Communication = { - ledger: { - approvals: Array<{ - kind: string; - status: string; - relationship: { - action: string; - prospective_parent_id: string; - operation_id: string; - } | null; - }>; - }; - }; - const controls = (): Array<{ path: string; control: Control }> => { - if (!existsSync(sessionsDir)) return []; - return readdirSync(sessionsDir).flatMap((id) => { - const path = join(sessionsDir, id, "subagent", "control.json"); - if (!existsSync(path)) return []; - return [{ path, control: JSON.parse(readFileSync(path, "utf8")) as Control }]; - }); - }; - const controlByName = (name: string) => { - const found = controls().find(({ control }) => - control.configuration.name === name - ); - if (!found) throw new Error(`missing ${name} control`); - return found; - }; - const communicationFor = (childId: string) => JSON.parse(readFileSync( - join(sessionsDir, childId, "subagent", "communication.json"), - "utf8", - )) as Communication; - - let phase: "setup" | "inspect" = "setup"; - let step = 0; - const gateway = startDynamicFakeGateway(() => { - const responses = phase === "setup" - ? [ - () => fakeGatewayToolCall(createParentCallId, "subagent", { - command: { - create: { name: parentName, mode: "persistent" }, - }, - }), - () => fakeGatewayToolCall(createChildCallId, "subagent", { - command: { - create: { name: childName, mode: "persistent" }, - }, - }), - () => fakeGatewayToolCall(reparentCallId, "subagent", { - command: { - relationship: { - action: "reparent", - id: controlByName(childName).control.child_id, - parent_id: controlByName(parentName).control.child_id, - }, - }, - }), - () => fakeGatewayFinalText("MODEL_APPROVAL_SETUP_COMPLETE"), - ] - : [ - () => fakeGatewayToolCall(inspectCallId, "subagent", { - command: { - inspect: { - id: controlByName(childName).control.child_id, - sections: ["status", "relationship", "events"], - }, - }, - }), - () => fakeGatewayFinalText("MODEL_APPROVAL_INSPECT_COMPLETE"), - ]; - const response = responses[step++]; - return response?.() ?? new Response("unexpected gateway step", { status: 500 }); - }, { - classifierDecision: "clear", - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - try { - session = await TmuxSession.create({ - cmd: FX_BIN, - cwd: fixture.workspace, - env: { - HOME: fixture.home, - AI_GATEWAY_API_KEY: "approved-reparent-key", - VERCEL_OIDC_TOKEN: undefined, - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - FX_AUTO_UPGRADE: "0", - FX_DISABLE_KEYCHAIN: "1", - FX_SKIP_ONBOARDING: "1", - FX_SOUND: "0", - FX_RECORD: tapePath, - NO_COLOR: "1", - }, - width: 132, - height: 36, - stderrPath: fixture.stderrPath, - }); - const active = session; - await active.waitForComposer(TIMEOUT); - await active.sendText("Create two children and reparent the second under the first."); - const approvalPane = await active.waitForText("Reparent subagent", 60_000); - expect(approvalPane).toContain("1. Yes"); - - const parentBefore = controlByName(parentName).control; - const childBefore = controlByName(childName).control; - const rootId = parentBefore.parent_id; - if (!rootId) throw new Error("parent fixture is not attached to the root"); - expect(childBefore.parent_id).toBe(rootId); - const generationBefore = childBefore.generation; - const operationCountBefore = childBefore.operations.length; - const eventCountBefore = childBefore.events.length; - const approvalBefore = communicationFor(childBefore.child_id).ledger.approvals - .find((approval) => approval.kind === "relationship"); - expect(approvalBefore).toMatchObject({ - status: "pending", - relationship: { - action: "reparent", - prospective_parent_id: parentBefore.child_id, - }, - }); - - await active.sendLiteralText("1"); - await active.waitForPane((pane) => { - try { - const child = controlByName(childName).control; - return hasEmptyComposer(pane) && - child.parent_id === parentBefore.child_id && - child.generation === generationBefore + 1; - } catch { - return false; - } - }, 60_000); - await active.waitForText("MODEL_APPROVAL_SETUP_COMPLETE", 60_000); - - const childAfter = controlByName(childName).control; - expect(childAfter.parent_id).toBe(parentBefore.child_id); - expect(childAfter.generation).toBe(generationBefore + 1); - expect(childAfter.operations).toHaveLength(operationCountBefore + 1); - expect(childAfter.events).toHaveLength(eventCountBefore + 1); - expect(childAfter.operations.at(-1)).toMatchObject({ - code: "relationship_changed", - identity_source: "model", - target_id: childBefore.child_id, - }); - const approvalAfter = communicationFor(childBefore.child_id).ledger.approvals - .find((approval) => approval.kind === "relationship"); - expect(approvalAfter).toMatchObject({ status: "consumed" }); - - phase = "inspect"; - step = 0; - await active.sendText("Inspect the approved child's relationship."); - await active.waitForText("MODEL_APPROVAL_INSPECT_COMPLETE", 60_000); - const scrollback = await active.captureFullScrollback(); - expect(scrollback).toContain("Create two children and reparent the second"); - expect(scrollback).toContain("MODEL_APPROVAL_INSPECT_COMPLETE"); - expect(scrollback).not.toContain("approval pending"); - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - - await active.sendText("/quit"); - expect(await active.waitForSessionEnd(TIMEOUT)).toBe(true); - session = null; - expect(readFileSync(tapePath).toString("latin1")).not.toContain("Approval ID:"); - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - } finally { - gateway.stop(); - } - }, - 120_000, - ); test( "human Ctrl-X reparent moves one nested child to the interactive root exactly once", async () => { const fixture = createFixture(); const tapePath = join(root!, "direct-tty-reparent.fxtape"); - const parentName = "tty-reparent-parent"; - const childName = "tty-reparent-child"; + const parentName = "DIRECT_TTY_REPARENT_PARENT_WORK"; + const childName = "DIRECT_TTY_REPARENT_CHILD_WORK"; const parentPrompt = "DIRECT_TTY_REPARENT_PARENT_WORK"; const childPrompt = "DIRECT_TTY_REPARENT_CHILD_WORK"; const createParentCallId = "direct_tty_reparent_create_parent"; @@ -2684,22 +2014,16 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { } if (body.includes(parentPrompt)) { return fakeGatewayToolCall(createChildCallId, "subagent", { - command: { - create: { - name: childName, - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); } return fakeGatewayToolCall(createParentCallId, "subagent", { - command: { - create: { - name: parentName, - mode: "persistent", - prompt: parentPrompt, - }, + request: { + action: "run", + task: parentPrompt, }, }); }, { @@ -3012,17 +2336,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { ); } return fakeGatewayToolCall("checkpoint3_restart_create", "subagent", { - command: { - create: { - name: "restart-child", - mode: "persistent", - prompt: childPrompt, - permission_mode: "auto", - notifications: { - milestones: ["checkpoint3"], - stop_conditions: ["terminal"], - }, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -3053,21 +2369,21 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForText("CHECKPOINT3_PARENT_CREATED_CHILD", TIMEOUT); await active.sendKeys("C-x"); const tree = await active.waitForPane( - (pane) => pane.includes("restart-child") && pane.includes("running"), + (pane) => pane.includes("CHECKPOINT3_RESTART_INTERRUPTED_CHILD") && pane.includes("running"), TIMEOUT, ); expect(tree).toContain("Agents & processes"); await active.sendKeys("Enter"); const running = await active.waitForPane( (pane) => - pane.includes("restart-child") && + pane.includes("CHECKPOINT3_RESTART_INTERRUPTED_CHILD") && pane.includes("status: running") && pane.includes("running"), TIMEOUT, ); expect(running).toContain("Parent agent"); const childId = running.match( - /restart-child\s+·\s+([^\s]+)/, + /CHECKPOINT3_RESTART_INTERRUPTED_CHILD\s+·\s+([^\s]+)/, )?.[1]; if (!childId) throw new Error("running child did not expose its ID"); const controlPath = join( @@ -3135,7 +2451,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { const interruptedTree = await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("restart-child") && + pane.includes("CHECKPOINT3_RESTART_INTERRUPTED_CHILD") && pane.includes("interrupted"), TIMEOUT, ); @@ -3156,7 +2472,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.sendKeys("Tab"); await active.sendLiteralText("s"); const configuration = await active.waitForText("Configure child", TIMEOUT); - expect(configuration).toContain("checkpoint3"); + expect(configuration).toContain(childPrompt); await active.sendKeys("Escape"); await active.waitForText("status: interrupted", TIMEOUT); @@ -3164,18 +2480,18 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("restart-child") && + pane.includes("CHECKPOINT3_RESTART_INTERRUPTED_CHILD") && !pane.includes("Activity —"), TIMEOUT, ); await active.sendLiteralText("a"); - const activity = await active.waitForText("Activity — restart-child", TIMEOUT); + const activity = await active.waitForText("Activity — CHECKPOINT3_RESTART_INTERRUPTED_CHILD", TIMEOUT); expect(activity).toContain(childId); await active.sendKeys("Escape"); await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("restart-child") && + pane.includes("CHECKPOINT3_RESTART_INTERRUPTED_CHILD") && !pane.includes("Activity —"), TIMEOUT, ); @@ -3183,7 +2499,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForText("status: interrupted", TIMEOUT); await active.sendKeys("Tab"); await active.sendLiteralText("x"); - await active.waitForText("Actions — restart-child", TIMEOUT); + await active.waitForText("Actions — CHECKPOINT3_RESTART_INTERRUPTED_CHILD", TIMEOUT); await active.sendLiteralText("r"); const completed = await active.waitForPane( (pane) => pane.includes(resumedText) && pane.includes("status: idle"), @@ -3214,9 +2530,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { }; expect(recoveredControl.parent_id).toBe(controlBeforeCrash.parent_id); expect(recoveredControl.state).toBe("idle"); - expect(recoveredControl.configuration.notifications.milestones).toEqual([ - "checkpoint3", - ]); + expect(recoveredControl.configuration.notifications.milestones).toEqual([]); expect(recoveredControl.queue).toEqual([ expect.objectContaining({ content: childPrompt, @@ -3802,13 +3116,10 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { if (body.includes(parentPrompt)) { if (!childId) throw new Error("parent follow-up requested before child ID was known"); return fakeGatewayToolCall(parentCallId, "subagent", { - command: { - message: { - send: { - id: childId, - content: parentMessage, - }, - }, + request: { + action: "send", + child_id: childId, + message: parentMessage, }, }); } @@ -4085,217 +3396,6 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { 90_000, ); - test( - "terminal one-off delivers once retires and leaves persistent controls intact", - async () => { - const fixture = createFixture(); - const childName = "temporary-result"; - const persistentName = "persistent-control"; - const persistentInitial = "PERSISTENT_CONTROL_INITIAL"; - const persistentReady = "PERSISTENT_CONTROL_READY"; - const persistentResume = "PERSISTENT_CONTROL_SECOND"; - const persistentResumed = "PERSISTENT_CONTROL_RESUMED"; - const mainPrompt = "ONEOFF_RETIREMENT_MAIN"; - const childPrompt = "ONEOFF_RETIREMENT_CHILD"; - const childDone = "ONEOFF_RETIREMENT_RESULT"; - const parentAck = "ONEOFF_RETIREMENT_ACK"; - const parentAckDone = "ONEOFF_RETIREMENT_ACK_DONE"; - const childStream = controlledTextResponse("ONEOFF_RETIREMENT_STREAM_"); - const gateway = startDynamicFakeGateway((body) => { - if (body.includes(persistentResume)) return fakeGatewayFinalText(persistentResumed); - if (body.includes(parentAck) && body.includes(childDone)) { - return fakeGatewayFinalText(parentAckDone); - } - if (body.includes(persistentInitial)) return fakeGatewayFinalText(persistentReady); - if (body.includes('"toolCallId":"create_temporary_result"')) { - return fakeGatewayFinalText("ONEOFF_RETIREMENT_MAIN_DONE"); - } - if (body.includes(childPrompt)) return childStream.response; - if (body.includes(mainPrompt)) { - return fakeGatewayToolCall("create_temporary_result", "subagent", { - command: { - create: { - name: childName, - mode: "one_off", - prompt: childPrompt, - }, - }, - }); - } - return fakeGatewayFinalText("unexpected retirement request"); - }, { - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - const env = relationshipTestEnv(fixture, gateway, "oneoff-retirement"); - - type ResumeControl = { - child_id: string; - parent_id: string; - mode: "persistent" | "one_off"; - state: string; - configuration: { name: string }; - }; - const sessionsDir = join(fixture.home, ".fx", "sessions"); - const readControls = () => - readdirSync(sessionsDir) - .map((id) => join(sessionsDir, id, "subagent", "control.json")) - .filter((path) => existsSync(path)) - .map((path) => ({ - path, - value: JSON.parse(readFileSync(path, "utf8")) as ResumeControl, - })); - - async function waitForControl(name: string, state: string) { - const deadline = Date.now() + TIMEOUT; - while (Date.now() < deadline) { - const found = readControls().find((entry) => - entry.value.configuration.name === name && - entry.value.state === state - ); - if (found) return found; - await Bun.sleep(25); - } - throw new Error(`control did not reach ${name}:${state}`); - } - - async function runAsk(args: string[]) { - const child = Bun.spawn([FX_BIN, "ask", ...args], { - cwd: fixture.workspace, - env, - stdout: "pipe", - stderr: "pipe", - }); - const [stdout, stderr, exitCode] = await Promise.all([ - new Response(child.stdout).text(), - new Response(child.stderr).text(), - child.exited, - ]); - return { stdout, stderr, exitCode }; - } - - try { - session = await TmuxSession.create({ - cmd: FX_BIN, - cwd: fixture.workspace, - env, - width: 80, - height: 24, - stderrPath: fixture.stderrPath, - }); - const active = session; - await active.waitForComposer(TIMEOUT); - await active.sendKeys("C-x"); - await active.waitForText("Agents & processes", TIMEOUT); - await active.sendLiteralText("c"); - await active.waitForText("Create persistent agent", TIMEOUT); - await pasteVisibleText(active, persistentName); - await active.sendKeys("Tab"); - await active.sendKeys("Tab"); - await pasteVisibleText(active, persistentInitial); - await active.sendKeys("Enter"); - await active.waitForPane( - (pane) => pane.includes(persistentReady) && pane.includes("status: idle"), - TIMEOUT, - ); - await active.sendKeys("Escape"); - await active.waitForText("Agents & processes", TIMEOUT); - await active.sendKeys("C-x"); - await active.waitForComposer(TIMEOUT); - - await active.sendText(mainPrompt); - await active.waitForPane( - (pane) => pane.includes("ONEOFF_RETIREMENT_MAIN_DONE") && !pane.includes("Streaming ("), - TIMEOUT, - ); - const childStartedDeadline = Date.now() + TIMEOUT; - while ( - !gateway.requests.some((request) => request.body.includes(childPrompt)) && - Date.now() < childStartedDeadline - ) { - await Bun.sleep(25); - } - expect(gateway.requests.some((request) => request.body.includes(childPrompt))).toBe(true); - await active.sendKeys("C-x"); - await active.waitForPane( - (pane) => - pane.includes(childName) && - pane.includes("running") && - pane.includes(persistentName), - TIMEOUT, - ); - await active.sendKeys("C-x"); - await active.waitForComposer(TIMEOUT); - childStream.release(childDone); - const oneOff = await waitForControl(childName, "completed"); - const persistent = await waitForControl(persistentName, "idle"); - - await active.sendKeys("C-x"); - const managerPane = await active.waitForPane( - (pane) => pane.includes(persistentName) && !pane.includes(childName), - TIMEOUT, - ); - expect(managerPane).not.toContain(childName); - await active.sendKeys("C-x"); - await active.waitForComposer(TIMEOUT); - - await active.sendText(parentAck); - await active.waitForText(parentAckDone, TIMEOUT); - expect( - gateway.requests.some((request) => - request.body.includes(parentAck) && request.body.includes(childDone) - ), - ).toBe(true); - - const childDir = join(sessionsDir, oneOff.value.child_id); - const retirementDeadline = Date.now() + TIMEOUT; - while (existsSync(childDir) && Date.now() < retirementDeadline) { - await Bun.sleep(25); - } - expect(existsSync(childDir)).toBe(false); - - await active.sendText("/quit"); - expect(await active.waitForSessionEnd(TIMEOUT)).toBe(true); - session = null; - - const retired = await runAsk([ - "--json", - "--auto", - "--resume-id", - oneOff.value.child_id, - "must not resume", - ]); - expect(retired.exitCode).toBe(1); - expect(retired.stderr).toBe(""); - expect(JSON.parse(retired.stdout)).toMatchObject({ - exit_code: 1, - error: "SessionNotFound", - }); - - const persistentControl = await runAsk([ - "--json", - "--auto", - "--resume-id", - persistent.value.child_id, - persistentResume, - ]); - expect(persistentControl.exitCode).toBe(0); - expect(persistentControl.stderr).toBe(""); - expect(JSON.parse(persistentControl.stdout)).toMatchObject({ - exit_code: 0, - output: persistentResumed, - }); - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - } finally { - if (!childStream.released()) { - try { - childStream.release("CLEANUP"); - } catch {} - } - gateway.stop(); - } - }, - 60_000, - ); test( "persistent child quit exits locally without sending a model turn", async () => { @@ -4928,322 +4028,103 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { expect(withDuration.operations.at(-1)).toMatchObject({ code: "configured", identity_source: "human", - generation: initial.generation + 1, - }); - - await active.sendKeys("Tab"); - await active.sendKeys("C-x"); - await active.waitForComposer(TIMEOUT); - await active.sendText("/quit"); - expect(await active.waitForSessionEnd(TIMEOUT)).toBe(true); - session = null; - - session = await TmuxSession.create({ - cmd: `${FX_BIN} resume ${withDuration.parent_id}`, - cwd: fixture.workspace, - env, - width: 96, - height: 28, - stderrPath: resumedStderrPath, - }); - active = session; - await active.waitForComposer(TIMEOUT); - await active.sendKeys("C-x"); - await active.waitForPane( - (pane) => pane.includes("duration-worker") && pane.includes("idle"), - TIMEOUT, - ); - expect(readConfigurationControl(controlPath)).toEqual(withDuration); - - await active.sendKeys("Enter"); - await active.waitForPane( - (pane) => - pane.includes("Subagent: duration-worker") && - pane.includes("status: idle"), - TIMEOUT, - ); - await active.sendKeys("Tab"); - await active.sendLiteralText("s"); - await active.waitForText("Configure child", TIMEOUT); - await active.sendKeys("Tab"); - await active.sendKeys("Tab"); - await active.sendKeys("Tab"); - await active.sendKeys("Tab"); - await active.waitForPane( - (pane) => - pane.split("\n").some((line) => - line.startsWith("> Report duration ms: 900") - ), - TIMEOUT, - ); - await active.sendKeys("C-u"); - const clearedForm = await active.waitForPane( - (pane) => - pane.split("\n").some((line) => - line.startsWith("> Report duration ms:") && !line.includes("900") - ), - TIMEOUT, - ); - expect(clearedForm).not.toContain("Stop after duration:"); - await active.sendKeys("Enter"); - await active.waitForPane((pane) => !pane.includes("Configure child"), TIMEOUT); - - const withoutDuration = await waitForConfigurationControl( - controlPath, - (control) => - control.generation === withDuration.generation + 1 && - control.configuration.notifications.report_duration_ms === null, - controlTimeout, - ); - expect(withoutDuration.configuration.notifications).toMatchObject({ - report_interval_ms: 100, - report_duration_ms: null, - stop_conditions: ["terminal"], - }); - expect(withoutDuration.operations).toHaveLength( - withDuration.operations.length + 1, - ); - expect(withoutDuration.operations.at(-1)).toMatchObject({ - code: "configured", - identity_source: "human", - generation: withDuration.generation + 1, - }); - expect(readConfigurationControl(controlPath)).toEqual(withoutDuration); - await active.sendKeys("Tab"); - await active.sendKeys("C-x"); - await active.waitForComposer(TIMEOUT); - await active.sendText("/quit"); - expect(await active.waitForSessionEnd(TIMEOUT)).toBe(true); - session = null; - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - expect(readFileSync(resumedStderrPath, "utf8")).toBe(""); - } finally { - gateway.stop(); - } - }, - 90_000, - ); - - test( - "configure rejects a concurrent winner then retries the preserved draft once", - async () => { - const fixture = createFixture(); - const resumedStderrPath = join(root!, "configure-contention-resumed.stderr"); - writeFileSync(resumedStderrPath, ""); - let releaseExternal!: (response: Response) => void; - let externalReleased = false; - const externalResponse = new Promise((resolve) => { - releaseExternal = (response) => { - externalReleased = true; - resolve(response); - }; - }); - const gateway = startDynamicFakeGateway((body) => { - if (body.includes('"toolCallId":"external_configure"')) { - return fakeGatewayFinalText("BACKGROUND_CONFIGURED"); - } - if (body.includes("BACKGROUND_CONFIGURE")) return externalResponse; - return fakeGatewayFinalText("STALE_CHILD_READY"); - }, { - classifierDecision: "clear", - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - const env = { - HOME: fixture.home, - AI_GATEWAY_API_KEY: "configure-contention-key", - VERCEL_OIDC_TOKEN: undefined, - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - FX_AUTO_UPGRADE: "0", - FX_DISABLE_KEYCHAIN: "1", - FX_SKIP_ONBOARDING: "1", - FX_SOUND: "0", - NO_COLOR: "1", - }; - - try { - session = await TmuxSession.create({ - cmd: FX_BIN, - cwd: fixture.workspace, - env, - width: 96, - height: 28, - stderrPath: fixture.stderrPath, - }); - let active = session; - await active.waitForComposer(TIMEOUT); - await active.sendKeys("C-x"); - await active.waitForText("Agents & processes", TIMEOUT); - await active.sendLiteralText("c"); - await active.waitForText("Create persistent agent", TIMEOUT); - await pasteVisibleText(active, "stale-form-child"); - await active.sendKeys("Tab"); - await active.sendKeys("Tab"); - await pasteVisibleText(active, "STALE_CHILD_PROMPT"); - await active.sendKeys("Enter"); - await active.waitForText("STALE_CHILD_READY", TIMEOUT); + generation: initial.generation + 1, + }); - const controlPath = configurationControlPath(fixture); - const initial = readConfigurationControl(controlPath); await active.sendKeys("Tab"); await active.sendKeys("C-x"); await active.waitForComposer(TIMEOUT); - await active.sendText("BACKGROUND_CONFIGURE"); - const requestStartedAt = Date.now(); - while ( - !gateway.requests.some((request) => - request.body.includes("BACKGROUND_CONFIGURE") - ) && Date.now() - requestStartedAt < TIMEOUT - ) { - await Bun.sleep(25); - } - expect(gateway.requests.some((request) => - request.body.includes("BACKGROUND_CONFIGURE") - )).toBe(true); + await active.sendText("/quit"); + expect(await active.waitForSessionEnd(TIMEOUT)).toBe(true); + session = null; + session = await TmuxSession.create({ + cmd: `${FX_BIN} resume ${withDuration.parent_id}`, + cwd: fixture.workspace, + env, + width: 96, + height: 28, + stderrPath: resumedStderrPath, + }); + active = session; + await active.waitForComposer(TIMEOUT); await active.sendKeys("C-x"); await active.waitForPane( - (pane) => - pane.includes("Agents & processes") && - pane.includes("stale-form-child"), + (pane) => pane.includes("duration-worker") && pane.includes("idle"), TIMEOUT, ); + expect(readConfigurationControl(controlPath)).toEqual(withDuration); + await active.sendKeys("Enter"); await active.waitForPane( (pane) => - pane.includes("Subagent: stale-form-child") && + pane.includes("Subagent: duration-worker") && pane.includes("status: idle"), TIMEOUT, ); await active.sendKeys("Tab"); await active.sendLiteralText("s"); await active.waitForText("Configure child", TIMEOUT); - await active.sendKeys("C-u"); - await pasteVisibleText(active, "tui-draft-final"); await active.sendKeys("Tab"); await active.sendKeys("Tab"); - await active.sendKeys("C-u"); - await pasteVisibleText(active, "tui-mark"); await active.sendKeys("Tab"); - await active.sendKeys("C-u"); - await pasteVisibleText(active, "700"); - - releaseExternal(fakeGatewayToolCall("external_configure", "subagent", { - command: { - configure: { - id: initial.child_id, - name: "external-winner", - model: FAKE_GATEWAY_MODEL, - effort: "high", - permission_mode: "auto", - notifications: { - terminal: { completed: true, failed: true, cancelled: true }, - milestones: ["external-mark"], - report_interval_ms: 900, - stop_conditions: ["terminal"], - }, - }, - }, - })); - const external = await waitForConfigurationControl( - controlPath, - (control) => - control.generation === initial.generation + 1 && - control.configuration.name === "external-winner", - ); - const refreshed = await active.waitForPane( + await active.sendKeys("Tab"); + await active.waitForPane( (pane) => - pane.includes("Configure child") && - pane.includes("Effective/current name: external-winner") && - pane.includes("Name: tui-draft-final"), + pane.split("\n").some((line) => + line.startsWith("> Report duration ms: 900") + ), TIMEOUT, ); - expect(refreshed).toContain("Effective/current permission mode: auto"); - expect(external.operations).toHaveLength(initial.operations.length + 1); - expect(external.operations.at(-1)).toMatchObject({ - code: "configured", - identity_source: "model", - generation: initial.generation + 1, - }); - - await active.sendKeys("Enter"); - const rejected = await active.waitForPane( + await active.sendKeys("C-u"); + const clearedForm = await active.waitForPane( (pane) => - pane.includes("Command failed: stale_generation (retryable)") && - pane.includes("Name: tui-draft-final") && - pane.includes("Effective/current name: external-winner"), + pane.split("\n").some((line) => + line.startsWith("> Report duration ms:") && !line.includes("900") + ), TIMEOUT, ); - expect(rejected).toContain("Report interval ms: 700"); - expect(readConfigurationControl(controlPath)).toEqual(external); - + expect(clearedForm).not.toContain("Stop after duration:"); await active.sendKeys("Enter"); await active.waitForPane((pane) => !pane.includes("Configure child"), TIMEOUT); - const final = await waitForConfigurationControl( + + const withoutDuration = await waitForConfigurationControl( controlPath, (control) => - control.generation === external.generation + 1 && - control.configuration.name === "tui-draft-final", - ); - expect(final.configuration).toMatchObject({ - name: "tui-draft-final", - effort: "auto", - permission_mode: "yolo", - notifications: { - milestones: ["tui-mark"], - report_interval_ms: 700, - report_duration_ms: null, - stop_conditions: ["terminal"], - }, + control.generation === withDuration.generation + 1 && + control.configuration.notifications.report_duration_ms === null, + controlTimeout, + ); + expect(withoutDuration.configuration.notifications).toMatchObject({ + report_interval_ms: 100, + report_duration_ms: null, + stop_conditions: ["terminal"], }); - expect(final.operations).toHaveLength(external.operations.length + 1); - expect(final.operations.at(-1)).toMatchObject({ + expect(withoutDuration.operations).toHaveLength( + withDuration.operations.length + 1, + ); + expect(withoutDuration.operations.at(-1)).toMatchObject({ code: "configured", identity_source: "human", - generation: external.generation + 1, + generation: withDuration.generation + 1, }); - + expect(readConfigurationControl(controlPath)).toEqual(withoutDuration); await active.sendKeys("Tab"); await active.sendKeys("C-x"); await active.waitForComposer(TIMEOUT); await active.sendText("/quit"); expect(await active.waitForSessionEnd(TIMEOUT)).toBe(true); session = null; - - session = await TmuxSession.create({ - cmd: `${FX_BIN} resume ${final.parent_id}`, - cwd: fixture.workspace, - env, - width: 96, - height: 28, - stderrPath: resumedStderrPath, - }); - active = session; - await active.waitForComposer(TIMEOUT); - await active.sendKeys("C-x"); - await active.waitForPane( - (pane) => pane.includes("tui-draft-final") && pane.includes("idle"), - TIMEOUT, - ); - expect(readConfigurationControl(controlPath)).toEqual(final); - await active.sendKeys("C-x"); - await active.waitForComposer(TIMEOUT); - await active.sendText("/quit"); - expect(await active.waitForSessionEnd(TIMEOUT)).toBe(true); - session = null; expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); expect(readFileSync(resumedStderrPath, "utf8")).toBe(""); } finally { - if (!externalReleased) { - releaseExternal(fakeGatewayFinalText("BACKGROUND_CONFIGURE_CLEANUP")); - } gateway.stop(); } }, - 120_000, + 90_000, ); + test( "persistent child preserves its reading position across both reopen paths", async () => { @@ -5618,10 +4499,10 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { if (body.includes(parentPrompt)) { if (!childId) throw new Error("visible child ID was not captured"); return fakeGatewayToolCall(parentCallId, "subagent", { - command: { - message: { - send: { id: childId, content: parentMessage }, - }, + request: { + action: "send", + child_id: childId, + message: parentMessage, }, }); } @@ -5985,157 +4866,6 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { 120_000, ); - test( - "assembled TTY tree keeps persistent configuration and hides settled one-offs", - async () => { - const fixture = createFixture(); - const persistentPrompt = "CHECKPOINT3_PERSISTENT_CREATES_NESTED"; - const nestedPrompt = "CHECKPOINT3_NESTED_ONE_OFF"; - const oneOffPrompt = "CHECKPOINT3_ROOT_ONE_OFF"; - const gateway = startDynamicFakeGateway((body) => { - if (body.includes('"toolCallId":"checkpoint3_nested_create"')) { - return fakeGatewayFinalText("CHECKPOINT3_PERSISTENT_COMPLETE"); - } - if (body.includes('"toolCallId":"checkpoint3_root_one_off"')) { - return fakeGatewayFinalText("CHECKPOINT3_ASSEMBLED_PARENT_COMPLETE"); - } - if (body.includes('"toolCallId":"checkpoint3_root_persistent"')) { - return fakeGatewayToolCall("checkpoint3_root_one_off", "subagent", { - command: { - create: { - name: "assembled-one-off", - mode: "one_off", - prompt: oneOffPrompt, - notifications: { stop_conditions: ["terminal"] }, - }, - }, - }); - } - if (body.includes(nestedPrompt) && !body.includes(persistentPrompt)) { - return fakeGatewayFinalText("CHECKPOINT3_NESTED_COMPLETE"); - } - if (body.includes(oneOffPrompt)) { - return fakeGatewayFinalText("CHECKPOINT3_ONE_OFF_COMPLETE"); - } - if (body.includes(persistentPrompt)) { - return fakeGatewayToolCall("checkpoint3_nested_create", "subagent", { - command: { - create: { - name: "assembled-nested", - mode: "one_off", - prompt: nestedPrompt, - }, - }, - }); - } - return fakeGatewayToolCall("checkpoint3_root_persistent", "subagent", { - command: { - create: { - name: "assembled-persistent", - mode: "persistent", - prompt: persistentPrompt, - notifications: { - milestones: ["assembled-checkpoint"], - report_interval_ms: 1000, - report_duration_ms: 250, - stop_conditions: ["terminal"], - }, - }, - }, - }); - }, { - classifierDecision: "clear", - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - try { - session = await TmuxSession.create({ - cwd: fixture.workspace, - env: { - HOME: fixture.home, - AI_GATEWAY_API_KEY: "checkpoint-three-assembled-key", - VERCEL_OIDC_TOKEN: undefined, - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - FX_AUTO_UPGRADE: "0", - NO_COLOR: "1", - }, - width: 112, - height: 32, - stderrPath: fixture.stderrPath, - }); - const active = session; - await active.waitForComposer(TIMEOUT); - await active.sendText("Build the checkpoint three assembled tree."); - await active.waitForText("CHECKPOINT3_ASSEMBLED_PARENT_COMPLETE", TIMEOUT); - await active.sendKeys("C-x"); - const tree = await active.waitForPane( - (pane) => - pane.includes("assembled-persistent") && - pane.includes("idle") && - !pane.includes("assembled-one-off") && - !pane.includes("assembled-nested"), - TIMEOUT, - ); - expect(tree).toContain("Agents & processes"); - expect(tree).not.toContain("assembled-one-off"); - expect(tree).not.toContain("assembled-nested"); - - type Control = { - child_id: string; - parent_id: string; - mode: "persistent" | "one_off"; - state: string; - configuration: { - name: string; - notifications: { milestones: string[]; stop_conditions: string[] }; - }; - queue: Array<{ content: string; status: string }>; - }; - const sessionsDir = join(fixture.home, ".fx", "sessions"); - const readControls = () => readdirSync(sessionsDir) - .map((id) => join(sessionsDir, id, "subagent", "control.json")) - .filter((path) => existsSync(path)) - .map((path) => JSON.parse(readFileSync(path, "utf8")) as Control); - let persistent: Control | undefined; - const controlsDeadline = Date.now() + TIMEOUT; - while (Date.now() < controlsDeadline) { - persistent = readControls().find( - (control) => control.configuration.name === "assembled-persistent" && - control.state === "idle", - ); - if (persistent) break; - await Bun.sleep(25); - } - if (!persistent) throw new Error("assembled persistent control did not settle"); - expect(persistent!.mode).toBe("persistent"); - expect(persistent!.state).toBe("idle"); - expect(persistent!.configuration.notifications).toMatchObject({ - milestones: ["assembled-checkpoint"], - stop_conditions: ["terminal", "duration_elapsed"], - }); - - await active.sendKeys("Enter"); - const persistentChat = await active.waitForPane( - (pane) => - pane.includes("assembled-persistent") && - pane.includes(persistent!.child_id) && - pane.includes("status: idle") && - pane.includes("CHECKPOINT3_PERSISTENT_COMPLETE"), - TIMEOUT, - ); - expect(persistentChat).toContain(`Parent: ${persistent!.parent_id}`); - for (const request of gateway.requests) { - expect(request.body).toContain('"name":"subagent"'); - expect(request.body).not.toContain('"name":"task"'); - } - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - } finally { - gateway.stop(); - } - }, - 120_000, - ); test( "manager cancel preserves a persistent child chat and returns it idle", @@ -6149,12 +4879,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { } if (body.includes(childPrompt)) return childStream.response; return fakeGatewayToolCall("checkpoint3_cancel_create", "subagent", { - command: { - create: { - name: "manager-cancel-child", - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -6195,7 +4922,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("manager-cancel-child") && + pane.includes("CHECKPOINT3_MANAGER_CANCEL_ACTIVE") && pane.includes("running"), TIMEOUT, ); @@ -6214,31 +4941,31 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("manager-cancel-child") && + pane.includes("CHECKPOINT3_MANAGER_CANCEL_ACTIVE") && pane.includes("running"), TIMEOUT, ); await active.sendKeys("Enter"); const running = await active.waitForPane( (pane) => - pane.includes("manager-cancel-child") && + pane.includes("CHECKPOINT3_MANAGER_CANCEL_ACTIVE") && pane.includes("status: running") && pane.includes("running") && pane.includes("CHECKPOINT3_CANCEL_STREAM_"), TIMEOUT, ); const childId = running.match( - /manager-cancel-child\s+·\s+([^\s]+)/, + /CHECKPOINT3_MANAGER_CANCEL_ACTIVE\s+·\s+([^\s]+)/, )?.[1]; if (!childId) throw new Error("cancel child did not expose its ID"); await active.sendKeys("Tab"); await active.sendLiteralText("x"); - await active.waitForText("Actions — manager-cancel-child", TIMEOUT); + await active.waitForText("Actions — CHECKPOINT3_MANAGER_CANCEL_ACTIVE", TIMEOUT); await active.sendLiteralText("c"); const cancelled = await active.waitForPane( (pane) => - pane.includes("manager-cancel-child") && + pane.includes("CHECKPOINT3_MANAGER_CANCEL_ACTIVE") && pane.includes("status: idle"), TIMEOUT, ); @@ -6307,13 +5034,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { ); } return fakeGatewayToolCall(parentCallId, "subagent", { - command: { - create: { - name: "approval-cancel-child", - mode: "persistent", - prompt: childPrompt, - permission_mode: "ask", - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -6341,7 +5064,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForComposer(TIMEOUT); await active.sendText(parentPrompt); await active.waitForText( - "Subagent approval-cancel-child needs permission", + "Subagent CANCEL_BLOCKED_APPROVAL_CHILD needs permission", TIMEOUT, ); @@ -6349,16 +5072,16 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("approval-cancel-child") && + pane.includes("CANCEL_BLOCKED_APPROVAL_CHILD") && pane.includes("approval"), TIMEOUT, ); await active.sendKeys("Enter"); const blocked = await active.waitForPane( (pane) => - pane.includes("Subagent: approval-cancel-child") && + pane.includes("Subagent: CANCEL_BLOCKED_APPROVAL_CHILD") && pane.includes("status: approval") && - pane.includes("Subagent approval-cancel-child needs permission") && + pane.includes("Subagent CANCEL_BLOCKED_APPROVAL_CHILD needs permission") && pane.includes("Command") && pane.includes("$ # shell.run profile=user shell=") && pane.includes("printf denied > cancelled-approval-effect.txt") && @@ -6366,7 +5089,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { TIMEOUT, ); const childId = blocked.match( - /approval-cancel-child\s+·\s+([^\s]+)/, + /CANCEL_BLOCKED_APPROVAL_CHILD\s+·\s+([^\s]+)/, )?.[1]; if (!childId) throw new Error("approval child did not expose its ID"); const requestCountBeforeShutdown = gateway.requests.length; @@ -6437,7 +5160,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await resumed.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("approval-cancel-child") && + pane.includes("CANCEL_BLOCKED_APPROVAL_CHILD") && pane.includes("interrupted"), TIMEOUT, ); @@ -6512,12 +5235,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { return childCompletion; } return fakeGatewayToolCall("selected_child_create", "subagent", { - command: { - create: { - name: "route-child", - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -6553,7 +5273,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { expect(childRequests).toBe(1); await active.sendKeys("C-x"); - await active.waitForText("route-child", TIMEOUT); + await active.waitForText("SELECTED_CHILD_ROUTE_RECOVERY", TIMEOUT); await active.sendKeys("Enter"); await active.waitForText(childPrompt, TIMEOUT); @@ -6568,7 +5288,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await Bun.sleep(25); } expect(recordedOutput).toContain(retryText); - expect(recordedOutput).toContain("route-child"); + expect(recordedOutput).toContain("SELECTED_CHILD_ROUTE_RECOVERY"); releaseChild(fakeGatewayFinalText(finalText)); await active.waitForText(finalText, TIMEOUT); @@ -6615,12 +5335,9 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { if (body.includes(humanOneLines[0]!)) return humanOneStream.response; if (body.includes(childPrompt)) return childStream.response; return fakeGatewayToolCall("manager_create_1", "subagent", { - command: { - create: { - name: "live-child", - mode: "persistent", - prompt: childPrompt, - }, + request: { + action: "run", + task: childPrompt, }, }); }, { @@ -6687,7 +5404,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { pane.includes("Agents & processes") && pane.split("\n").some((line) => line.startsWith("› ") && - line.includes("live-child") && + line.includes("CHILD1") && line.includes("running") ), TIMEOUT, @@ -6697,11 +5414,11 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { const detail = await active.waitForPane( (pane) => pane.includes("Subagent") && - pane.includes("live-child") && + pane.includes("CHILD1") && pane.includes("MANAGER_CHILD_LIVE_"), TIMEOUT, ); - const childId = detail.match(/live-child\s+·\s+([^\s]+)/)?.[1]; + const childId = detail.match(/CHILD1\s+·\s+([^\s]+)/)?.[1]; if (!childId) throw new Error("child chat did not expose the immutable child ID"); authoritativeChildId = childId; expect(detail).toContain("Parent:"); @@ -6712,7 +5429,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { expect(detail).toContain("effort:"); expect(detail).not.toContain("Source:"); expect(detail).not.toContain("Enter Send"); - expect(detail).not.toContain("Subagent live-child • status:"); + expect(detail).not.toContain("Subagent CHILD1 • status:"); expect(detail).not.toContain("Context:"); expect(detail).toContain(FAKE_GATEWAY_MODEL); @@ -6720,7 +5437,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { const streamingRoot = await active.waitForPane( (pane) => pane.includes("Agents & processes") && - pane.includes("live-child") && + pane.includes("CHILD1") && pane.includes("running") && pane.includes("r archives"), TIMEOUT, @@ -6818,10 +5535,10 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { for (const line of humanOneLines) expect(completed).toContain(line); expect(completed).toContain(humanTwo); expect(completed).toContain(`┃ ${humanOneLines[0]}`); - expect(completed).toContain("live-child · idle ·"); + expect(completed).toContain("CHILD1 · idle ·"); expect(completed).not.toContain("Enter Send"); expect(completed).not.toContain("Source:"); - expect(completed).not.toContain("Subagent live-child • status:"); + expect(completed).not.toContain("Subagent CHILD1 • status:"); expect(completed).toContain("● 1 tool call · 1 read"); expect(completed).toContain(`└ Read ${childToolPath}`); expect(completed.match(/MANAGER_HUMAN_ONE_LIVE_/g)).toHaveLength(1); @@ -6904,7 +5621,7 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { TIMEOUT, ); await active.sendLiteralText("a"); - const activity = await active.waitForText("Activity — live-child", TIMEOUT); + const activity = await active.waitForText("Activity — CHILD1", TIMEOUT); expect(activity).toContain(childId); await active.sendKeys("Escape"); await active.waitForPane( @@ -6920,48 +5637,8 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { normalizeThinkingFrame(mainGridBeforeManager), ); expect(active.cursorPosition()).toEqual(mainCursorBeforeManager); - parentStream.releaseToolCall("manager_archive_1", "subagent", { - command: { - lifecycle: { - id: authoritativeChildId!, - action: "close", - }, - }, - }); + parentStream.release("MANAGER_PARENT_COMPLETE"); await active.waitForText("MANAGER_PARENT_COMPLETE", TIMEOUT); - - await active.sendKeys("C-x"); - const activeTree = await active.waitForText("Agents & processes", TIMEOUT); - expect(activeTree).not.toContain(`› live-child`); - await active.sendLiteralText("r"); - const archived = await active.waitForText("Archived subagents", TIMEOUT); - expect(archived).toContain("live-child"); - await active.sendKeys("Enter"); - const archivedDetail = await active.waitForPane( - (pane) => - pane.includes("Read-only child") && - pane.includes(`Read ${childToolPath}`), - TIMEOUT, - ); - expect(archivedDetail).toContain("Read-only child"); - expect(archivedDetail).not.toContain("Enter Send"); - expect(archivedDetail).not.toContain("Context:"); - expect(hasEmptyComposer(archivedDetail)).toBe(false); - await active.sendKeys("Escape"); - await active.waitForText("Archived subagents", TIMEOUT); - await active.sendKeys("C-x"); - await active.waitForPane((pane) => !pane.includes("Agents & processes"), TIMEOUT); - - const restoredBeforeRepeat = await active.capturePaneGrid(); - const cursorBeforeRepeat = active.cursorPosition(); - await active.sendKeys("C-x"); - await active.waitForText("Agents & processes", TIMEOUT); - await active.sendLiteralText("r"); - await active.waitForText("Archived subagents", TIMEOUT); - await active.sendKeys("C-x"); - await active.waitForPane((pane) => !pane.includes("Agents & processes"), TIMEOUT); - expect(await active.capturePaneGrid()).toEqual(restoredBeforeRepeat); - expect(active.cursorPosition()).toEqual(cursorBeforeRepeat); expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); } finally { if (!childStream.released()) childStream.release("CLEANUP"); @@ -6974,85 +5651,6 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { 90_000, ); - test( - "Ctrl-X traverses a bounded 101-child tree through stable pages", - async () => { - const fixture = createFixture(); - let createIndex = 0; - const gateway = startDynamicFakeGateway(() => { - if (createIndex === 101) { - return fakeGatewayFinalText("MANAGER_BOUNDED_TREE_COMPLETE"); - } - const index = createIndex; - createIndex += 1; - return fakeGatewayToolCall( - `manager_bounded_create_${index.toString().padStart(3, "0")}`, - "subagent", - { - command: { - create: { - name: `page-child-${index.toString().padStart(3, "0")}`, - mode: "persistent", - }, - }, - }, - ); - }, { - classifierDecision: "clear", - models: [{ id: FAKE_GATEWAY_MODEL, type: "language", tags: ["tool-use"] }], - }); - try { - session = await TmuxSession.create({ - cwd: fixture.workspace, - env: { - HOME: fixture.home, - AI_GATEWAY_API_KEY: "manager-bounded-tree-key", - VERCEL_OIDC_TOKEN: undefined, - FX_GATEWAY_BASE_URL: gateway.baseUrl, - FX_GATEWAY_CHAT_URL: gateway.chatUrl, - FX_MODEL: FAKE_GATEWAY_MODEL, - FX_AUTO_UPGRADE: "0", - NO_COLOR: "1", - }, - width: 100, - height: 30, - stderrPath: fixture.stderrPath, - }); - const active = session; - await active.waitForComposer(TIMEOUT); - await active.sendText("Create the bounded manager tree."); - await active.waitForText("MANAGER_BOUNDED_TREE_COMPLETE", 120_000); - expect(createIndex).toBe(101); - - await active.sendKeys("C-x"); - const first = await active.waitForPane( - (pane) => pane.includes("page-child-000"), - TIMEOUT, - ); - expect(first).not.toContain("page-child-100"); - - await active.sendLiteralText("]"); - const second = await active.waitForPane( - (pane) => - pane.includes("page-child-100") && - pane.includes("Previous children available: [ previous page."), - TIMEOUT, - ); - expect(second).not.toContain("page-child-000"); - - await active.sendLiteralText("["); - const returned = await active.waitForPane( - (pane) => pane.includes("page-child-000"), - TIMEOUT, - ); - expect(returned).not.toContain("page-child-100"); - expect(readFileSync(fixture.stderrPath, "utf8")).toBe(""); - } finally { - gateway.stop(); - } - }, - 150_000, - ); test( "zero-turn parent that owns a persistent child remains available in resume", @@ -7169,22 +5767,16 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { } if (body.includes(outerPrompt)) { return fakeGatewayToolCall("nested_send_inner_create", "subagent", { - command: { - create: { - name: "nested-send-inner", - mode: "persistent", - prompt: innerPrompt, - }, + request: { + action: "run", + task: innerPrompt, }, }); } return fakeGatewayToolCall("nested_send_root_create", "subagent", { - command: { - create: { - name: "nested-send-outer", - mode: "persistent", - prompt: outerPrompt, - }, + request: { + action: "run", + task: outerPrompt, }, }); }, { @@ -7219,8 +5811,8 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { await active.sendKeys("C-x"); await active.waitForPane( (pane) => - pane.includes("nested-send-outer") && - pane.includes("nested-send-inner") && + pane.includes("NESTED_SEND_OUTER_PROMPT") && + pane.includes("NESTED_SEND_INNER_PROMPT") && pane.includes("idle"), TIMEOUT, ); @@ -7228,15 +5820,15 @@ describe.skipIf(!tmuxAvailable())("tui: Agents & processes", () => { const selected = await active.waitForPane( (pane) => pane.split("\n").some((line) => - line.startsWith("› ") && line.includes("nested-send-inner") + line.startsWith("› ") && line.includes("NESTED_SEND_INNER_PROMPT") ), TIMEOUT, ); - expect(selected).toContain("nested-send-outer"); + expect(selected).toContain("NESTED_SEND_OUTER_PROMPT"); await active.sendKeys("Enter"); await active.waitForPane( (pane) => - pane.includes("Subagent: nested-send-inner") && + pane.includes("Subagent: NESTED_SEND_INNER_PROMPT") && pane.includes("status: idle"), TIMEOUT, );