From 5df7cfe047117f658209ee234717eba7a1aa06a0 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 11:13:28 -0400 Subject: [PATCH 01/14] Make steering the default during active turns - Submit active-turn steering with Enter and treat Ctrl+Enter as the same action. - Hide internal queue state while steering waits on a tool, and show Escape as the immediate action. - Interrupt active work and start pending steering without opening queue review. - Continue terminal responses with the latest steering before finalizing the turn. --- README.md | 2 +- src/core/agent/runtime/execution_memory.zig | 21 +- src/core/agent/runtime/orchestrator.zig | 66 +- src/core/agent/runtime/tests/gateway_flow.zig | 30 + src/core/agent/runtime/tests/support.zig | 13 + src/core/agent/worker_runtime.zig | 132 +- src/core/app/app_input_runtime.zig | 27 +- src/core/app/app_render_runtime.zig | 5 + src/core/app/input_queue_runtime.zig | 2 +- src/core/app/input_submit_runtime.zig | 30 +- src/core/input/input_action.zig | 1 - src/main.zig | 57 +- src/ui/footer/input_presentation.zig | 44 +- src/ui/footer/paint_plan.zig | 3 +- src/ui/footer/render_input.zig | 1 + src/ui/input/escape_parser.zig | 2 +- src/ui/input/runtime.zig | 4 +- src/ui/render.zig | 8 +- .../e2e/tui-gateway-stream-lifecycle.test.ts | 1808 ++--------------- tests/e2e/tui-resume.test.ts | 1 - 20 files changed, 450 insertions(+), 1807 deletions(-) diff --git a/README.md b/README.md index 58e2238c9..66d443194 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ cd your_project fx ``` -The current directory becomes the primary workspace. Enter a prompt, or run `/help` to browse interactive commands. While fx is working, press Enter to queue a follow-up or Ctrl+Enter to steer the active turn at its next model boundary. If the turn has already closed, fx safely queues the steering prompt as the next turn. +The current directory becomes the primary workspace. Enter a prompt, or run `/help` to browse interactive commands. While fx is working, Enter steers the active turn at its next safe model boundary. If a tool is running, fx waits for it to finish; press Escape to interrupt the active work and apply the update as soon as the turn settles. Tool calls are expanded by default. Enable `Collapse tool calls` in `/settings`, or set `"collapse_tool_calls": true` in `~/.fx/settings.json`, to show one summary per tool-call group in the main transcript. Individual calls remain available in the full transcript with Ctrl+O. diff --git a/src/core/agent/runtime/execution_memory.zig b/src/core/agent/runtime/execution_memory.zig index 54e0d51c3..ba367fe3c 100644 --- a/src/core/agent/runtime/execution_memory.zig +++ b/src/core/agent/runtime/execution_memory.zig @@ -24,13 +24,24 @@ const ToolCall = types.ToolCall; const Config = runtime_config.Config; const ToolExecutionStatus = runtime_tool_contracts.ToolExecutionStatus; -const steering_open = "\n"; +const steering_open = + "\n" ++ + "Apply this live user update to the current task. Continue working unless the user asks you to stop, the task is complete, or a genuine blocker prevents progress.\n\n"; const steering_close = "\n"; pub fn steeringMessage(alloc: Allocator, text: []const u8) ![]u8 { return std.fmt.allocPrint(alloc, steering_open ++ "{s}" ++ steering_close, .{text}); } +test "steering message tells the model to apply the update and continue" { + const message = try steeringMessage(std.testing.allocator, "focus on rendering"); + defer std.testing.allocator.free(message); + + try std.testing.expect(std.mem.find(u8, message, "live user update") != null); + try std.testing.expect(std.mem.find(u8, message, "Continue working") != null); + try std.testing.expectEqualStrings("focus on rendering", steeringText(message).?); +} + pub fn persistedStatusForCurrentFxLocalResult( status: ToolExecutionStatus, output: []const u8, @@ -1033,11 +1044,15 @@ test "large result storage redacts secret-bearing output before preview and disk test "execution memory persists consumed steering without protocol wrappers" { const alloc = std.testing.allocator; + const first = try steeringMessage(alloc, "focus on rendering"); + defer alloc.free(first); + const second = try steeringMessage(alloc, "run the focused test"); + defer alloc.free(second); const messages = [_]ChatMessage{ .{ .role = .user, .content = "ordinary user context" }, - .{ .role = .user, .content = "\nfocus on rendering\n" }, + .{ .role = .user, .content = first }, .{ .role = .assistant, .content = "continuing" }, - .{ .role = .user, .content = "\nrun the focused test\n" }, + .{ .role = .user, .content = second }, }; const execution = try buildExecutionMemory(alloc, &messages); diff --git a/src/core/agent/runtime/orchestrator.zig b/src/core/agent/runtime/orchestrator.zig index ba7013fdb..fc4a5c4cd 100644 --- a/src/core/agent/runtime/orchestrator.zig +++ b/src/core/agent/runtime/orchestrator.zig @@ -83,6 +83,30 @@ const TurnFinalizationGuard = runtime_finalization.TurnFinalizationGuard; const PromptFinishTrace = runtime_finalization.PromptFinishTrace; const ToolExecutionResult = runtime_tool_contracts.ToolExecutionResult; +fn append_pending_steering_after_assistant( + deps: *const AgentRuntimeDeps, + arena: Allocator, + within_turn_suffix: *std.ArrayList(ChatMessage), + turn_id: u64, + assistant_text: []const u8, +) !bool { + const take_steering = deps.take_steering orelse return false; + const guidance = try take_steering(deps.ctx, arena, turn_id); + if (guidance.len == 0) return false; + + try within_turn_suffix.append(arena, .{ + .role = .assistant, + .content = assistant_text, + }); + for (guidance) |text| { + try within_turn_suffix.append(arena, .{ + .role = .user, + .content = try runtime_execution_memory.steeringMessage(arena, text), + }); + } + return true; +} + fn request_union_schema_advertised( advertised_functions: []const model_tool_schema.FunctionSchema, tool_name: []const u8, @@ -6082,6 +6106,19 @@ fn processQueuedPromptLoop( rendered, ); + if (agent_steps.allowsStep(config.agent_step_limit, step + 1) and + try append_pending_steering_after_assistant( + deps, + arena, + &within_turn_suffix, + turn_id, + history_text, + )) + { + try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" }); + continue; + } + if (!lifecycle.view.hasStop() or stop_state.dispatched) { if (!has_content) { try deps.push_text(deps.ctx, .{ .operational = rendered }); @@ -8673,24 +8710,17 @@ fn processQueuedPromptLoop( const final_text = try runtime_assistant_stream.normalizeAssistantTextForDisplay(arena, raw_final); const rendered = if (final_text.len > 0) final_text else "Done."; - // Close the model-response race: guidance admitted while this step - // was streaming converts the terminal response into an assistant - // prefix followed by a new user steering message. - if (agent_steps.allowsStep(config.agent_step_limit, step + 1)) { - if (deps.take_steering) |take_steering| { - const guidance = try take_steering(deps.ctx, arena, turn_id); - if (guidance.len > 0) { - try within_turn_suffix.append(arena, .{ .role = .assistant, .content = rendered }); - for (guidance) |text| { - try within_turn_suffix.append(arena, .{ - .role = .user, - .content = try runtime_execution_memory.steeringMessage(arena, text), - }); - } - try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" }); - continue; - } - } + if (agent_steps.allowsStep(config.agent_step_limit, step + 1) and + try append_pending_steering_after_assistant( + deps, + arena, + &within_turn_suffix, + turn_id, + rendered, + )) + { + try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" }); + continue; } if (!lifecycle.view.hasStop() or stop_state.dispatched) { diff --git a/src/core/agent/runtime/tests/gateway_flow.zig b/src/core/agent/runtime/tests/gateway_flow.zig index 0c4d67110..ae2f594a0 100644 --- a/src/core/agent/runtime/tests/gateway_flow.zig +++ b/src/core/agent/runtime/tests/gateway_flow.zig @@ -215,6 +215,36 @@ test "processQueuedPrompt accounts exact direct-provider usage without deferred try std.testing.expectEqual(@as(?u64, 1), snapshot.request_count); } +test "terminal assistant completion continues with steering admitted during the response" { + const alloc = std.testing.allocator; + const completions = [_]FakeCompletion{ + .{ .content = "Original answer" }, + .{ .content = "Updated answer" }, + }; + var gateway = FakeGateway.init(alloc, &completions); + defer gateway.deinit(); + const steering = [_][]const u8{"change direction"}; + var hooks = FakeAgentRuntimeDeps.init(alloc); + hooks.steering_messages = &steering; + hooks.steering_take_at = 2; + defer hooks.deinit(); + var fixture = PromptFixture{}; + + try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()); + + try std.testing.expectEqual(@as(usize, 2), gateway.request_bodies.items.len); + try expectBodyContainsInOrder(&gateway, 1, &.{ + "Original answer", + "user_steering", + "change direction", + }); + try std.testing.expectEqualStrings("Updated answer", hooks.finish_assistant_text.?); + try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); + const execution = hooks.history_turns.items[0].assistant.execution; + try std.testing.expectEqual(@as(usize, 1), execution.steering.len); + try std.testing.expectEqualStrings("change direction", execution.steering[0]); +} + fn makeOwnedProviderPrompt(alloc: Allocator, text: []const u8, model: []const u8) !QueuedPrompt { const prompt = try alloc.dupe(u8, text); errdefer alloc.free(prompt); diff --git a/src/core/agent/runtime/tests/support.zig b/src/core/agent/runtime/tests/support.zig index 6db4b5ad1..8f03d56f1 100644 --- a/src/core/agent/runtime/tests/support.zig +++ b/src/core/agent/runtime/tests/support.zig @@ -660,6 +660,9 @@ pub const FakeAgentRuntimeDeps = struct { pause_on_auto_retry_status: bool = false, recovery_pause_flag: ?*std.atomic.Value(bool) = null, route_recovery_status_error_attempt: ?usize = null, + steering_messages: []const []const u8 = &.{}, + steering_take_at: usize = 1, + steering_take_count: usize = 0, pub fn init(alloc: Allocator) FakeAgentRuntimeDeps { return .{ .alloc = alloc }; @@ -774,6 +777,7 @@ pub const FakeAgentRuntimeDeps = struct { .request_route_recovery = if (self.enable_route_recovery) requestRouteRecovery else null, .available_model_capabilities = availableModelCapabilities, .resolve_model_capabilities = resolveModelCapabilities, + .take_steering = if (self.steering_messages.len > 0) takeSteering else null, .format_tool_execution_error = formatError, .record_tool_call_rejected = recordRejected, .report_inner_tool_usage = reportCapturedInnerToolUsage, @@ -854,6 +858,15 @@ pub const FakeAgentRuntimeDeps = struct { return try alloc.dupe(u8, token); } + fn takeSteering(raw: *anyopaque, arena: Allocator, _: u64) ![]const []const u8 { + const self: *FakeAgentRuntimeDeps = @ptrCast(@alignCast(raw)); + self.steering_take_count += 1; + if (self.steering_take_count != self.steering_take_at) return &.{}; + const messages = try arena.alloc([]const u8, self.steering_messages.len); + @memcpy(messages, self.steering_messages); + return messages; + } + fn requestRouteRecovery(raw: *anyopaque, _: Allocator, request: runtime_deps.RouteRecoveryRequest) !runtime_deps.RouteRecoveryDecision { const self: *FakeAgentRuntimeDeps = @ptrCast(@alignCast(raw)); self.route_recovery_count += 1; diff --git a/src/core/agent/worker_runtime.zig b/src/core/agent/worker_runtime.zig index 80ef88f60..44b7b6eaa 100644 --- a/src/core/agent/worker_runtime.zig +++ b/src/core/agent/worker_runtime.zig @@ -625,19 +625,27 @@ pub const WorkerRuntime = struct { self.worker_cancel_requested.store(true, .seq_cst); } - pub fn requestCancelWithQueueReview(self: *WorkerRuntime) bool { + pub fn requestInteractiveCancel(self: *WorkerRuntime) bool { self.worker_mutex.lockUncancelable(io_mod.getIo()); defer self.worker_mutex.unlock(io_mod.getIo()); - const paused = self.beginQueueReviewLocked(.post_cancel); - debug_trace.logf("worker", "cancel requested processing={s} queued={d} queue_paused={s}", .{ + const steering_pending = for (self.queued_prompts.items) |prompt| { + if (prompt.steer_target_turn_id == self.active_turn_id) break true; + } else false; + const paused = if (steering_pending) + false + else + self.beginQueueReviewLocked(.post_cancel); + debug_trace.logf("worker", "cancel requested processing={s} queued={d} steering_pending={s} queue_paused={s}", .{ if (self.worker_processing) "true" else "false", self.queued_prompt_count, + if (steering_pending) "true" else "false", if (paused) "true" else "false", }); - debug_trace.eventf("interrupt", "cancel_requested", .{}, "processing={s} queued={d} queue_paused={s} active_tool_known=false", .{ + debug_trace.eventf("interrupt", "cancel_requested", .{}, "processing={s} queued={d} steering_pending={s} queue_paused={s} active_tool_known=false", .{ if (self.worker_processing) "true" else "false", self.queued_prompt_count, + if (steering_pending) "true" else "false", if (paused) "true" else "false", }); self.worker_cancel_requested.store(true, .seq_cst); @@ -794,9 +802,13 @@ pub const WorkerRuntime = struct { try self.admitPrompt(alloc, prompt, false); } - /// Transfers `prompt` to the active turn when steering is requested and the - /// turn still accepts guidance. Otherwise it enters the ordinary FIFO. - pub fn admitPrompt( + pub fn admitInteractivePrompt(self: *WorkerRuntime, alloc: std.mem.Allocator, prompt: QueuedPrompt) !void { + try self.admitPrompt(alloc, prompt, true); + } + + /// Targets eligible interactive input to the active turn. Other input + /// remains in the ordinary FIFO. + fn admitPrompt( self: *WorkerRuntime, alloc: std.mem.Allocator, prompt: QueuedPrompt, @@ -915,6 +927,11 @@ pub const WorkerRuntime = struct { fn beginQueueReviewLocked(self: *WorkerRuntime, reason: QueueReviewReason) bool { if (self.queued_prompts.items.len == 0) return false; + if (reason == .manual) { + for (self.queued_prompts.items) |prompt| { + if (prompt.steer_target_turn_id == self.active_turn_id) return false; + } + } if (self.queue_admission) |current| { if (reason == .post_cancel and current != .post_cancel) { self.queue_admission = .post_cancel; @@ -3148,6 +3165,24 @@ fn freeEventList(alloc: std.mem.Allocator, events: *std.ArrayList(WorkerEvent)) events.deinit(alloc); } +test "interactive prompt admission derives steering from active worker state" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "steer", "model")); + + const guidance = try runtime.takeSteering(alloc, 41); + defer { + for (guidance) |text| alloc.free(text); + alloc.free(guidance); + } + try std.testing.expectEqual(@as(usize, 1), guidance.len); + try std.testing.expectEqualStrings("steer", guidance[0]); +} + test "active prompt admission drains steering in FIFO order" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; @@ -3155,8 +3190,8 @@ test "active prompt admission drains steering in FIFO order" { runtime.worker_processing = true; runtime.active_turn_id = 41; - try runtime.admitPrompt(alloc, try makePrompt(alloc, "first", "model"), true); - try runtime.admitPrompt(alloc, try makePrompt(alloc, "second", "model"), true); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "second", "model")); const guidance = try runtime.takeSteering(alloc, 41); defer { for (guidance) |text| alloc.free(text); @@ -3171,27 +3206,15 @@ test "active prompt admission drains steering in FIFO order" { try std.testing.expectEqualStrings("second", runtime.worker_events.items[1].append_user_feedback); } -test "queue review atomically blocks steering consumption and edits by prompt identity" { +test "manual queue review does not intercept active steering" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; defer runtime.deinit(alloc); runtime.worker_processing = true; runtime.active_turn_id = 41; - try runtime.admitPrompt(alloc, try makePrompt(alloc, "before", "model"), true); - try std.testing.expect(runtime.beginQueueReview(.manual)); - - const drafts = try runtime.snapshotQueuedPromptDrafts(alloc); - defer freeQueuedPromptDrafts(alloc, drafts); - try std.testing.expectEqual(@as(usize, 1), drafts.len); - try std.testing.expectEqual(PromptDraftKind.steering, drafts[0].kind); - try std.testing.expectEqual(@as(usize, 0), (try runtime.takeSteering(alloc, 41)).len); - - const edited_text = try alloc.dupe(u8, "after"); - alloc.free(drafts[0].prompt); - drafts[0].prompt = edited_text; - try std.testing.expect(try runtime.replaceQueuedPromptDrafts(alloc, drafts)); - try std.testing.expect(runtime.resumeQueueReview()); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "before", "model")); + try std.testing.expect(!runtime.beginQueueReview(.manual)); const guidance = try runtime.takeSteering(alloc, 41); defer { @@ -3199,34 +3222,7 @@ test "queue review atomically blocks steering consumption and edits by prompt id alloc.free(guidance); } try std.testing.expectEqual(@as(usize, 1), guidance.len); - try std.testing.expectEqualStrings("after", guidance[0]); -} - -test "queue review commits steering edit after active turn demotes it" { - const alloc = std.testing.allocator; - var runtime = WorkerRuntime{}; - defer runtime.deinit(alloc); - runtime.worker_processing = true; - runtime.active_turn_id = 41; - - try runtime.admitPrompt(alloc, try makePrompt(alloc, "before", "model"), true); - try std.testing.expect(runtime.beginQueueReview(.manual)); - - const drafts = try runtime.snapshotQueuedPromptDrafts(alloc); - defer freeQueuedPromptDrafts(alloc, drafts); - try std.testing.expectEqual(@as(usize, 1), drafts.len); - try std.testing.expectEqual(PromptDraftKind.steering, drafts[0].kind); - - runtime.finishProcessing(); - try std.testing.expect(runtime.queued_prompts.items[0].steer_target_turn_id == null); - - const edited_text = try alloc.dupe(u8, "after"); - alloc.free(drafts[0].prompt); - drafts[0].prompt = edited_text; - try std.testing.expect(try runtime.replaceQueuedPromptDrafts(alloc, drafts)); - - try std.testing.expectEqualStrings("after", runtime.queued_prompts.items[0].prompt); - try std.testing.expect(runtime.queued_prompts.items[0].steer_target_turn_id == null); + try std.testing.expectEqualStrings("before", guidance[0]); } test "late steering keeps admission order when demoted on finish" { @@ -3236,8 +3232,8 @@ test "late steering keeps admission order when demoted on finish" { runtime.worker_processing = true; runtime.active_turn_id = 9; - try runtime.admitPrompt(alloc, try makePrompt(alloc, "steer first", "model"), true); - try runtime.admitPrompt(alloc, try makePrompt(alloc, "queue second", "model"), false); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "steer first", "model")); + try runtime.enqueuePrompt(alloc, try makePrompt(alloc, "queue second", "model")); runtime.finishProcessing(); try std.testing.expect(!runtime.worker_processing); @@ -3255,8 +3251,8 @@ test "clear queued prompts also clears steering" { runtime.worker_processing = true; runtime.active_turn_id = 9; - try runtime.admitPrompt(alloc, try makePrompt(alloc, "steer", "model"), true); - try runtime.admitPrompt(alloc, try makePrompt(alloc, "queued", "model"), false); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "steer", "model")); + try runtime.enqueuePrompt(alloc, try makePrompt(alloc, "queued", "model")); runtime.clearQueuedPrompts(alloc, &.{}); try std.testing.expectEqual(@as(usize, 0), runtime.queuePreview().count); @@ -3264,12 +3260,12 @@ test "clear queued prompts also clears steering" { try std.testing.expectEqual(@as(usize, 0), (try runtime.takeSteering(alloc, 9)).len); } -test "idle steer request uses ordinary queue" { +test "idle interactive admission uses ordinary queue" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; defer runtime.deinit(alloc); - try runtime.admitPrompt(alloc, try makePrompt(alloc, "next", "model"), true); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "next", "model")); try std.testing.expectEqual(@as(usize, 1), runtime.queuedPromptCount()); try std.testing.expect(runtime.queued_prompts.items[0].steer_target_turn_id == null); } @@ -4298,7 +4294,7 @@ test "explicit cancellation pauses queued admission for post-cancel review" { runtime.worker_processing = true; try runtime.enqueuePrompt(alloc, try makePrompt(alloc, "queued", "model")); - try std.testing.expect(runtime.requestCancelWithQueueReview()); + try std.testing.expect(runtime.requestInteractiveCancel()); try std.testing.expect(runtime.isCancelRequested()); try std.testing.expectEqual(QueueReviewReason.post_cancel, runtime.queueReviewReason().?); var snapshot = try runtime.snapshotState(alloc); @@ -4307,6 +4303,24 @@ test "explicit cancellation pauses queued admission for post-cancel review" { try std.testing.expectEqual(QueueReviewReason.post_cancel, snapshot.queue_review_reason.?); } +test "explicit cancellation keeps targeted steering runnable" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 17; + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "steer now", "model")); + + try std.testing.expect(!runtime.requestInteractiveCancel()); + try std.testing.expect(runtime.isCancelRequested()); + try std.testing.expect(runtime.queueReviewReason() == null); + + runtime.finishProcessing(); + const next = (try runtime.tryTakeNextPrompt(alloc)).?; + defer freeQueuedPrompt(alloc, next); + try std.testing.expectEqualStrings("steer now", next.prompt); +} + test "turn start hold rejects busy worker and blocks take while held" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; diff --git a/src/core/app/app_input_runtime.zig b/src/core/app/app_input_runtime.zig index 8af59935a..f85db98e2 100644 --- a/src/core/app/app_input_runtime.zig +++ b/src/core/app/app_input_runtime.zig @@ -650,7 +650,6 @@ pub fn Runtime(comptime App: type) type { decoded.question_action, decoded.cancel_pending, input_limits.composer_bytes, - max_prompt_history, )) { .done => {}, .remapped_byte => |byte| { @@ -960,7 +959,6 @@ pub fn Runtime(comptime App: type) type { question_action: ?question_prompt.Action, was_cancel_pending: bool, max_input_len: usize, - max_prompt_history: usize, ) !ResolvedEscapeRoute { switch (resolved) { .remapped_byte, .paste_start, .paste_end, .ignore => {}, @@ -1106,7 +1104,6 @@ pub fn Runtime(comptime App: type) type { .composer_shortcut, .toggle_full_transcript, => unreachable, - .steer_submit => try submit_rt.submitSteering(app, max_prompt_history), .page_up, .page_down, .mouse_wheel, @@ -5474,7 +5471,6 @@ test "app_input_runtime ctrl-l preserves an active inline picker" { null, false, 4096, - 100, ); try std.testing.expect(app.skills.menu.active); try std.testing.expectEqualStrings("$man", app.input_runtime.edit_state.input.items); @@ -8795,7 +8791,6 @@ test "app_input_runtime active multiline history moves vertically before advanci null, false, 4096, - 100, ); try std.testing.expectEqualStrings("older", app.input_runtime.edit_state.input.items); @@ -11450,7 +11445,6 @@ const FakeSubmitApp = struct { transcript: std.ArrayList(u8) = .empty, last_command: ?[]u8 = null, last_prompt: ?[]u8 = null, - last_steering: ?[]u8 = null, last_images: []types.ImageAttachment = &.{}, last_skill_tokens: std.ArrayList(registered_entities.SkillTokenSpan) = .empty, notice_topic: std.ArrayList(u8) = .empty, @@ -11489,7 +11483,6 @@ const FakeSubmitApp = struct { self.notice_body.deinit(self.alloc); if (self.last_command) |text| self.alloc.free(text); if (self.last_prompt) |text| self.alloc.free(text); - if (self.last_steering) |text| self.alloc.free(text); types.freeImageAttachmentSlice(self.alloc, self.last_images); self.clearLastSkillTokens(); self.last_skill_tokens.deinit(self.alloc); @@ -11581,14 +11574,6 @@ const FakeSubmitApp = struct { return self.enqueuePromptWithSkillBindings(text, &.{}); } - pub fn steerPrompt(self: *FakeSubmitApp, text: []const u8) !bool { - if (!self.queue_admitted) return false; - const copy = try self.alloc.dupe(u8, text); - if (self.last_steering) |old| self.alloc.free(old); - self.last_steering = copy; - return true; - } - pub fn enqueuePromptWithSkillBindings( self: *FakeSubmitApp, text: []const u8, @@ -11785,7 +11770,6 @@ test "composer shortcut line delete handles decoded and raw mutations" { null, false, 4096, - 100, ); try std.testing.expectEqualStrings("alpha\nright\ngamma", app.input_runtime.edit_state.input.items); try std.testing.expect(app.shell.render_requests.hasReason(.footer)); @@ -11865,7 +11849,6 @@ test "composer shortcut line delete preserves no-op picker redraw and metadata s null, false, 4096, - 100, ); try std.testing.expect(app.shell.render_requests.hasReason(.footer)); try std.testing.expectEqual(picker_state.ModelPickerStage.effort, app.input_runtime.picker.model_picker_stage); @@ -11881,7 +11864,6 @@ test "composer shortcut line delete preserves no-op picker redraw and metadata s null, false, 4096, - 100, ); try std.testing.expect(!app.shell.render_requests.hasReason(.footer)); try std.testing.expectEqual(picker_state.ModelPickerStage.fast, app.input_runtime.picker.model_picker_stage); @@ -14034,20 +14016,15 @@ test "app_input_runtime paste edit keeps the original history draft reachable" { try std.testing.expectEqualStrings("unsent draft", app.input_runtime.composer_history.draftText().?); } -test "ctrl+enter submits steering while ordinary submit keeps queue semantics" { +test "ordinary submit uses one interactive prompt path" { const alloc = std.testing.allocator; var app = FakeSubmitApp{ .alloc = alloc }; defer app.deinit(); app.stream.active = true; try app.input_runtime.edit_state.input.appendSlice(alloc, "steer now"); - try input_submit_runtime.SubmitRuntime(FakeSubmitApp).submitSteering(&app, 100); - try std.testing.expectEqualStrings("steer now", app.last_steering.?); - try std.testing.expect(app.last_prompt == null); - - try app.input_runtime.edit_state.input.appendSlice(alloc, "queue next"); try input_submit_runtime.SubmitRuntime(FakeSubmitApp).submit(&app, 100); - try std.testing.expectEqualStrings("queue next", app.last_prompt.?); + try std.testing.expectEqualStrings("steer now", app.last_prompt.?); } test "app_input_runtime small paste opens skills menu for matching dollar token" { diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index fd32b2aba..98a28c1de 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -630,6 +630,11 @@ pub fn Runtime(comptime App: type) type { queue_preview.steering_count else 0, + .steering_waiting_on_tool = if (comptime @hasField(@TypeOf(queue_preview), "steering_count")) + queue_preview.steering_count > 0 and + shell_runtime.activeToolActivityCount(&app.shell) > 0 + else + false, .queued_paused = if (comptime @hasField(@TypeOf(queue_preview), "paused")) queue_preview.paused else diff --git a/src/core/app/input_queue_runtime.zig b/src/core/app/input_queue_runtime.zig index 77dfb450d..dba93eb0d 100644 --- a/src/core/app/input_queue_runtime.zig +++ b/src/core/app/input_queue_runtime.zig @@ -105,7 +105,7 @@ pub const PromptAdmission = enum { pub fn Runtime(comptime App: type) type { return struct { pub fn requestCancelAndOpen(app: *App) bool { - return openAfterPause(app, app.worker.requestCancelWithQueueReview()); + return openAfterPause(app, app.worker.requestInteractiveCancel()); } pub fn pauseAndOpenAfterModalCancel(app: *App) bool { diff --git a/src/core/app/input_submit_runtime.zig b/src/core/app/input_submit_runtime.zig index dc6495117..7afdb680e 100644 --- a/src/core/app/input_submit_runtime.zig +++ b/src/core/app/input_submit_runtime.zig @@ -429,21 +429,11 @@ pub fn SubmitRuntime(comptime App: type) type { ); } - pub const Intent = enum { queue, steer }; - pub fn submitInput(app: *App, max_prompt_history: usize) !void { try submit(app, max_prompt_history); } - pub fn submitSteering(app: *App, max_prompt_history: usize) !void { - try submitWithIntent(app, max_prompt_history, .steer); - } - pub fn submit(app: *App, max_prompt_history: usize) !void { - try submitWithIntent(app, max_prompt_history, .queue); - } - - fn submitWithIntent(app: *App, max_prompt_history: usize, intent: Intent) !void { if (comptime @hasField(App, "submission")) { if (app.submission.pending) |pending| { debug_trace.eventf( @@ -522,7 +512,7 @@ pub fn SubmitRuntime(comptime App: type) type { if (trimmed.len == 0) { if (app.pending_images.items.len > 0) { if (!try preflightPrompt(app)) return; - const admission = try enqueuePromptForSubmit(app, "", &.{}, null, intent); + const admission = try enqueuePromptForSubmit(app, "", &.{}, null); if (admission == .rejected) return; releasePendingImages(app); app.input_runtime.inputResetState().clearCurrent(app.alloc); @@ -646,7 +636,6 @@ pub fn SubmitRuntime(comptime App: type) type { display_skill_tokens, &accepted_draft, images, - intent, ) else try enqueuePromptForSubmit( @@ -654,7 +643,6 @@ pub fn SubmitRuntime(comptime App: type) type { visual_text.text, display_skill_tokens, &accepted_draft, - intent, ); if (admission == .rejected) return; commitStableExtractedImageIds(app, extracted.images); @@ -868,13 +856,10 @@ pub fn SubmitRuntime(comptime App: type) type { prompt: []const u8, skill_tokens: []const registered_entities.SkillTokenSpan, accepted_draft: ?*const AcceptedDraftProjection, - intent: Intent, ) !PromptAdmission { - if (intent == .queue) { - switch (try installPendingSubmission(app, prompt, skill_tokens)) { - .installed => return .pending, - .unavailable => {}, - } + switch (try installPendingSubmission(app, prompt, skill_tokens)) { + .installed => return .pending, + .unavailable => {}, } const resume_review = if (comptime @hasField(App, "queued_prompt_review")) app.queued_prompt_review.active() @@ -895,10 +880,7 @@ pub fn SubmitRuntime(comptime App: type) type { } } - const accepted = if (intent == .steer and - (comptime @hasDecl(App, "steerPrompt"))) - try App.steerPrompt(app, prompt) - else if (comptime @hasDecl(App, "enqueuePromptWithReviewDraft")) blk: { + const accepted = if (comptime @hasDecl(App, "enqueuePromptWithReviewDraft")) blk: { if (accepted_draft) |draft| { break :blk try App.enqueuePromptWithReviewDraft( app, @@ -983,7 +965,6 @@ pub fn SubmitRuntime(comptime App: type) type { skill_tokens: []const registered_entities.SkillTokenSpan, accepted_draft: *const AcceptedDraftProjection, staged_images: *std.ArrayList(types.ImageAttachment), - intent: Intent, ) !PromptAdmission { const original_images = app.pending_images; app.pending_images = staged_images.*; @@ -998,7 +979,6 @@ pub fn SubmitRuntime(comptime App: type) type { prompt, skill_tokens, accepted_draft, - intent, ); if (admission == .rejected) { staged_images.* = app.pending_images; diff --git a/src/core/input/input_action.zig b/src/core/input/input_action.zig index 0ce60e3f3..9ab480a9e 100644 --- a/src/core/input/input_action.zig +++ b/src/core/input/input_action.zig @@ -93,7 +93,6 @@ pub const Action = union(enum) { toggle_permission_mode, open_all_sessions, insert_newline, - steer_submit, paste_start, paste_end, composer_shortcut: ShortcutAction, diff --git a/src/main.zig b/src/main.zig index abaf5dd79..265a2146e 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1062,14 +1062,9 @@ const App = struct { prompt, skill_tokens, null, - .queue, ); } - pub fn steerPrompt(self: *App, prompt: []const u8) !bool { - return self.enqueuePromptWithOptionalReview(prompt, &.{}, null, .steer); - } - pub fn enqueuePromptWithReviewDraft( self: *App, prompt: []const u8, @@ -1096,18 +1091,14 @@ const App = struct { .image_tokens = @constCast(review_image_tokens), .skill_display_spans = review_skill_spans, }, - .queue, ); } - const PromptSubmitIntent = enum { queue, steer }; - fn enqueuePromptWithOptionalReview( self: *App, prompt: []const u8, skill_tokens: []const registered_entities.SkillTokenSpan, review_draft: ?worker_runtime.QueueReviewDraft, - intent: PromptSubmitIntent, ) !bool { const context_targets = if (self.context_enabled) try context_contract.applicableTargetsForImages(self.alloc, self.pending_images.items) @@ -1128,11 +1119,10 @@ const App = struct { debug_trace.preview(prompt, 120), }, ); - if (!try self.snapshotAndQueuePromptWithSkillBindings( + if (!try self.snapshotAndAdmitInteractivePromptWithSkillBindings( prompt, skill_tokens, review_draft, - intent, )) return false; WorkerAppRuntime.syncState( self, @@ -1177,7 +1167,6 @@ const App = struct { draft.images, draft.turn_id, true, - .queue, )) return error.PendingPromptQueueRejected; WorkerAppRuntime.syncState( self, @@ -1326,14 +1315,13 @@ const App = struct { try RenderAppRuntime.flushRequestedFrame(@as(*Self, self)); } - pub fn snapshotAndQueuePromptWithSkillBindings( + fn snapshotAndAdmitInteractivePromptWithSkillBindings( self: *App, prompt: []const u8, skill_tokens: []const registered_entities.SkillTokenSpan, review_draft: ?worker_runtime.QueueReviewDraft, - intent: PromptSubmitIntent, ) !bool { - return self.snapshotAndQueuePrompt( + const queued = try self.snapshotPrompt( prompt, skill_tokens, review_draft, @@ -1341,8 +1329,11 @@ const App = struct { null, 0, false, - intent, ); + errdefer worker_runtime.freeQueuedPrompt(std.heap.c_allocator, queued); + try self.worker.admitInteractivePrompt(std.heap.c_allocator, queued); + HerdrAppRuntime.reportWorking(self); + return true; } pub fn continuePausedRecovery(self: *App) !bool { @@ -1361,7 +1352,6 @@ const App = struct { null, checkpoint.turn_id, false, - .queue, )) return false; WorkerAppRuntime.syncState( self, @@ -1379,8 +1369,33 @@ const App = struct { prompt_images: ?[]const types.ImageAttachment, turn_id: u64, user_prompt_already_presented: bool, - intent: PromptSubmitIntent, ) !bool { + const queued = try self.snapshotPrompt( + prompt, + skill_tokens, + review_draft, + recovery_checkpoint, + prompt_images, + turn_id, + user_prompt_already_presented, + ); + errdefer worker_runtime.freeQueuedPrompt(std.heap.c_allocator, queued); + try self.worker.enqueuePrompt(std.heap.c_allocator, queued); + HerdrAppRuntime.reportWorking(self); + return true; + } + + // Caller owns the returned prompt until worker admission succeeds. + fn snapshotPrompt( + self: *App, + prompt: []const u8, + skill_tokens: []const registered_entities.SkillTokenSpan, + review_draft: ?worker_runtime.QueueReviewDraft, + recovery_checkpoint: ?*const session_codec.RecoveryCheckpoint, + prompt_images: ?[]const types.ImageAttachment, + turn_id: u64, + user_prompt_already_presented: bool, + ) !worker_runtime.QueuedPrompt { const source_images = if (recovery_checkpoint) |checkpoint| checkpoint.user.images else if (prompt_images) |images| @@ -1465,7 +1480,7 @@ const App = struct { review, ); - try self.worker.admitPrompt(std.heap.c_allocator, .{ + return .{ .turn_id = if (recovery_checkpoint) |checkpoint| checkpoint.turn_id else turn_id, .prompt = prompt_copy, .images = images_copy, @@ -1487,9 +1502,7 @@ const App = struct { .recovery_checkpoint = recovery_checkpoint_copy, .recovery_source_already_presented = recovery_checkpoint != null, .user_prompt_already_presented = user_prompt_already_presented, - }, recovery_checkpoint == null and intent == .steer); - HerdrAppRuntime.reportWorking(self); - return true; + }; } pub fn installInitialMcpRuntime(self: *App, runtime: ?*mcp_runtime_mod.McpRuntime) void { diff --git a/src/ui/footer/input_presentation.zig b/src/ui/footer/input_presentation.zig index 6a4a4407a..dae336030 100644 --- a/src/ui/footer/input_presentation.zig +++ b/src/ui/footer/input_presentation.zig @@ -54,12 +54,13 @@ pub const ComposedInputRows = struct { } }; -// Collapsed queue banner: the prompts stay hidden until the review is opened, -// so this row only reports how many are waiting and how to reach them. +// Pending prompts stay hidden here. Ordinary queued work advertises review, +// while active-turn steering reports its wait and interrupt action. pub fn composeQueuedSummaryRow( alloc: Allocator, queued_count: usize, steering_count: usize, + steering_waiting_on_tool: bool, queued_paused: bool, width: u16, ) !std.ArrayList(u8) { @@ -67,17 +68,14 @@ pub fn composeQueuedSummaryRow( try row.appendSlice(alloc, ui_render.hint_style); // The paused hint row already owns the controls, so it drops the affordance. - const affordance = if (queued_paused) "" else " · ↑ to edit"; + const affordance = if (queued_paused or steering_count > 0) "" else " · ↑ to edit"; var row_buf: [max_top_row_len]u8 = undefined; - const ordinary_count = queued_count -| steering_count; const label = if (queued_count == 0) "queued" - else if (ordinary_count == 0 and steering_count == 1) - std.fmt.bufPrint(&row_buf, "1 steering message{s}", .{affordance}) catch "1 steering message" - else if (ordinary_count == 0) - std.fmt.bufPrint(&row_buf, "{d} steering messages{s}", .{ steering_count, affordance }) catch "steering messages" + else if (steering_count > 0 and steering_waiting_on_tool) + "Waiting for tool · Esc to steer now" else if (steering_count > 0) - std.fmt.bufPrint(&row_buf, "{d} pending messages · {d} steering{s}", .{ queued_count, steering_count, affordance }) catch "pending messages" + std.fmt.bufPrint(&row_buf, "{d} pending message{s}", .{ queued_count, if (queued_count == 1) "" else "s" }) catch "pending messages" else if (queued_count == 1) std.fmt.bufPrint(&row_buf, "1 queued message{s}", .{affordance}) catch "1 queued message" else @@ -93,13 +91,10 @@ pub fn composeQueueReviewHintRow( width: u16, empty_draft: bool, cancel_all_available: bool, - steering: bool, ) !std.ArrayList(u8) { var row: std.ArrayList(u8) = .empty; try row.appendSlice(alloc, ui_render.dim_style); - const hint = if (steering) - "steering paused · enter to apply" - else if (cancel_all_available) + const hint = if (cancel_all_available) "paused · enter to send · press esc to cancel all queued" else if (empty_draft) "paused · delete again to remove queued prompt · enter to send unchanged" @@ -111,24 +106,24 @@ pub fn composeQueueReviewHintRow( } test "collapsed queue banner counts the waiting prompts and offers the review" { - var single = try composeQueuedSummaryRow(std.testing.allocator, 1, 0, false, 80); + var single = try composeQueuedSummaryRow(std.testing.allocator, 1, 0, false, false, 80); defer single.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, single.items, "1 queued message · ↑ to edit") != null); - var many = try composeQueuedSummaryRow(std.testing.allocator, 3, 0, false, 80); + var many = try composeQueuedSummaryRow(std.testing.allocator, 3, 0, false, false, 80); defer many.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, many.items, "3 queued messages · ↑ to edit") != null); } test "collapsed queue banner identifies pending steering" { - var row = try composeQueuedSummaryRow(std.testing.allocator, 1, 1, false, 80); + var row = try composeQueuedSummaryRow(std.testing.allocator, 1, 1, true, false, 80); defer row.deinit(std.testing.allocator); - try std.testing.expect(std.mem.find(u8, row.items, "1 steering message · ↑ to edit") != null); + try std.testing.expect(std.mem.find(u8, row.items, "Waiting for tool · Esc to steer now") != null); } test "collapsed queue banner drops the affordance while the review is paused" { - var row = try composeQueuedSummaryRow(std.testing.allocator, 2, 0, true, 80); + var row = try composeQueuedSummaryRow(std.testing.allocator, 2, 0, false, true, 80); defer row.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, row.items, "2 queued messages") != null); @@ -136,7 +131,7 @@ test "collapsed queue banner drops the affordance while the review is paused" { } test "queue review hint explains empty draft deletion" { - var row = try composeQueueReviewHintRow(std.testing.allocator, 100, true, false, false); + var row = try composeQueueReviewHintRow(std.testing.allocator, 100, true, false); defer row.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, row.items, "delete again to remove queued prompt") != null); @@ -144,19 +139,12 @@ test "queue review hint explains empty draft deletion" { } test "post-cancel queue review hint offers cancelling every queued prompt" { - var row = try composeQueueReviewHintRow(std.testing.allocator, 100, false, true, false); + var row = try composeQueueReviewHintRow(std.testing.allocator, 100, false, true); defer row.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, row.items, "press esc to cancel all queued") != null); } -test "steering review hint says enter applies steering" { - var row = try composeQueueReviewHintRow(std.testing.allocator, 100, false, false, true); - defer row.deinit(std.testing.allocator); - - try std.testing.expect(std.mem.find(u8, row.items, "steering paused · enter to apply") != null); -} - // Ordered widest-first; every fallback keeps the enter/esc controls so narrow // terminals never lose the submit and cancel instructions. const freeform_question_hints = [_][]const u8{ @@ -438,7 +426,7 @@ pub fn composeHintRow( ctx.has_api_key or (ctx.auth_picker.active and ctx.auth_picker.include_skip), ctx.model, ctx.permission_mode, - ctx.queued_count, + ctx.queued_count -| ctx.steering_count, active_label, ctx.fast_indicator_active, ctx.effort, diff --git a/src/ui/footer/paint_plan.zig b/src/ui/footer/paint_plan.zig index 4bb4d04ba..d9ea9a2fc 100644 --- a/src/ui/footer/paint_plan.zig +++ b/src/ui/footer/paint_plan.zig @@ -585,6 +585,7 @@ fn pushQueuedPromptBannerRows( alloc, ctx.queued_count, ctx.steering_count, + ctx.steering_waiting_on_tool, ctx.queued_paused, width, ); @@ -596,7 +597,6 @@ fn pushQueuedPromptBannerRows( width, false, ctx.queued_cancel_all_available, - ctx.steering_count > 0, ); try pushFooterBandRow(alloc, frame, plan, plan.footer.banner +| painted, &hint); painted +|= 1; @@ -720,7 +720,6 @@ fn pushQueuedPromptBannerRows( width, empty_draft, ctx.queued_cancel_all_available, - ctx.steering_count > 0, ); try pushFooterBandRow(alloc, frame, plan, hint_row, &hint); } diff --git a/src/ui/footer/render_input.zig b/src/ui/footer/render_input.zig index 7dd0b8330..dc0a255ef 100644 --- a/src/ui/footer/render_input.zig +++ b/src/ui/footer/render_input.zig @@ -422,6 +422,7 @@ pub const RenderContext = struct { permission_mode: types.PermissionMode = .ask, queued_count: usize, steering_count: usize = 0, + steering_waiting_on_tool: bool = false, queued_paused: bool = false, queued_cancel_all_available: bool = false, queued_prompt_cards: []const QueuedPromptCard = &.{}, diff --git a/src/ui/input/escape_parser.zig b/src/ui/input/escape_parser.zig index 60837b570..818dbf0f3 100644 --- a/src/ui/input/escape_parser.zig +++ b/src/ui/input/escape_parser.zig @@ -109,7 +109,7 @@ fn kittyUnicodeKeyAction(keycode: u16, modifiers: u16, meta_prefixed: bool) Inpu } return if (keycode == kitty_up_key) .cursor_up else .cursor_down; } - if (keycode == 13 and mods == ctrl_modifier and !meta_prefixed) return .steer_submit; + if (keycode == 13 and mods == ctrl_modifier and !meta_prefixed) return .{ .remapped_byte = '\r' }; if (keycode == 13 and (mods & (shift_modifier | alt_modifier)) != 0) { return .insert_newline; } diff --git a/src/ui/input/runtime.zig b/src/ui/input/runtime.zig index f6bfa912e..1265cfaec 100644 --- a/src/ui/input/runtime.zig +++ b/src/ui/input/runtime.zig @@ -4660,7 +4660,7 @@ test "input escape parser handles cmd+arrow as home/end" { try std.testing.expectEqual(@as(u8, 0), stage); } -test "input escape parser handles ctrl+enter as steering submit" { +test "input escape parser treats ctrl+enter as ordinary submit" { // ESC[13;5u is Kitty's Ctrl+Enter encoding. var stage: u8 = 1; var param: u16 = 0; @@ -4670,7 +4670,7 @@ test "input escape parser handles ctrl+enter as steering submit" { try std.testing.expectEqual(@as(?InputEscapeAction, null), consumeInputEscapeByte(&stage, ¶m, ¶m2, '3')); try std.testing.expectEqual(@as(?InputEscapeAction, null), consumeInputEscapeByte(&stage, ¶m, ¶m2, ';')); try std.testing.expectEqual(@as(?InputEscapeAction, null), consumeInputEscapeByte(&stage, ¶m, ¶m2, '5')); - try std.testing.expectEqual(@as(?InputEscapeAction, .steer_submit), consumeInputEscapeByte(&stage, ¶m, ¶m2, 'u')); + try std.testing.expectEqual(@as(?InputEscapeAction, .{ .remapped_byte = '\r' }), consumeInputEscapeByte(&stage, ¶m, ¶m2, 'u')); try std.testing.expectEqual(@as(u8, 0), stage); } diff --git a/src/ui/render.zig b/src/ui/render.zig index 293f390b3..094551d45 100644 --- a/src/ui/render.zig +++ b/src/ui/render.zig @@ -418,9 +418,7 @@ pub fn buildHintLine( var queued_buf: [32]u8 = undefined; appendStatusSegment(out, &end, std.fmt.bufPrint(&queued_buf, "queued {d}", .{queued_count}) catch ""); } - if (stream_active and !awaiting_permission) { - appendStatusSegment(out, &end, "enter queue"); - } + _ = stream_active; const status_limit = @min(@as(usize, width), out.len); const show_effort = model_supports_effort and !effort.isDefault(); if (leadingPermissionModeFits(status_limit, permission_label, model_label)) { @@ -940,10 +938,10 @@ test "dev build label drops an unresolved revision" { try std.testing.expectEqualStrings(expected, label); } -test "buildHintLine advertises queue without persistent steering hint while streaming" { +test "buildHintLine does not advertise queue or steering modes while streaming" { var buf: [128]u8 = undefined; const line = buildHintLine(true, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{}, 120, &buf); - try std.testing.expect(std.mem.find(u8, line, "enter queue") != null); + try std.testing.expect(std.mem.find(u8, line, "enter queue") == null); try std.testing.expect(std.mem.find(u8, line, "ctrl+enter steer") == null); } diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index 123f006cc..9285d055c 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -630,32 +630,6 @@ function splitHeldTextResponse( ); } -function duplicateKeyToolResponse(): Response { - return fakeGatewaySse([ - { type: "tool-input-start", id: "queued_duplicate_list", toolName: "glob_files" }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: '{"' }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: "dept" }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: 'h"' }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: ":1" }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: ', "' }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: "dept" }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: 'h"' }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: ":2" }, - { type: "tool-input-delta", id: "queued_duplicate_list", delta: "}" }, - { type: "tool-input-end", id: "queued_duplicate_list" }, - { - type: "tool-call", - toolCallId: "queued_duplicate_list", - toolName: "glob_files", - input: '{"depth":1, "depth":2}', - }, - { - type: "finish", - finishReason: { unified: "tool-calls", raw: "tool-calls" }, - }, - ]); -} - async function waitForCondition( predicate: () => boolean, description: string, @@ -669,33 +643,6 @@ async function waitForCondition( throw new Error(`timed out waiting for ${description}`); } -async function waitForCursorRow( - session: TmuxSession, - text: string, - description: string, - timeoutMs = TIMEOUT, -): Promise<{ cursor: { row: number; col: number }; grid: string[] }> { - const started = Date.now(); - let cursor = session.cursorPosition(); - let grid: string[] = []; - while (Date.now() - started < timeoutMs) { - const before = session.cursorPosition(); - grid = await session.capturePaneGrid(); - cursor = session.cursorPosition(); - if ( - before.row === cursor.row && - before.col === cursor.col && - grid[cursor.row]?.includes(text) - ) { - return { cursor, grid }; - } - await Bun.sleep(25); - } - throw new Error( - `timed out waiting for ${description}; cursor=${JSON.stringify(cursor)}\n${grid.join("\n")}`, - ); -} - async function waitForEscapedScrollback( session: TmuxSession, predicate: (scrollback: string) => boolean, @@ -2954,132 +2901,155 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { }, SPLIT_BOUNDARY_TEST_TIMEOUT, ); - test( - "confirmed post-cancel queued prompt recovers duplicate-key tool arguments", + "ordinary Enter waits for a running tool before steering the same turn", async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-cancel-integrity-"))); + root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-cooperative-steering-"))); const home = join(root, "home"); - const workspacePath = join(root, "workspace"); + const workspace = join(root, "workspace"); const tracePath = join(root, "trace.log"); const stderrPath = join(root, "stderr.log"); - const tapePath = join(root, "session.fxtape"); + const releasePath = join(workspace, ".release-steering-tool"); mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); + mkdirSync(workspace, { recursive: true }); writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: HoldState = { started: false, cancelled: false }; - const duplicateArguments = '{"depth":1, "depth":2}'; - const finalText = "Queued recovery completed after sanitized history."; - const queuedGateway = startFakeGateway([ - fakeGatewaySerializedToolCall( - "first_turn_command", - "terminal", - '{"action":"exec","command":"printf preflight-failed > preflight.txt","timeout_ms":600000}', - ), - () => heldGatewayResponse(hold), - fakeGatewaySerializedToolCall( - "queued_grep_command", - "terminal", - '{"action":"exec","command":"grep -R \\"preflight\\" -n . | head","timeout_ms":600000}', - ), - duplicateKeyToolResponse(), + const command = + `while [ ! -f ${JSON.stringify(releasePath)} ]; do sleep 0.05; done; ` + + "printf COOPERATIVE_TOOL_DONE"; + const steering = "Use COOPERATIVE_STEERING_SENTINEL in the answer."; + const finalText = "COOPERATIVE_STEERING_COMPLETE"; + const steeringGateway = startFakeGateway([ + fakeGatewayToolCall("cooperative_steering_tool", "terminal", { + action: "exec", + timeout_ms: 600_000, + command, + }), fakeGatewayFinalText(finalText), ]); - gateway = queuedGateway; + gateway = steeringGateway; session = await TmuxSession.create({ cwd: workspace, stderrPath, + width: 120, + height: 40, env: { HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-cancel-integrity-key", + AI_GATEWAY_API_KEY: "fake-cooperative-steering-key", VERCEL_OIDC_TOKEN: undefined, FX_AUTO_UPGRADE: "0", - FX_PERMISSION_MODE: "auto", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, + FX_SOUND: "0", + FX_PERMISSION_MODE: "yolo", + FX_GATEWAY_BASE_URL: steeringGateway.baseUrl, + FX_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_E2E_GATEWAY_CHAT_URL: steeringGateway.chatUrl, FX_MODEL: MODEL, - FX_RECORD: tapePath, - FX_RECORD_INPUT: "1", FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,core,gateway,stream,tool,sse,worker,input,prompt", + FX_TRACE_SCOPES: "agent,worker,input,tool,interrupt", }, }); await session.waitForComposer(TIMEOUT); - await session.sendText( - "Create dogfood-notes.txt with three lines, then report its byte count.", - ); + await session.sendText("Run the cooperative steering fixture."); + await session.waitForText("Running while", TIMEOUT); + await session.sendText(steering); + await session.waitForText("Waiting for tool · Esc to steer now", TIMEOUT); + expect(await session.capturePane()).not.toContain("queued 1"); + expect(steeringGateway.requests).toHaveLength(1); + + writeFileSync(releasePath, "release\n"); + await session.waitForText(finalText, TIMEOUT); await waitForCondition( - () => queuedGateway.requests.length >= 2 && hold.started, - "held second gateway request", + () => steeringGateway.requests.length === 2, + "cooperative steering request", ); - await session.sendText( - "Why did preflight fail? Do not write anything; just explain briefly.", - ); - await session.waitForText(queuedSummaryText(1), TIMEOUT); - await session.sendKeys("C-c"); - await session.waitForPane( - (candidate) => - candidate.includes("Why did preflight fail?") && - candidate.includes("paused") && - candidate.includes("enter to send"), - TIMEOUT, + const continuedBody = steeringGateway.requests[1]!.body; + const trace = readFileSync(tracePath, "utf8"); + expect(continuedBody.indexOf("COOPERATIVE_TOOL_DONE")).toBeGreaterThanOrEqual(0); + expect(continuedBody.indexOf(steering)).toBeGreaterThan( + continuedBody.indexOf("COOPERATIVE_TOOL_DONE"), ); - expect(queuedGateway.requests).toHaveLength(2); - await session.sendKeys("Enter"); - const pane = await session.waitForText(finalText, TIMEOUT); + expect(continuedBody).toContain("live user update"); + expect(trace).toContain("event=prompt_steering_consumed"); + expect(trace).not.toContain("event=queue_review_started"); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + expect(session.isAlive()).toBe(true); + expect(session.isPaneAlive()).toBe(true); + }, + TIMEOUT * 2, + ); + + test( + "Escape interrupts a running tool and starts pending steering without review", + async () => { + root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-immediate-steering-"))); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const tracePath = join(root, "trace.log"); + const stderrPath = join(root, "stderr.log"); + mkdirSync(join(home, ".fx"), { recursive: true }); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(home, ".fx", "settings.json"), "{}"); + const steering = "Apply IMMEDIATE_STEERING_SENTINEL now."; + const finalText = "IMMEDIATE_STEERING_COMPLETE"; + const steeringGateway = startFakeGateway([ + fakeGatewayToolCall("immediate_steering_tool", "terminal", { + action: "exec", + timeout_ms: 600_000, + command: "sleep 30", + }), + fakeGatewayFinalText(finalText), + ]); + gateway = steeringGateway; + + session = await TmuxSession.create({ + cwd: workspace, + stderrPath, + width: 120, + height: 40, + env: { + HOME: home, + AI_GATEWAY_API_KEY: "fake-immediate-steering-key", + VERCEL_OIDC_TOKEN: undefined, + FX_AUTO_UPGRADE: "0", + FX_SOUND: "0", + FX_PERMISSION_MODE: "yolo", + FX_GATEWAY_BASE_URL: steeringGateway.baseUrl, + FX_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_E2E_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_MODEL: MODEL, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "agent,worker,input,tool,interrupt,history", + }, + }); + + await session.waitForComposer(TIMEOUT); + await session.sendText("Run the immediate steering fixture."); + await session.waitForText("Running sleep 30", TIMEOUT); + await session.sendText(steering); + await session.waitForText("Waiting for tool · Esc to steer now", TIMEOUT); + expect(steeringGateway.requests).toHaveLength(1); + + await session.sendKeys("Escape"); + await session.waitForText(finalText, TIMEOUT); await waitForCondition( - () => queuedGateway.requests.length === 5 && hold.cancelled, - "fifth gateway request after held request cancellation", + () => steeringGateway.requests.length === 2, + "immediate steering request", ); - const finalRequest = JSON.parse(queuedGateway.requests[4].body) as { - prompt: Array<{ content?: Array> }>; - }; - const parts = finalRequest.prompt.flatMap((message) => message.content ?? []); - const repairedCalls = parts.filter((part) => - part.type === "tool-call" && - part.toolCallId === "queued_duplicate_list" && - part.toolName === "glob_files" - ); - const repairedResults = parts.filter((part) => - part.type === "tool-result" && - part.toolCallId === "queued_duplicate_list" && - part.toolName === "glob_files" - ); + const continuedBody = steeringGateway.requests[1]!.body; const trace = readFileSync(tracePath, "utf8"); - const stderr = readFileSync(stderrPath, "utf8"); - - expect(repairedCalls).toEqual([ - expect.objectContaining({ input: {} }), - ]); - expect(repairedResults).toEqual([ - expect.objectContaining({ - output: expect.objectContaining({ - type: "error-text", - value: expect.stringContaining("tool_execution_failed"), - }), - }), - ]); - expect(queuedGateway.requests[4].body).not.toContain(duplicateArguments); - expect(trace).toContain("event=tool_argument_integrity"); - expect(trace).toContain("failure=malformed_json"); - expect(trace).toContain("event=queue_review_started"); - expect(trace).toContain("reason=post_cancel"); - expect(trace).toContain("event=queue_review_committed"); - expect(trace).toContain("event=queue_review_resumed"); - expect(trace).not.toContain(duplicateArguments); - expect(pane).not.toContain("InvalidGatewayHistory"); - expect(stderr).toBe(""); + expect(continuedBody).toContain("Run the immediate steering fixture."); + expect(continuedBody).toContain(steering); + expect(trace).toContain("steering_pending=true"); + expect(trace).toContain("outcome_kind=interrupted"); + expect(trace).not.toContain("event=queue_review_started"); + expect(readFileSync(stderrPath, "utf8")).toBe(""); expect(session.isAlive()).toBe(true); expect(session.isPaneAlive()).toBe(true); - expect(existsSync(tapePath)).toBe(true); }, - TIMEOUT, + TIMEOUT * 2, ); test( @@ -3379,45 +3349,64 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { ); test( - "Up pauses queued admission and commits every edited prompt in FIFO order", + "queued image yank survives deleting its queue card", async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-review-"))); + root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-image-yank-"))); const home = join(root, "home"); const workspacePath = join(root, "workspace"); const tracePath = join(root, "trace.log"); const stderrPath = join(root, "stderr.log"); + const imagePath = join(workspacePath, "queued-image.png"); mkdirSync(join(home, ".fx"), { recursive: true }); mkdirSync(workspacePath, { recursive: true }); writeFileSync(join(home, ".fx", "settings.json"), "{}"); + copyFileSync( + join(REPO_ROOT, "tests/e2e/fixtures/favicon.png"), + imagePath, + ); const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const activeAfter = "ACTIVE_FINISHED_WHILE_QUEUE_PAUSED"; - const firstQueued = "Continue with QUEUE_REVIEW_FIRST_SENTINEL."; - const firstEdited = " QUEUE_REVIEW_EDITED_SENTINEL"; - const secondQueued = "Continue with QUEUE_REVIEW_SECOND_SENTINEL."; - const secondEdited = " QUEUE_REVIEW_SECOND_EDITED_SENTINEL"; - const firstDone = "QUEUE_REVIEW_FIRST_DONE"; - const secondDone = "QUEUE_REVIEW_SECOND_DONE"; - const queuedGateway = startFakeGateway([ - () => splitHeldTextResponse(hold, "ACTIVE_QUEUE_REVIEW_STARTED\n", activeAfter), - fakeGatewayFinalText(firstDone), - fakeGatewayFinalText(secondDone), - ]); + const image = realpathSync(imagePath); + const expectedImageData = readFileSync(image).toString("base64"); + const hold: HoldState = { started: false, cancelled: false }; + const queuedPrompt = "Describe FXC141_QUEUED_IMAGE_YANK."; + const done = "FXC141_QUEUED_IMAGE_YANK_DONE"; + const queuedGateway = startFakeGateway( + [ + () => + heldGatewayResponse(hold, [ + { type: "text-start", id: "answer_1" }, + { + type: "text-delta", + id: "answer_1", + delta: "ACTIVE_QUEUED_IMAGE_YANK_STARTED\n", + }, + ]), + fakeGatewayFinalText(done), + ], + { + models: [{ + id: MODEL, + type: "language", + tags: ["vision", "file-input", "tool-use"], + }], + }, + ); gateway = queuedGateway; session = await TmuxSession.create({ cwd: workspace, stderrPath, - width: 120, - height: 40, + width: 100, + height: 30, env: { HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-review-key", + AI_GATEWAY_API_KEY: "fake-queued-image-yank-key", VERCEL_OIDC_TOKEN: undefined, FX_AUTO_UPGRADE: "0", FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, + FX_E2E_GATEWAY_MODELS_URL: `${queuedGateway.baseUrl}/coding-agent/v1/models`, FX_MODEL: MODEL, FX_TRACE_LOG: tracePath, FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", @@ -3425,1491 +3414,84 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { }); await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the active queue review turn open."); + await session.sendText("Hold the queued image yank turn open."); await waitForCondition( () => queuedGateway.requests.length === 1 && hold.started, - "held active request for manual queue review", + "held active request for queued image yank", ); - await session.sendText(firstQueued); - await session.sendText(secondQueued); + await session.sendText(`/image ${image}`); + await session.waitForText("attached image: queued-image.png", TIMEOUT); + await session.sendText(queuedPrompt); await session.waitForPane( (pane) => - pane.includes(queuedSummaryText(2)) && - !pane.includes(firstQueued) && - !pane.includes(secondQueued), + pane.includes(queuedSummaryText(1)) && + !pane.includes(queuedPrompt), TIMEOUT, ); + rmSync(image); - await session.sendKeys("Up"); + await session.sendKeys("C-c"); + await waitForCondition(() => hold.cancelled, "queued image active request cancellation"); await session.waitForPane( (pane) => - pane.includes(secondQueued) && + pane.includes(queuedPrompt) && pane.includes("paused") && pane.includes("enter to send"), TIMEOUT, ); - const queuedCardLine = (await session.capturePaneEscapes()) - .split("\n") - .find((line) => line.includes(firstQueued)); - expect(queuedCardLine).toBeDefined(); - expect(queuedCardLine).toContain("┃"); - expect(queuedCardLine).not.toContain("\x1b[48;"); - await session.sendLiteral(secondEdited); - await session.waitForPane( - (pane) => pane.includes(secondQueued + secondEdited), - TIMEOUT, - ); - await session.sendKeys("Up"); + + await session.sendKeys("End"); + await session.sendKeys("C-u"); await session.waitForPane( (pane) => - pane.includes(firstQueued) && - pane.includes("paused") && - pane.includes("enter to send"), + !pane.includes(queuedPrompt) && + pane.includes("delete again to remove queued prompt"), TIMEOUT, ); - - hold.release?.(); - await session.waitForText(activeAfter, TIMEOUT); + await session.sendKeys("C-k"); await waitForCondition( () => existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes("event=stream_complete"), - "active stream completion while queue review is paused", + readFileSync(tracePath, "utf8").includes( + "event=queue_review_draft_deleted", + ), + "empty queued image card deletion", ); - await Bun.sleep(250); - expect(queuedGateway.requests).toHaveLength(1); - expect(hold.cancelled).toBe(false); - await session.pasteText(firstEdited); + await session.sendKeys("C-y"); + await session.waitForPane( + (pane) => + pane.includes("[Image 2]") && + pane.includes("FXC141_QUEUED_IMAGE_YANK"), + TIMEOUT, + ); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + expect(session.isAlive()).toBe(true); + expect(session.isPaneAlive()).toBe(true); + await session.sendKeys("Enter"); - await session.waitForText(secondDone, TIMEOUT); + await session.waitForText(done, TIMEOUT); await waitForCondition( - () => queuedGateway.requests.length === 3, - "edited and remaining queued prompts in FIFO order", + () => queuedGateway.requests.length === 2, + "yanked image Gateway request", ); - const firstQueuedBody = queuedGateway.requests[1].body; - const secondQueuedBody = queuedGateway.requests[2].body; + const yankedBody = queuedGateway.requests[1]!.body; const trace = readFileSync(tracePath, "utf8"); - expect(firstQueuedBody).toContain(firstQueued); - expect(firstQueuedBody).toContain(firstEdited.trim()); - expect(firstQueuedBody).not.toContain(secondQueued); - expect(secondQueuedBody).toContain(secondQueued); - expect(secondQueuedBody).toContain(secondEdited.trim()); + expect(yankedBody.match(/"type":"file"/g) ?? []).toHaveLength(1); + expect(yankedBody).toContain(expectedImageData); + expect(yankedBody).toContain("[Image #2]" + queuedPrompt); + expect(yankedBody).not.toContain(image); expect(trace).toContain("event=queue_review_started"); - expect(trace).toContain("reason=manual"); - expect(trace).toContain("event=queue_review_committed"); - expect(trace).toContain("event=queue_review_batch_committed"); - expect(trace).toContain("event=queue_review_resumed"); + expect(trace).toContain("reason=post_cancel"); + expect(trace).toContain("event=queue_review_deleted"); + expect(trace).toContain("event=queue_review_draft_deleted"); expect(readFileSync(stderrPath, "utf8")).toBe(""); expect(session.isAlive()).toBe(true); expect(session.isPaneAlive()).toBe(true); }, TIMEOUT * 2, ); - - test( - "Escape hides a focused queue editor without cancelling the active stream", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-review-escape-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const queuedPrompt = "Keep QUEUE_ESCAPE_FOCUS_SENTINEL pending."; - const editorSuffix = " QUEUE_ESCAPE_EDITOR_SUFFIX"; - const composerText = "NEW_COMPOSER_OWNER"; - const queuedGateway = startFakeGateway([ - () => - splitHeldTextResponse( - hold, - "ACTIVE_QUEUE_ESCAPE_STARTED\n", - "ACTIVE_QUEUE_ESCAPE_FINISHED", - ), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 120, - height: 40, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-review-escape-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the queue Escape ownership turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for queue Escape ownership", - ); - await session.sendText(queuedPrompt); - await session.sendKeys("Up"); - await session.waitForPane( - (pane) => - pane.includes(queuedPrompt) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - await session.sendLiteralText(editorSuffix); - await session.waitForText(queuedPrompt + editorSuffix, TIMEOUT); - - await session.sendKeys("Escape"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes("event=queue_review_hidden"), - "hidden queue review before stream cancellation", - ); - await session.waitForPane( - (pane) => pane.includes("Generating"), - TIMEOUT, - ); - expect(hold.cancelled).toBe(false); - - await session.sendLiteralText(composerText); - await session.waitForPane( - (pane) => - pane.includes(composerText) && - !pane.includes(queuedPrompt + editorSuffix + composerText), - TIMEOUT, - ); - - const trace = readFileSync(tracePath, "utf8"); - expect(trace).toContain("event=queue_review_hidden"); - expect(trace).not.toContain("event=cancel_requested"); - expect(queuedGateway.requests).toHaveLength(1); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); - - test( - "queued review preserves pasted backing and follows a long draft cursor", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-semantic-drafts-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const pastedPrompt = - `QUEUE_PASTE_START_${"q".repeat(5000)}_QUEUE_PASTE_END`; - const pastedEdit = "Z"; - const longPrompt = - `QUEUE_CURSOR_HEAD_${"e".repeat(1800)}_QUEUE_CURSOR_TAIL`; - const longEdit = "_EDITED_AT_TAIL"; - const pastedDone = "QUEUE_PASTE_DONE"; - const longDone = "QUEUE_CURSOR_DONE"; - const queuedGateway = startFakeGateway([ - () => - splitHeldTextResponse( - hold, - "ACTIVE_SEMANTIC_QUEUE_STARTED\n", - "ACTIVE_SEMANTIC_QUEUE_FINISHED", - ), - fakeGatewayFinalText(pastedDone), - fakeGatewayFinalText(longDone), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 80, - height: 24, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-semantic-draft-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the semantic queue review turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for semantic queue review", - ); - - await session.pasteText(pastedPrompt); - await session.waitForPane( - (pane) => pane.includes("[Pasted text #"), - TIMEOUT, - ); - await session.sendKeys("Enter"); - await session.sendLiteralText(longPrompt); - await session.sendKeys("Enter"); - await session.waitForPane( - (pane) => pane.includes(queuedSummaryText(2)), - TIMEOUT, - ); - - await session.sendKeys("Up"); - await session.waitForPane( - (pane) => - pane.includes("QUEUE_CURSOR_TAIL") && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - let { cursor, grid } = await waitForCursorRow( - session, - "QUEUE_CURSOR_TAIL", - "long queued draft cursor row", - ); - expect(grid[cursor.row]).toContain("QUEUE_CURSOR_TAIL"); - await session.sendLiteral(longEdit); - await session.waitForPane((pane) => pane.includes(longEdit), TIMEOUT); - - await session.sendKeys("Up"); - await session.waitForPane( - (pane) => pane.includes("[Pasted text #") && pane.includes("paused"), - TIMEOUT, - ); - ({ cursor, grid } = await waitForCursorRow( - session, - "[Pasted text #", - "pasted queued draft cursor row", - )); - expect(grid[cursor.row]).toContain("[Pasted text #"); - await session.sendLiteral(pastedEdit); - await session.waitForPane((pane) => pane.includes("]Z"), TIMEOUT); - - await session.sendKeys("Enter"); - hold.release?.(); - await session.waitForText(longDone, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 3, - "semantic queued prompts after active turn", - ); - - expect(queuedGateway.requests[1].body).toContain( - pastedPrompt + pastedEdit, - ); - expect(queuedGateway.requests[2].body).toContain(longPrompt + longEdit); - expect(readFileSync(tracePath, "utf8")).toContain( - "event=queue_review_batch_committed", - ); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 3, - ); - - test( - "queued review shows file completions and preserves the accepted path", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-file-picker-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(join(workspacePath, "src"), { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - writeFileSync( - join(workspacePath, "src", "main.zig"), - "pub fn main() void {}\n", - ); - const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const olderPrompt = "OLDER_QUEUE_DRAFT"; - const completedPrompt = "Review @src/main.zig"; - const queuedDone = "QUEUE_FILE_PICKER_DONE"; - const queuedGateway = startFakeGateway([ - () => - splitHeldTextResponse( - hold, - "ACTIVE_FILE_PICKER_QUEUE_STARTED\n", - "ACTIVE_FILE_PICKER_QUEUE_FINISHED", - ), - fakeGatewayFinalText(olderPrompt), - fakeGatewayFinalText(queuedDone), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 80, - height: 24, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-file-picker-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the queued file picker turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for queued file picker", - ); - await session.sendText(olderPrompt); - await session.sendText(completedPrompt); - await session.waitForPane( - (pane) => pane.includes(queuedSummaryText(2)), - TIMEOUT, - ); - - await session.sendKeys("Up"); - await session.waitForText("paused", TIMEOUT); - await session.sendKeys("BSpace BSpace BSpace BSpace"); - await session.waitForPane( - (pane) => - pane.includes("Review @src/main") && - pane.includes("src/main.zig"), - TIMEOUT, - ); - await session.sendKeys("Up"); - await session.waitForText(olderPrompt, TIMEOUT); - await session.sendKeys("Down"); - await session.waitForPane( - (pane) => - pane.includes("Review @src/main") && - pane.includes("src/main.zig"), - TIMEOUT, - ); - - await session.sendKeys("Enter"); - await session.waitForText(completedPrompt, TIMEOUT); - await session.sendKeys("Up"); - await session.waitForText(olderPrompt, TIMEOUT); - await session.sendKeys("Down"); - await session.waitForText(completedPrompt, TIMEOUT); - await session.sendKeys("Enter"); - - hold.release?.(); - await session.waitForText(queuedDone, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 3, - "queued prompts after file picker review", - ); - - expect(queuedGateway.requests[2].body).toContain(completedPrompt); - expect(readFileSync(tracePath, "utf8")).toContain( - "file picker Enter consumed selected=true", - ); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 3, - ); - - test( - "streaming model selection applies to the next turn without changing the active request", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-next-turn-model-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync( - join(home, ".fx", "settings.json"), - JSON.stringify({ - model: GLM_MODEL, - effort: "auto", - fast_mode: false, - }), - ); - const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const nextModel = "openai/gpt-5"; - const nextTurnDone = "NEXT_TURN_MODEL_SELECTION_DONE"; - const queuedGateway = startFakeGateway( - [ - () => - splitHeldTextResponse( - hold, - "ACTIVE_ORIGINAL_MODEL_STARTED\n", - "ACTIVE_ORIGINAL_MODEL_FINISHED", - ), - fakeGatewayFinalText(nextTurnDone), - ], - { - models: [ - { id: GLM_MODEL, type: "language", tags: ["tool-use"] }, - { - id: nextModel, - type: "language", - tags: ["reasoning", "tool-use"], - reasoning_options: [{ type: "effort", values: ["low", "high"] }], - }, - ], - }, - ); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 80, - height: 24, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-next-turn-model-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_MODELS_URL: `${queuedGateway.baseUrl}/coding-agent/v1/models`, - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the original model turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held original-model request", - ); - await waitForCondition( - () => queuedGateway.modelRequests.length > 0, - "next-turn model catalog warmup", - ); - - await session.sendLiteralText(`/model ${nextModel}`); - await session.sendKeys("Space"); - await session.sendLiteralText("auto"); - await session.waitForPane( - (pane) => composerContains(pane, `/model ${nextModel} auto`), - TIMEOUT, - ); - await session.sendKeys("Enter"); - await session.waitForText(`Next turn will use ${nextModel}`, TIMEOUT); - - expect(queuedGateway.requests).toHaveLength(1); - expect( - queuedGateway.requests[0]!.headers.get("ai-language-model-id"), - ).toBe(GLM_MODEL); - expect(hold.cancelled).toBe(false); - expect(hasEmptyComposer(await session.capturePane())).toBe(true); - - hold.release?.(); - await session.waitForText("ACTIVE_ORIGINAL_MODEL_FINISHED", TIMEOUT); - await session.sendText("Use the selected model now."); - await session.waitForText(nextTurnDone, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 2, - "next-turn selected-model request", - ); - - expect( - queuedGateway.requests[1]!.headers.get("ai-language-model-id"), - ).toBe(nextModel); - expect(JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8"))) - .toMatchObject({ models: { gateway: nextModel }, effort: "auto" }); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 3, - ); - - test( - "queued review keeps the disabled model picker hidden", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-model-picker-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const hiddenModel = "provider/queued-hidden-model"; - const queuedGateway = startFakeGateway( - [ - () => - splitHeldTextResponse( - hold, - "ACTIVE_MODEL_PICKER_QUEUE_STARTED\n", - "ACTIVE_MODEL_PICKER_QUEUE_FINISHED", - ), - ], - { - models: [ - { id: MODEL, type: "language", tags: ["tool-use"] }, - { id: hiddenModel, type: "language", tags: ["tool-use"] }, - ], - }, - ); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 80, - height: 24, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-model-picker-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_MODELS_URL: `${queuedGateway.baseUrl}/coding-agent/v1/models`, - FX_MODEL: MODEL, - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the queued model picker turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for queued model picker", - ); - await waitForCondition( - () => queuedGateway.modelRequests.length > 0, - "queued model picker catalog warmup", - ); - await session.sendText("QUEUED_MODEL_PICKER_DRAFT"); - await session.waitForPane( - (pane) => pane.includes(queuedSummaryText(1)), - TIMEOUT, - ); - - await session.sendKeys("Up"); - await session.waitForText("paused", TIMEOUT); - await session.sendKeys("C-u"); - await session.sendLiteralText("/model provider/queued-hidden"); - await session.waitForPane( - (pane) => pane.includes("/model provider/queued-hidden"), - TIMEOUT, - ); - await Bun.sleep(200); - - let pane = await session.capturePane(); - expect(pane).not.toContain(hiddenModel); - await session.sendKeys("Tab"); - pane = await session.capturePane(); - expect(pane).toContain("/model provider/queued-hidden"); - expect(pane).not.toContain(hiddenModel); - await session.sendKeys("Enter"); - pane = await session.capturePane(); - expect(pane).toContain("/model provider/queued-hidden"); - expect(pane).not.toContain(hiddenModel); - expect(queuedGateway.requests).toHaveLength(1); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); - - test( - "empty Enter resumes a hidden paused queue without editing it", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-empty-enter-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const queuedPrompt = "Continue with EMPTY_ENTER_QUEUE_SENTINEL."; - const queuedDone = "EMPTY_ENTER_QUEUE_DONE"; - const queuedGateway = startFakeGateway([ - () => - splitHeldTextResponse( - hold, - "ACTIVE_EMPTY_ENTER_STARTED\n", - "ACTIVE_EMPTY_ENTER_FINISHED", - ), - fakeGatewayFinalText(queuedDone), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 120, - height: 40, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-empty-enter-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the empty Enter queue turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for empty Enter queue review", - ); - await session.sendText(queuedPrompt); - await session.waitForPane( - (pane) => - pane.includes(queuedSummaryText(1)) && - !pane.includes(queuedPrompt), - TIMEOUT, - ); - await session.sendKeys("Up"); - await session.waitForPane( - (pane) => - pane.includes(queuedPrompt) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - await session.sendKeys("Down"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes("event=queue_review_hidden"), - "hidden queue review after Down before empty Enter", - ); - - await session.sendKeys("Enter"); - await waitForCondition( - () => - readFileSync(tracePath, "utf8").includes( - "event=queue_review_finished source=unchanged_queue", - ), - "unchanged queue submission from empty composer", - ); - expect(queuedGateway.requests).toHaveLength(1); - - hold.release?.(); - await session.waitForText(queuedDone, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 2, - "unchanged queued prompt after active turn", - ); - - const trace = readFileSync(tracePath, "utf8"); - expect(queuedGateway.requests[1].body).toContain(queuedPrompt); - expect(trace).toContain("event=queue_review_resumed"); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); - - test( - "Up edits a queued card in place and repeated Ctrl+U deletes only the empty draft", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-inline-delete-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: SplitHoldState = { started: false, cancelled: false }; - const firstQueued = "Keep QUEUE_INLINE_FIRST_SENTINEL."; - const firstQueuedEdit = " this"; - const secondQueued = "Delete QUEUE_INLINE_SECOND_SENTINEL."; - const firstDone = "QUEUE_INLINE_FIRST_DONE"; - const queuedGateway = startFakeGateway([ - () => - splitHeldTextResponse( - hold, - "ACTIVE_QUEUE_INLINE_STARTED\n", - "ACTIVE_QUEUE_INLINE_FINISHED", - ), - fakeGatewayFinalText(firstDone), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 120, - height: 40, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-inline-delete-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the inline queue editor turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for inline queue editing", - ); - await session.sendText(firstQueued); - await session.sendText(secondQueued); - await session.waitForPane( - (pane) => - pane.includes(queuedSummaryText(2)) && - !pane.includes(firstQueued) && - !pane.includes(secondQueued), - TIMEOUT, - ); - - await session.sendKeys("Up"); - await session.waitForPane( - (pane) => - pane.includes(secondQueued) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - let { cursor, grid } = await waitForCursorRow( - session, - secondQueued, - "second queued draft cursor row", - ); - expect(grid[cursor.row]).toContain(secondQueued); - const pausedRow = grid.findIndex((line) => line.includes("paused")); - const emptyComposerRow = grid.findIndex( - (line, index) => index > pausedRow && isEmptyComposerLine(line), - ); - expect(pausedRow).toBeGreaterThanOrEqual(0); - expect(emptyComposerRow).toBeGreaterThan(pausedRow); - expect(cursor.row).not.toBe(emptyComposerRow); - - await session.sendKeys("Up"); - await session.waitForPane((pane) => pane.includes(firstQueued), TIMEOUT); - await session.sendKeys("Left"); - await session.sendKeys("Right"); - await session.sendLiteral(firstQueuedEdit); - await session.waitForPane( - (pane) => pane.includes(firstQueued + firstQueuedEdit), - TIMEOUT, - ); - await session.sendKeys("Down"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes( - "event=queue_review_navigate direction=newer index=1", - ), - "edited draft followed by newer queue navigation", - ); - ({ cursor, grid } = await waitForCursorRow( - session, - secondQueued, - "newer queued draft cursor row", - )); - expect(grid[cursor.row]).toContain(secondQueued); - await session.waitForPane( - (pane) => pane.includes(firstQueued + firstQueuedEdit), - TIMEOUT, - ); - - await session.sendKeys("C-u"); - await session.waitForPane( - (pane) => - pane.includes(firstQueued) && - !pane.includes(secondQueued) && - pane.includes("delete again to remove queued prompt") && - pane.includes("enter to send unchanged"), - TIMEOUT, - ); - await session.sendKeys("C-u"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes( - "event=queue_review_draft_deleted", - ), - "selected empty queued draft deletion", - ); - await session.waitForPane( - (pane) => - pane.includes(firstQueued) && - !pane.includes(secondQueued) && - pane.includes("queued 1"), - TIMEOUT, - ); - ({ cursor, grid } = await waitForCursorRow( - session, - firstQueued, - "remaining queued draft cursor row", - )); - expect(grid[cursor.row]).toContain(firstQueued); - expect(queuedGateway.requests).toHaveLength(1); - - await session.sendKeys("Enter"); - hold.release?.(); - await session.waitForText(firstDone, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 2, - "remaining queued prompt after inline deletion", - ); - - const trace = readFileSync(tracePath, "utf8"); - expect(queuedGateway.requests[1].body).toContain( - firstQueued + firstQueuedEdit, - ); - expect(queuedGateway.requests[1].body).not.toContain(secondQueued); - expect(trace).toContain("event=queue_review_deleted"); - expect(trace).toContain("event=queue_review_draft_deleted"); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); - - test( - "queued image yank survives deleting its queue card", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-image-yank-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - const imagePath = join(workspacePath, "queued-image.png"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - copyFileSync( - join(REPO_ROOT, "tests/e2e/fixtures/favicon.png"), - imagePath, - ); - const workspace = realpathSync(workspacePath); - const image = realpathSync(imagePath); - const expectedImageData = readFileSync(image).toString("base64"); - const hold: HoldState = { started: false, cancelled: false }; - const queuedPrompt = "Describe FXC141_QUEUED_IMAGE_YANK."; - const done = "FXC141_QUEUED_IMAGE_YANK_DONE"; - const queuedGateway = startFakeGateway( - [ - () => - heldGatewayResponse(hold, [ - { type: "text-start", id: "answer_1" }, - { - type: "text-delta", - id: "answer_1", - delta: "ACTIVE_QUEUED_IMAGE_YANK_STARTED\n", - }, - ]), - fakeGatewayFinalText(done), - ], - { - models: [{ - id: MODEL, - type: "language", - tags: ["vision", "file-input", "tool-use"], - }], - }, - ); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 100, - height: 30, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-image-yank-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_MODELS_URL: `${queuedGateway.baseUrl}/coding-agent/v1/models`, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the queued image yank turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for queued image yank", - ); - await session.sendText(`/image ${image}`); - await session.waitForText("attached image: queued-image.png", TIMEOUT); - await session.sendText(queuedPrompt); - await session.waitForPane( - (pane) => - pane.includes(queuedSummaryText(1)) && - !pane.includes(queuedPrompt), - TIMEOUT, - ); - rmSync(image); - - await session.sendKeys("C-c"); - await waitForCondition(() => hold.cancelled, "queued image active request cancellation"); - await session.waitForPane( - (pane) => - pane.includes(queuedPrompt) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - - await session.sendKeys("End"); - await session.sendKeys("C-u"); - await session.waitForPane( - (pane) => - !pane.includes(queuedPrompt) && - pane.includes("delete again to remove queued prompt"), - TIMEOUT, - ); - await session.sendKeys("C-k"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes( - "event=queue_review_draft_deleted", - ), - "empty queued image card deletion", - ); - - await session.sendKeys("C-y"); - await session.waitForPane( - (pane) => - pane.includes("[Image 2]") && - pane.includes("FXC141_QUEUED_IMAGE_YANK"), - TIMEOUT, - ); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - - await session.sendKeys("Enter"); - await session.waitForText(done, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 2, - "yanked image Gateway request", - ); - - const yankedBody = queuedGateway.requests[1]!.body; - const trace = readFileSync(tracePath, "utf8"); - expect(yankedBody.match(/"type":"file"/g) ?? []).toHaveLength(1); - expect(yankedBody).toContain(expectedImageData); - expect(yankedBody).toContain("[Image #2]" + queuedPrompt); - expect(yankedBody).not.toContain(image); - expect(trace).toContain("event=queue_review_started"); - expect(trace).toContain("reason=post_cancel"); - expect(trace).toContain("event=queue_review_deleted"); - expect(trace).toContain("event=queue_review_draft_deleted"); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); - - test( - "Ctrl+C pauses two queued prompts until the visible draft is confirmed", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-post-cancel-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: HoldState = { started: false, cancelled: false }; - const firstQueued = "Continue with FOLLOWUP_FIRST_SENTINEL."; - const firstEdited = " FOLLOWUP_EDITED_SENTINEL"; - const secondQueued = "Continue with FOLLOWUP_SECOND_SENTINEL."; - const firstDone = "FOLLOWUP_FIRST_DONE"; - const secondDone = "FOLLOWUP_SECOND_DONE"; - const queuedGateway = startFakeGateway([ - () => - heldGatewayResponse(hold, [ - { type: "text-start", id: "answer_1" }, - { - type: "text-delta", - id: "answer_1", - delta: "ACTIVE_POST_CANCEL_REVIEW_STARTED\n", - }, - ]), - fakeGatewayFinalText(firstDone), - fakeGatewayFinalText(secondDone), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 120, - height: 40, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-post-cancel-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the active post-cancel turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for post-cancel queue review", - ); - await session.sendText(firstQueued); - await session.sendText(secondQueued); - await session.waitForPane( - (pane) => - pane.includes(queuedSummaryText(2)) && - !pane.includes(firstQueued) && - !pane.includes(secondQueued), - TIMEOUT, - ); - expect(queuedGateway.requests).toHaveLength(1); - await session.sendKeys("C-c"); - await waitForCondition(() => hold.cancelled, "active request cancellation"); - await session.waitForPane( - (pane) => - pane.includes(secondQueued) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - await Bun.sleep(250); - expect(queuedGateway.requests).toHaveLength(1); - - await session.sendKeys("Escape"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes("event=queue_review_hidden"), - "hidden post-cancel queue draft", - ); - await Bun.sleep(250); - expect(queuedGateway.requests).toHaveLength(1); - - await session.sendText("/status"); - await Bun.sleep(250); - expect(queuedGateway.requests).toHaveLength(1); - expect(readFileSync(tracePath, "utf8")).not.toContain( - "event=queue_review_resumed", - ); - - await session.sendKeys("Up"); - await session.waitForPane( - (pane) => - pane.includes(secondQueued) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - await session.sendKeys("Up"); - await session.waitForPane( - (pane) => - pane.includes(firstQueued) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - await session.pasteText(firstEdited); - await session.sendKeys("Enter"); - await session.waitForText(secondDone, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 3, - "post-cancel edited and remaining queued prompts", - ); - - const firstQueuedBody = queuedGateway.requests[1].body; - const secondQueuedBody = queuedGateway.requests[2].body; - const trace = readFileSync(tracePath, "utf8"); - expect(firstQueuedBody).toContain(firstQueued); - expect(firstQueuedBody).toContain(firstEdited.trim()); - expect(firstQueuedBody).not.toContain(secondQueued); - expect(secondQueuedBody).toContain(secondQueued); - expect(trace).toContain("event=queue_review_started"); - expect(trace).toContain("reason=post_cancel"); - expect(trace).toContain("event=queue_review_hidden"); - expect(trace).toContain("event=queue_review_committed"); - expect(trace).toContain("event=queue_review_resumed"); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); - - for (const scenario of [ - { name: "at stable geometry", slug: "stable", resize: null }, - { - name: "after a settled resize", - slug: "resized", - resize: { width: 68, height: 18 }, - }, - ]) { - test( - `queue resume preserves streamed scrollback ${scenario.name}`, - async () => { - const artifactBase = createArtifactRoot(); - const artifacts = join(artifactBase, scenario.slug); - const home = join(artifacts, "home"); - const workspace = join(artifacts, "workspace"); - const tracePath = join(artifacts, "trace.log"); - const stderrPath = join(artifacts, "stderr.log"); - const tapePath = join(artifacts, "session.fxtape"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspace, { recursive: true }); - writeFileSync( - join(home, ".fx", "settings.json"), - JSON.stringify({ - sandbox: "none", - permission_mode: "auto", - permission: {}, - }), - ); - - const numberedLines = Array.from( - { length: 24 }, - (_, index) => `QUEUE_SCROLLBACK_LINE_${String(index + 1).padStart(2, "0")}`, - ); - const firstQueued = "QUEUE_SCROLLBACK_FIRST_PROMPT"; - const secondQueued = "QUEUE_SCROLLBACK_SECOND_PROMPT"; - const draft = "QUEUE_SCROLLBACK_DRAFT_PROMPT"; - const firstDone = "QUEUE_SCROLLBACK_FIRST_DONE"; - const secondDone = "QUEUE_SCROLLBACK_SECOND_DONE"; - const draftDone = "QUEUE_SCROLLBACK_DRAFT_DONE"; - const queuedGateway = startFakeGateway([ - fakeGatewaySse([ - { type: "text-start", id: "answer_1" }, - ...numberedLines.map((line) => ({ - type: "text-delta", - id: "answer_1", - delta: `${line}\n`, - })), - { type: "text-end", id: "answer_1" }, - { - type: "tool-call", - toolCallId: "queue_scrollback_command", - toolName: "shell", - input: { - request: { - action: "run", - yield_time_ms: 30_000, - timeout_ms: 600_000, - command: "sleep 30", - }, - }, - }, - { - type: "finish", - finishReason: { unified: "tool-calls", raw: "tool-calls" }, - }, - ]), - fakeGatewayFinalText(firstDone), - fakeGatewayFinalText(secondDone), - fakeGatewayFinalText(draftDone), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: realpathSync(workspace), - stderrPath, - width: 124, - height: 36, - minimumHistoryLines: 2_000, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queue-scrollback-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_PERMISSION_MODE: "auto", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_RECORD: tapePath, - FX_RECORD_INPUT: "1", - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt,scroll", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Start the queue scrollback stream."); - await session.waitForText("Running sleep 30", TIMEOUT); - await session.waitForText(numberedLines.at(-1)!, TIMEOUT); - - await session.sendText(firstQueued); - await session.sendText(secondQueued); - await session.waitForPane( - (pane) => - pane.includes(queuedSummaryText(2)) && - !pane.includes(firstQueued) && - !pane.includes(secondQueued), - TIMEOUT, - ); - await session.sendLiteral(draft); - await session.waitForText(draft, TIMEOUT); - - await session.sendKeys("C-o"); - await Bun.sleep(250); - await session.sendKeys("C-o"); - await session.waitForText(draft, TIMEOUT); - if (scenario.resize) { - await session.resizeWindow(scenario.resize.width, scenario.resize.height); - await session.waitForText(draft, TIMEOUT); - } - - await session.sendKeys("C-c"); - await session.waitForPane( - (pane) => - pane.includes("Cancelled sleep 30") && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - expect(queuedGateway.requests).toHaveLength(1); - - await session.sendKeys("Enter"); - await session.waitForText(draftDone, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 4, - "resumed queued prompts and submitted draft", - ); - - const scrollback = await session.captureFullScrollback(); - for (const line of numberedLines) { - expect(countOccurrences(scrollback, line)).toBe(1); - } - for (const text of [ - firstQueued, - firstDone, - secondQueued, - secondDone, - draft, - draftDone, - ]) { - expect(countOccurrences(scrollback, text)).toBe(1); - } - const orderedMarkers = [ - numberedLines[0]!, - numberedLines.at(-1)!, - firstQueued, - firstDone, - secondQueued, - secondDone, - draft, - draftDone, - ]; - for (let index = 1; index < orderedMarkers.length; index += 1) { - expect(scrollback.indexOf(orderedMarkers[index - 1]!)).toBeLessThan( - scrollback.indexOf(orderedMarkers[index]!), - ); - } - - expect(queuedGateway.requests[1]!.body).toContain(firstQueued); - expect(queuedGateway.requests[2]!.body).toContain(secondQueued); - expect(queuedGateway.requests[3]!.body).toContain(draft); - - const sessionsRoot = join(home, ".fx", "sessions"); - let eventsPath = ""; - await waitForCondition(() => { - if (!existsSync(sessionsRoot)) return false; - const sessionId = readdirSync(sessionsRoot).find((entry) => - existsSync(join(sessionsRoot, entry, "events.jsonl")) - ); - if (!sessionId) return false; - eventsPath = join(sessionsRoot, sessionId, "events.jsonl"); - return readFileSync(eventsPath, "utf8").includes(draftDone); - }, "complete queue scrollback session history"); - const events = readFileSync(eventsPath, "utf8"); - for (const marker of [...numberedLines, firstQueued, secondQueued, draft]) { - expect(events).toContain(marker); - } - - const trace = readFileSync(tracePath, "utf8"); - expect( - trace.split("\n").some((line) => - line.includes("transcript_anchor_invalidate") && - line.includes("atomic_user_prompt_append") - ), - ).toBe(false); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(existsSync(tapePath)).toBe(true); - expect( - execFileSync(FX_BIN, ["replay", tapePath, "--json"], { - encoding: "utf8", - }), - ).not.toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 3, - ); - } - - test( - "post-cancel hidden queue offers Escape to cancel every queued prompt", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-cancel-all-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - const workspace = realpathSync(workspacePath); - const hold: HoldState = { started: false, cancelled: false }; - const firstQueued = "Keep ESC_QUEUE_FIRST_SENTINEL pending."; - const secondQueued = "Keep ESC_QUEUE_SECOND_SENTINEL pending."; - const queuedGateway = startFakeGateway([ - () => - heldGatewayResponse(hold, [ - { type: "text-start", id: "answer_1" }, - { - type: "text-delta", - id: "answer_1", - delta: "ACTIVE_ESC_QUEUE_CANCEL_ALL_STARTED\n", - }, - ]), - ]); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 120, - height: 40, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-cancel-all-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the Escape cancel-all turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request before Escape queue cancellation", - ); - await session.sendText(firstQueued); - await session.sendText(secondQueued); - await session.waitForPane( - (pane) => - pane.includes(queuedSummaryText(2)) && - !pane.includes(firstQueued) && - !pane.includes(secondQueued), - TIMEOUT, - ); - - await session.sendKeys("C-c"); - await waitForCondition(() => hold.cancelled, "active request cancellation before queue cancel-all"); - await session.waitForPane( - (pane) => pane.includes(secondQueued) && pane.includes("paused"), - TIMEOUT, - ); - - await session.sendKeys("Escape"); - await session.waitForPane( - (pane) => pane.includes("press esc to cancel all queued"), - TIMEOUT, - ); - expect(queuedGateway.requests).toHaveLength(1); - - await session.sendKeys("Escape"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes( - "event=queue_review_cancelled_all", - ), - "all queued prompts cancelled by Escape", - ); - await session.waitForPane( - (pane) => - !pane.includes(firstQueued) && - !pane.includes(secondQueued) && - !pane.includes("press esc to cancel all queued"), - TIMEOUT, - ); - await Bun.sleep(250); - - const trace = readFileSync(tracePath, "utf8"); - expect(trace).toContain("event=queued_prompts_cleared"); - expect(queuedGateway.requests).toHaveLength(1); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); - test( "second Ctrl+C exits after active stream cancellation", async () => { diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index 0f63888bd..3300bfb06 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -2628,7 +2628,6 @@ test.skipIf(!tmuxAvailable())( if (/^└ (?:Running|Ran) /.test(row)) return ""; if (/^│ \d+ output lines$/.test(row)) return ""; if (/^│ \d+ more lines · → to expand$/.test(row)) return ""; - if (row.includes("enter queue ·")) return ""; if (/^(?:auto · )?gpt-5$/.test(row)) return ""; return row; }); From 2066cdbdbe4d7457cacab9873c2a0d9c817466de Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 12:00:43 -0400 Subject: [PATCH 02/14] Persist same-turn steering in session history Add steering text to durable execution memory schema 6. Continue reading schema 5 sessions without steering metadata. Cover the live app persistence boundary in interrupt recovery E2E. --- src/core/session/session_codec.zig | 61 ++++++++++++++++++------ tests/e2e/tui-interrupt-recovery.test.ts | 19 +++++--- 2 files changed, 59 insertions(+), 21 deletions(-) diff --git a/src/core/session/session_codec.zig b/src/core/session/session_codec.zig index ba3084ad5..c5a667e3d 100644 --- a/src/core/session/session_codec.zig +++ b/src/core/session/session_codec.zig @@ -1121,7 +1121,7 @@ fn writeSnapshotLocator(writer: *std.Io.Writer, value: ?[]const u8) !void { } fn writeExecutionMemory(writer: *std.Io.Writer, execution: session.ExecutionMemory) !void { - try writer.writeAll("{\"schema_version\":5,\"tool_steps\":["); + try writer.writeAll("{\"schema_version\":6,\"tool_steps\":["); for (execution.tool_steps, 0..) |step, i| { if (i > 0) try writer.writeByte(','); try writer.writeAll("{\"assistant\":"); @@ -1143,6 +1143,11 @@ fn writeExecutionMemory(writer: *std.Io.Writer, execution: session.ExecutionMemo if (i > 0) try writer.writeByte(','); try writeFileEvidence(writer, file); } + try writer.writeAll("],\"steering\":["); + for (execution.steering, 0..) |text, i| { + if (i > 0) try writer.writeByte(','); + try writeDurableBytes(writer, text); + } try writer.writeAll("],\"turn_summary\":"); if (execution.turn_summary) |summary| { try writeTurnSummary(writer, summary); @@ -1494,17 +1499,13 @@ fn imageAttachmentObject(value: std.json.Value) !std.json.ObjectMap { fn parseExecutionMemory(alloc: Allocator, value: std.json.Value) !session.ExecutionMemory { const source = try requireObject(value); - const has_turn_summary = source.get("turn_summary") != null; - const object = if (has_turn_summary) - try exactObject(value, &.{ "schema_version", "tool_steps", "files", "turn_summary" }) - else - try exactObject(value, &.{ "schema_version", "tool_steps", "files" }); - const schema_version = try requireU64(object, "schema_version"); - if (schema_version < 1 or schema_version > 5 or - (schema_version == 5) != has_turn_summary) - { - return error.InvalidSessionFormat; - } + const schema_version = try requireU64(source, "schema_version"); + const object = switch (schema_version) { + 1...4 => try exactObject(value, &.{ "schema_version", "tool_steps", "files" }), + 5 => try exactObject(value, &.{ "schema_version", "tool_steps", "files", "turn_summary" }), + 6 => try exactObject(value, &.{ "schema_version", "tool_steps", "files", "steering", "turn_summary" }), + else => return error.InvalidSessionFormat, + }; const tool_steps = try parseToolSteps( alloc, object.get("tool_steps") orelse return error.InvalidSessionFormat, @@ -1512,13 +1513,23 @@ fn parseExecutionMemory(alloc: Allocator, value: std.json.Value) !session.Execut ); errdefer session.freeExecutionMemory(alloc, .{ .tool_steps = tool_steps }); const files = try parseFiles(alloc, object.get("files") orelse return error.InvalidSessionFormat); - const turn_summary = if (has_turn_summary) + errdefer types.freeFileEvidenceSlice(alloc, files); + const steering: [][]u8 = if (schema_version >= 6) + try parseDurableBytesArray( + alloc, + object.get("steering") orelse return error.InvalidSessionFormat, + ) + else + &.{}; + errdefer types.freePermissionFeedback(alloc, steering); + const turn_summary = if (schema_version >= 5) try parseOptionalTurnSummary(object.get("turn_summary").?) else null; return .{ .tool_steps = tool_steps, .files = files, + .steering = steering, .turn_summary = turn_summary, }; } @@ -1769,7 +1780,7 @@ fn parseToolResult( 1 => .{ .object = try exactObject(value, v1_keys), .extended = false }, 2 => try exactVariantObject(value, v2_keys, v2_extended_keys), 3 => .{ .object = try exactObject(value, v3_keys), .extended = true }, - 4, 5 => .{ .object = try exactObject(value, v4_keys), .extended = true }, + 4, 5, 6 => .{ .object = try exactObject(value, v4_keys), .extended = true }, else => return error.InvalidSessionFormat, }; const object = result_shape.object; @@ -2937,11 +2948,13 @@ test "execution memory codec preserves feedback and reads v1 results without it" .tool_calls = calls[0..], .tool_results = results[0..], }}; + var steering = [_][]u8{@constCast("focus on persistence")}; const turn: session.HistoryTurn = .{ .assistant = .{ .user = .{ .text = @constCast("write a note") }, .assistant = @constCast("done"), .execution = .{ .tool_steps = steps[0..], + .steering = steering[0..], .turn_summary = .{ .started_at_ms = 100, .completed_at_ms = 250, @@ -2955,7 +2968,8 @@ test "execution memory codec preserves feedback and reads v1 results without it" var encoded: std.Io.Writer.Allocating = .init(alloc); defer encoded.deinit(); try writeHistoryTurn(&encoded.writer, turn); - try std.testing.expect(std.mem.find(u8, encoded.written(), "\"schema_version\":5") != null); + try std.testing.expect(std.mem.find(u8, encoded.written(), "\"schema_version\":6") != null); + try std.testing.expect(std.mem.find(u8, encoded.written(), "\"steering\":[\"focus on persistence\"]") != null); try std.testing.expect(std.mem.find(u8, encoded.written(), "\"permission_feedback\"") != null); try std.testing.expect(std.mem.find(u8, encoded.written(), "\"committed_file_presentation\"") != null); try std.testing.expect(std.mem.find(u8, encoded.written(), "\"command_output_replay\"") != null); @@ -2970,6 +2984,11 @@ test "execution memory codec preserves feedback and reads v1 results without it" turn.assistant.execution.turn_summary, decoded.assistant.execution.turn_summary, ); + try std.testing.expectEqual(@as(usize, 1), decoded.assistant.execution.steering.len); + try std.testing.expectEqualStrings( + "focus on persistence", + decoded.assistant.execution.steering[0], + ); try std.testing.expectEqual(@as(usize, 1), decoded_result.permission_feedback.len); try std.testing.expectEqualStrings("read it after writing", decoded_result.permission_feedback[0]); const presentation = decoded_result.committed_file_presentation orelse return error.TestExpectedPresentation; @@ -3016,6 +3035,14 @@ test "execution memory codec preserves feedback and reads v1 results without it" try std.testing.expect(v2_decoded.assistant.execution.tool_steps[0].tool_results[0].committed_file_presentation == null); try std.testing.expect(v2_decoded.assistant.execution.tool_steps[0].tool_results[0].command_output_replay == null); try std.testing.expect(v2_decoded.assistant.execution.tool_steps[0].tool_results[0].command_process_presentation == null); + + const v5 = + "{\"kind\":\"assistant\",\"user\":{\"text\":\"prompt\",\"images\":[]},\"assistant\":\"done\",\"execution\":{\"schema_version\":5,\"tool_steps\":[],\"files\":[],\"turn_summary\":null}}"; + var v5_parsed = try std.json.parseFromSlice(std.json.Value, alloc, v5, .{}); + defer v5_parsed.deinit(); + const v5_decoded = try parseHistoryTurn(alloc, v5_parsed.value); + defer session.freeHistoryTurn(alloc, v5_decoded); + try std.testing.expectEqual(@as(usize, 0), v5_decoded.assistant.execution.steering.len); } test "private codec preserves summary-only interrupted turns" { @@ -3491,6 +3518,10 @@ fn expectHistoryTurnEqual(expected: session.HistoryTurn, actual: session.History fn expectExecutionMemoryEqual(expected: session.ExecutionMemory, actual: session.ExecutionMemory) !void { try std.testing.expectEqual(expected.turn_summary, actual.turn_summary); + try std.testing.expectEqual(expected.steering.len, actual.steering.len); + for (expected.steering, actual.steering) |text, got_text| { + try std.testing.expectEqualSlices(u8, text, got_text); + } try std.testing.expectEqual(expected.tool_steps.len, actual.tool_steps.len); for (expected.tool_steps, actual.tool_steps) |step, got_step| { try expectOptionalBytesEqual(step.assistant, got_step.assistant); diff --git a/tests/e2e/tui-interrupt-recovery.test.ts b/tests/e2e/tui-interrupt-recovery.test.ts index 16ec0933c..8b85df7aa 100644 --- a/tests/e2e/tui-interrupt-recovery.test.ts +++ b/tests/e2e/tui-interrupt-recovery.test.ts @@ -62,7 +62,7 @@ afterEach(async () => { describe.skipIf(SKIP)("tui: interrupt recovery", () => { test( - "submitted status text queues behind an active response", + "submitted status text steers an active response without cancellation", async () => { root = realpathSync(mkdtempSync(join(tmpdir(), "fx-text-queues-"))); const home = join(root, "home"); @@ -137,16 +137,23 @@ describe.skipIf(SKIP)("tui: interrupt recovery", () => { expect(gateway.requests[1]!.body).not.toContain( "Continue from the latest meaningful state", ); + expect(gateway.requests[1]!.body).toContain(""); + expect(gateway.requests[1]!.body).toContain( + "Apply this live user update to the current task.", + ); expect(readFileSync(stderrPath, "utf8")).toBe(""); expect(session.isAlive()).toBe(true); expect(session.isPaneAlive()).toBe(true); const sessionRoot = join(home, ".fx", "sessions"); - const eventsPath = readdirSync(sessionRoot, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => join(sessionRoot, entry.name, "events.jsonl")) - .find((path) => existsSync(path) && readFileSync(path, "utf8").includes(queuedText)); - expect(eventsPath).toBeDefined(); + let eventsPath: string | undefined; + await waitForCondition(() => { + eventsPath = readdirSync(sessionRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => join(sessionRoot, entry.name, "events.jsonl")) + .find((path) => existsSync(path) && readFileSync(path, "utf8").includes(queuedText)); + return eventsPath !== undefined; + }, "steering history persistence"); const events = readFileSync(eventsPath!, "utf8"); expect(events).not.toContain('"kind":"interrupted"'); expect(events).toContain(queuedText); From b5a1edb7f425326bacaee9bd63ab7a94079dc8fe Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 12:25:21 -0400 Subject: [PATCH 03/14] Keep draft preservation coverage unsubmitted Avoid racing active-turn steering admission against the editor-draft assertion. --- tests/e2e/tui-gateway-stream-lifecycle.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index 9285d055c..7a3529ccc 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -2684,7 +2684,6 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { await session.sendLiteral(submittedPrompt); session.sendKeysImmediate(["Enter"]); session.sendLiteralImmediate(newerDraft); - session.sendKeysImmediate(["Enter"]); await waitForCondition( () => heldGateway.requests.length === 1 && hold.started, "held idle submitted prompt stream", From e849dda39f82806ff4c514ab75646de00252dd5e Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 13:13:26 -0400 Subject: [PATCH 04/14] Align terminal SDK smoke with steering Assert pending same-turn guidance instead of the removed queued-turn status. --- sdk/tests/test-term.mjs | 43 +++++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/sdk/tests/test-term.mjs b/sdk/tests/test-term.mjs index 94e34d81a..0bd6733d3 100644 --- a/sdk/tests/test-term.mjs +++ b/sdk/tests/test-term.mjs @@ -16,10 +16,10 @@ if (!supportsJspi()) { const output = []; const streamedDecoder = new TextDecoder(); let streamedText = ""; -const liveDraft = "queued draft"; -const queuedAnswer = "§"; +const liveDraft = "steering draft"; +const steeringAnswer = "§"; let draftVisibleAt; -let queuedVisibleAt; +let pendingVisibleAt; const originalSetTimeout = globalThis.setTimeout; let observeZeroTimeouts = false; let zeroTimeoutCount = 0; @@ -39,7 +39,7 @@ const terminal = { output.push(chunk); streamedText += streamedDecoder.decode(chunk, { stream: true }); if (draftVisibleAt === undefined && streamedText.includes(liveDraft)) draftVisibleAt = performance.now(); - if (queuedVisibleAt === undefined && streamedText.includes("queued 1")) queuedVisibleAt = performance.now(); + if (pendingVisibleAt === undefined && streamedText.includes("1 pending message")) pendingVisibleAt = performance.now(); process.stdout.write(chunk); }, async drain() { @@ -81,7 +81,7 @@ const mockFetch = async (_url, init) => { secondRequestBody = JSON.parse(new TextDecoder().decode(init.body)); return new Response(new ReadableStream({ start(controller) { - controller.enqueue(encoded.encode(`data: {"type":"text-delta","delta":"${queuedAnswer}"}\n`)); + controller.enqueue(encoded.encode(`data: {"type":"text-delta","delta":"${steeringAnswer}"}\n`)); controller.enqueue(encoded.encode('data: {"type":"finish","finishReason":{"unified":"stop"},"usage":{"inputTokens":{"total":1},"outputTokens":{"total":2}}}\n')); controller.enqueue(encoded.encode("data: [DONE]\n")); controller.close(); @@ -147,21 +147,21 @@ while (draftVisibleAt === undefined) { await new Promise((resolve) => setTimeout(resolve, 10)); } runtime.write("\r"); -while (queuedVisibleAt === undefined) { - if (streamFinishedAt !== undefined) throw new Error("terminal did not queue follow-up input while the response was active"); - if (performance.now() >= deadline) throw new Error("timed out waiting for queued follow-up input"); +while (pendingVisibleAt === undefined) { + if (streamFinishedAt !== undefined) throw new Error("terminal did not hold steering while the response was active"); + if (performance.now() >= deadline) throw new Error("timed out waiting for pending steering"); await new Promise((resolve) => setTimeout(resolve, 10)); } observeZeroTimeouts = false; -if (secondRequestAt !== undefined) throw new Error("queued follow-up started before the active response finished"); +if (secondRequestAt !== undefined) throw new Error("steering started before the active response finished"); releaseFirstStream(); while (streamFinishedAt === undefined) { if (performance.now() >= deadline) throw new Error("timed out waiting for streamed fx-term response"); await new Promise((resolve) => setTimeout(resolve, 10)); } -const queuedDeadline = performance.now() + 5000; -while (secondRequestAt === undefined || !streamedText.includes(queuedAnswer)) { - if (performance.now() >= queuedDeadline) throw new Error("timed out waiting for queued fx-term response"); +const steeringDeadline = performance.now() + 5000; +while (secondRequestAt === undefined || !streamedText.includes(steeringAnswer)) { + if (performance.now() >= steeringDeadline) throw new Error("timed out waiting for steered fx-term response"); await new Promise((resolve) => setTimeout(resolve, 10)); } runtime.write("/exit\r"); @@ -178,14 +178,19 @@ if (!text.includes("Run /help for commands")) throw new Error("shared fx welcome if (requestedModel !== "sdk/term-model") throw new Error(`terminal prompt did not use the host-restored model: ${requestedModel}`); if (!(streamStartedAt < streamFinishedAt)) throw new Error("terminal fetch did not remain active for continuous streaming"); if (!(draftVisibleAt < streamFinishedAt)) throw new Error("terminal rendered follow-up input only after continuous streaming finished"); -if (!(queuedVisibleAt < streamFinishedAt)) throw new Error("terminal queued follow-up input only after continuous streaming finished"); -if (!(secondRequestAt >= streamFinishedAt)) throw new Error("terminal started queued follow-up before continuous streaming finished"); -const queuedUser = secondRequestBody.prompt?.filter((message) => message.role === "user").at(-1); -const queuedText = queuedUser?.content?.filter((part) => part.type === "text").map((part) => part.text); -if (queuedText?.length !== 1 || queuedText[0] !== liveDraft) { - throw new Error(`queued follow-up request changed the submitted draft: ${JSON.stringify(queuedText)}`); +if (!(pendingVisibleAt < streamFinishedAt)) throw new Error("terminal showed pending steering only after continuous streaming finished"); +if (!(secondRequestAt >= streamFinishedAt)) throw new Error("terminal started steering before continuous streaming finished"); +const steeringUser = secondRequestBody.prompt?.filter((message) => message.role === "user").at(-1); +const steeringText = steeringUser?.content?.filter((part) => part.type === "text").map((part) => part.text); +if ( + steeringText?.length !== 1 || + !steeringText[0].includes("") || + !steeringText[0].includes("Apply this live user update to the current task.") || + !steeringText[0].includes(liveDraft) +) { + throw new Error(`steering request changed the submitted draft or directive: ${JSON.stringify(steeringText)}`); } -if (requestCount !== 2) throw new Error(`terminal sent ${requestCount} requests instead of the active and queued turns`); +if (requestCount !== 2) throw new Error(`terminal sent ${requestCount} requests instead of the active and steered steps`); if (zeroTimeoutCount !== 0) throw new Error(`terminal allocated ${zeroTimeoutCount} zero-timeout poll timer(s)`); if (!events.some((event) => event.type === "config.restore" && event.configId === "model")) throw new Error("terminal model restore event was not emitted"); if (!events.some((event) => event.type === "config.restore" && event.configId === "mode")) throw new Error("terminal mode restore event was not emitted"); From 843c5b81858a6e0c1d300c47588726e6be10d8f0 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 15:30:49 -0400 Subject: [PATCH 05/14] Show pending steering messages Render pending steering text in submission order. Keep the Escape affordance on the final message only. --- sdk/tests/test-term.mjs | 2 +- src/core/agent/worker_runtime.zig | 51 +++++++++++++ src/core/app/app_render_runtime.zig | 59 ++++++++------- src/ui/footer/input_presentation.zig | 72 ++++++++++++++----- src/ui/footer/paint_plan.zig | 38 +++++++--- src/ui/footer/render_input.zig | 15 +++- .../e2e/tui-gateway-stream-lifecycle.test.ts | 28 ++++++-- 7 files changed, 204 insertions(+), 61 deletions(-) diff --git a/sdk/tests/test-term.mjs b/sdk/tests/test-term.mjs index 0bd6733d3..4b6de9532 100644 --- a/sdk/tests/test-term.mjs +++ b/sdk/tests/test-term.mjs @@ -39,7 +39,7 @@ const terminal = { output.push(chunk); streamedText += streamedDecoder.decode(chunk, { stream: true }); if (draftVisibleAt === undefined && streamedText.includes(liveDraft)) draftVisibleAt = performance.now(); - if (pendingVisibleAt === undefined && streamedText.includes("1 pending message")) pendingVisibleAt = performance.now(); + if (pendingVisibleAt === undefined && streamedText.includes(`${liveDraft} · Esc to steer now`)) pendingVisibleAt = performance.now(); process.stdout.write(chunk); }, async drain() { diff --git a/src/core/agent/worker_runtime.zig b/src/core/agent/worker_runtime.zig index 44b7b6eaa..c342e3239 100644 --- a/src/core/agent/worker_runtime.zig +++ b/src/core/agent/worker_runtime.zig @@ -1324,6 +1324,32 @@ pub const WorkerRuntime = struct { }; } + /// Returns allocator-owned copies of pending steering text in admission + /// order. The caller frees every message and the returned slice. + pub fn snapshotSteeringMessages(self: *WorkerRuntime, alloc: std.mem.Allocator) ![][]u8 { + self.worker_mutex.lockUncancelable(io_mod.getIo()); + defer self.worker_mutex.unlock(io_mod.getIo()); + + var count: usize = 0; + for (self.queued_prompts.items) |prompt| { + if (prompt.steer_target_turn_id != null) count += 1; + } + if (count == 0) return &.{}; + + const messages = try alloc.alloc([]u8, count); + var copied: usize = 0; + errdefer { + for (messages[0..copied]) |message| alloc.free(message); + alloc.free(messages); + } + for (self.queued_prompts.items) |prompt| { + if (prompt.steer_target_turn_id == null) continue; + messages[copied] = try alloc.dupe(u8, prompt.prompt); + copied += 1; + } + return messages; + } + pub fn queuedPromptCount(self: *WorkerRuntime) usize { self.worker_mutex.lockUncancelable(io_mod.getIo()); defer self.worker_mutex.unlock(io_mod.getIo()); @@ -3206,6 +3232,31 @@ test "active prompt admission drains steering in FIFO order" { try std.testing.expectEqualStrings("second", runtime.worker_events.items[1].append_user_feedback); } +test "steering snapshot owns pending messages in admission order" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "second", "model")); + const messages = try runtime.snapshotSteeringMessages(alloc); + defer { + for (messages) |message| alloc.free(message); + alloc.free(messages); + } + + const guidance = try runtime.takeSteering(alloc, 41); + defer { + for (guidance) |text| alloc.free(text); + alloc.free(guidance); + } + try std.testing.expectEqual(@as(usize, 2), messages.len); + try std.testing.expectEqualStrings("first", messages[0]); + try std.testing.expectEqualStrings("second", messages[1]); +} + test "manual queue review does not intercept active steering" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 98a28c1de..1f0073e0e 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -136,12 +136,17 @@ const RenderReconciliation = union(enum) { const QueuedCardProjection = struct { cards: []render_input.QueuedPromptCard = &.{}, + steering_messages: [][]u8 = &.{}, + ordinary_count: usize = 0, + paused: bool = false, row_count: u16 = 0, editor_active: bool = false, fn deinit(self: *QueuedCardProjection, alloc: std.mem.Allocator) void { for (self.cards) |card| alloc.free(card.bytes); if (self.cards.len > 0) alloc.free(self.cards); + for (self.steering_messages) |message| alloc.free(message); + if (self.steering_messages.len > 0) alloc.free(self.steering_messages); self.* = .{}; } }; @@ -311,14 +316,31 @@ fn previewWithPendingCard( return next; } -// Queued prompts stay collapsed behind their summary row until the review is -// opened; only then does the banner expand into one card per queued prompt. +// Steering text stays visible while ordinary queued prompts remain collapsed +// until review opens. Every slice in the result is owned for one render frame. fn buildQueuedCardProjection(comptime App: type, app: *App) !QueuedCardProjection { - if (comptime !@hasField(App, "queued_prompt_review")) return .{}; + var projection: QueuedCardProjection = .{}; + errdefer projection.deinit(app.alloc); + const queue_preview = app.worker.queuePreview(); + const steering_count = if (comptime @hasField(@TypeOf(queue_preview), "steering_count")) + queue_preview.steering_count + else + 0; + projection.ordinary_count = queue_preview.count -| steering_count; + projection.paused = if (comptime @hasField(@TypeOf(queue_preview), "paused")) + queue_preview.paused + else + false; + if (comptime @hasDecl(@TypeOf(app.worker), "snapshotSteeringMessages")) { + if (steering_count > 0) { + projection.steering_messages = try app.worker.snapshotSteeringMessages(app.alloc); + } + } + if (comptime !@hasField(App, "queued_prompt_review")) return projection; const review_entries = app.queued_prompt_review.entries; if (!app.queued_prompt_review.visible or !app.queued_prompt_review.active() or - review_entries.len == 0) return .{}; + review_entries.len == 0) return projection; const draft_count = review_entries.len; const measurement = try input_queue_runtime.measureVisibleReviewRows( @@ -387,11 +409,10 @@ fn buildQueuedCardProjection(comptime App: type, app: *App) !QueuedCardProjectio cards[built] = .{ .bytes = bytes }; } - return .{ - .cards = cards, - .row_count = measurement.card_rows, - .editor_active = measurement.editor_active, - }; + projection.cards = cards; + projection.row_count = measurement.card_rows; + projection.editor_active = measurement.editor_active; + return projection; } noinline fn approvalScreenNeedsClear( @@ -515,8 +536,6 @@ pub fn Runtime(comptime App: type) type { shimmer_pos: i16, queued_cards: *const QueuedCardProjection, ) render_input.RenderContext { - const queue_preview = app.worker.queuePreview(); - const model_query = app.input_runtime.picker.activeModelPickerQuery(&app.input_runtime.edit_state); const pending_model = if (app.input_runtime.picker.hasPendingModelPickerSelection()) app.input_runtime.picker.model_picker_pending_model.items else null; var model_picker_stage: picker_state.ModelPickerStage = .model; @@ -625,20 +644,12 @@ pub fn Runtime(comptime App: type) type { app.permission_engine.mode else .ask, - .queued_count = if (queued_cards.cards.len > 0) queued_cards.cards.len else queue_preview.count, - .steering_count = if (comptime @hasField(@TypeOf(queue_preview), "steering_count")) - queue_preview.steering_count - else - 0, - .steering_waiting_on_tool = if (comptime @hasField(@TypeOf(queue_preview), "steering_count")) - queue_preview.steering_count > 0 and - shell_runtime.activeToolActivityCount(&app.shell) > 0 + .queued_count = if (queued_cards.cards.len > 0) + queued_cards.cards.len else - false, - .queued_paused = if (comptime @hasField(@TypeOf(queue_preview), "paused")) - queue_preview.paused - else - false, + queued_cards.ordinary_count + queued_cards.steering_messages.len, + .steering_messages = queued_cards.steering_messages, + .queued_paused = queued_cards.paused, .queued_cancel_all_available = if (comptime @hasField(App, "queued_prompt_review")) app.queued_prompt_review.active() and app.queued_prompt_review.reason.? == .post_cancel and diff --git a/src/ui/footer/input_presentation.zig b/src/ui/footer/input_presentation.zig index dae336030..9203a7f17 100644 --- a/src/ui/footer/input_presentation.zig +++ b/src/ui/footer/input_presentation.zig @@ -9,6 +9,7 @@ const display_width = @import("../../core/shared/display_width.zig"); const list_window = @import("../../core/shared/list_window.zig"); const skill_runtime = @import("../../core/skills/skill_runtime.zig"); const types = @import("../../core/shared/types.zig"); +const text_utils = @import("../../core/shared/text_utils.zig"); const paste_blocks = @import("../../core/input/pasted_blocks.zig"); const core_input_runtime = @import("../../core/input/runtime.zig"); const visual_layout = @import("../input/visual_layout.zig"); @@ -54,13 +55,10 @@ pub const ComposedInputRows = struct { } }; -// Pending prompts stay hidden here. Ordinary queued work advertises review, -// while active-turn steering reports its wait and interrupt action. +// Ordinary queued work stays collapsed until review opens. pub fn composeQueuedSummaryRow( alloc: Allocator, queued_count: usize, - steering_count: usize, - steering_waiting_on_tool: bool, queued_paused: bool, width: u16, ) !std.ArrayList(u8) { @@ -68,14 +66,10 @@ pub fn composeQueuedSummaryRow( try row.appendSlice(alloc, ui_render.hint_style); // The paused hint row already owns the controls, so it drops the affordance. - const affordance = if (queued_paused or steering_count > 0) "" else " · ↑ to edit"; + const affordance = if (queued_paused) "" else " · ↑ to edit"; var row_buf: [max_top_row_len]u8 = undefined; const label = if (queued_count == 0) "queued" - else if (steering_count > 0 and steering_waiting_on_tool) - "Waiting for tool · Esc to steer now" - else if (steering_count > 0) - std.fmt.bufPrint(&row_buf, "{d} pending message{s}", .{ queued_count, if (queued_count == 1) "" else "s" }) catch "pending messages" else if (queued_count == 1) std.fmt.bufPrint(&row_buf, "1 queued message{s}", .{affordance}) catch "1 queued message" else @@ -86,6 +80,39 @@ pub fn composeQueuedSummaryRow( return row; } +pub fn composeSteeringMessageRow( + alloc: Allocator, + message: []const u8, + show_escape_hint: bool, + width: u16, +) !std.ArrayList(u8) { + var row: std.ArrayList(u8) = .empty; + try row.appendSlice(alloc, ui_render.hint_style); + var safe_message = try text_utils.encodeTerminalSafe( + alloc, + message, + std.math.maxInt(usize), + ); + defer safe_message.deinit(alloc); + + const escape_hint = " · Esc to steer now"; + const width_usize: usize = width; + const escape_width = display_width.visibleWidth(escape_hint); + if (show_escape_hint and width_usize > escape_width) { + try row_text.appendSingleLineEllipsized( + alloc, + &row, + safe_message.bytes, + width_usize - escape_width, + ); + try row.appendSlice(alloc, escape_hint); + } else { + try row_text.appendSingleLineEllipsized(alloc, &row, safe_message.bytes, width_usize); + } + try row.appendSlice(alloc, ui_render.reset_style); + return row; +} + pub fn composeQueueReviewHintRow( alloc: Allocator, width: u16, @@ -106,24 +133,35 @@ pub fn composeQueueReviewHintRow( } test "collapsed queue banner counts the waiting prompts and offers the review" { - var single = try composeQueuedSummaryRow(std.testing.allocator, 1, 0, false, false, 80); + var single = try composeQueuedSummaryRow(std.testing.allocator, 1, false, 80); defer single.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, single.items, "1 queued message · ↑ to edit") != null); - var many = try composeQueuedSummaryRow(std.testing.allocator, 3, 0, false, false, 80); + var many = try composeQueuedSummaryRow(std.testing.allocator, 3, false, 80); defer many.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, many.items, "3 queued messages · ↑ to edit") != null); } -test "collapsed queue banner identifies pending steering" { - var row = try composeQueuedSummaryRow(std.testing.allocator, 1, 1, true, false, 80); - defer row.deinit(std.testing.allocator); +test "steering rows show actual messages and only the final escape hint" { + var first = try composeSteeringMessageRow(std.testing.allocator, "First steer", false, 80); + defer first.deinit(std.testing.allocator); + var final = try composeSteeringMessageRow(std.testing.allocator, "Second steer", true, 80); + defer final.deinit(std.testing.allocator); + + try std.testing.expect(std.mem.find(u8, first.items, "First steer") != null); + try std.testing.expect(std.mem.find(u8, first.items, "Esc to steer now") == null); + try std.testing.expect(std.mem.find(u8, final.items, "Second steer · Esc to steer now") != null); +} - try std.testing.expect(std.mem.find(u8, row.items, "Waiting for tool · Esc to steer now") != null); +test "steering rows visibly escape terminal control bytes" { + var unsafe = try composeSteeringMessageRow(std.testing.allocator, "before\x1b[2Jafter", true, 80); + defer unsafe.deinit(std.testing.allocator); + try std.testing.expect(std.mem.find(u8, unsafe.items, "before\\x1b[2Jafter · Esc to steer now") != null); + try std.testing.expect(std.mem.find(u8, unsafe.items, "\x1b[2J") == null); } test "collapsed queue banner drops the affordance while the review is paused" { - var row = try composeQueuedSummaryRow(std.testing.allocator, 2, 0, false, true, 80); + var row = try composeQueuedSummaryRow(std.testing.allocator, 2, true, 80); defer row.deinit(std.testing.allocator); try std.testing.expect(std.mem.find(u8, row.items, "2 queued messages") != null); @@ -426,7 +464,7 @@ pub fn composeHintRow( ctx.has_api_key or (ctx.auth_picker.active and ctx.auth_picker.include_skip), ctx.model, ctx.permission_mode, - ctx.queued_count -| ctx.steering_count, + ctx.queued_count -| ctx.steering_messages.len, active_label, ctx.fast_indicator_active, ctx.effort, diff --git a/src/ui/footer/paint_plan.zig b/src/ui/footer/paint_plan.zig index d9ea9a2fc..975ea6e12 100644 --- a/src/ui/footer/paint_plan.zig +++ b/src/ui/footer/paint_plan.zig @@ -581,16 +581,34 @@ fn pushQueuedPromptBannerRows( if (ctx.queued_prompt_cards.len == 0) { var painted: u16 = 0; - var summary = try input_presentation.composeQueuedSummaryRow( - alloc, - ctx.queued_count, - ctx.steering_count, - ctx.steering_waiting_on_tool, - ctx.queued_paused, - width, - ); - try pushFooterBandRow(alloc, frame, plan, plan.footer.banner, &summary); - painted +|= 1; + if (ctx.steering_messages.len > 0) { + for (ctx.steering_messages, 0..) |message, index| { + if (painted >= plan.footer.banner_rows) break; + var row = try input_presentation.composeSteeringMessageRow( + alloc, + message, + index + 1 == ctx.steering_messages.len, + width, + ); + try pushFooterBandRow( + alloc, + frame, + plan, + plan.footer.banner +| painted, + &row, + ); + painted +|= 1; + } + } else { + var summary = try input_presentation.composeQueuedSummaryRow( + alloc, + ctx.queued_count, + ctx.queued_paused, + width, + ); + try pushFooterBandRow(alloc, frame, plan, plan.footer.banner, &summary); + painted +|= 1; + } if (ctx.queued_paused and painted < plan.footer.banner_rows) { var hint = try input_presentation.composeQueueReviewHintRow( alloc, diff --git a/src/ui/footer/render_input.zig b/src/ui/footer/render_input.zig index dc0a255ef..38722a77d 100644 --- a/src/ui/footer/render_input.zig +++ b/src/ui/footer/render_input.zig @@ -421,8 +421,7 @@ pub const RenderContext = struct { composer_visible: bool = true, permission_mode: types.PermissionMode = .ask, queued_count: usize, - steering_count: usize = 0, - steering_waiting_on_tool: bool = false, + steering_messages: []const []const u8 = &.{}, queued_paused: bool = false, queued_cancel_all_available: bool = false, queued_prompt_cards: []const QueuedPromptCard = &.{}, @@ -509,11 +508,15 @@ pub const QueuedBannerFacts = struct { paused: bool = false, card_count: usize = 0, card_rows: u16 = 0, + steering_rows: u16 = 0, }; pub fn queuedBannerRowsForFacts(facts: QueuedBannerFacts) u16 { if (facts.queued_count == 0) return 0; const paused_hint_rows: u16 = @intFromBool(facts.paused); + if (facts.steering_rows > 0) { + return facts.steering_rows +| paused_hint_rows +| collapsed_queue_banner_gap_rows; + } if (facts.card_rows > 0) { const between_cards: u16 = @intCast(@min( facts.card_count -| 1, @@ -531,6 +534,10 @@ pub fn queuedBannerRows(ctx: RenderContext) u16 { .paused = ctx.queued_paused, .card_count = ctx.queued_prompt_cards.len, .card_rows = ctx.queued_prompt_card_rows, + .steering_rows = @intCast(@min( + ctx.steering_messages.len, + @as(usize, std.math.maxInt(u16)), + )), }); } @@ -542,6 +549,10 @@ test "queued banner row policy consumes aggregate card facts" { .queued_count = 2, .paused = true, })); + try std.testing.expectEqual(@as(u16, 3), queuedBannerRowsForFacts(.{ + .queued_count = 2, + .steering_rows = 2, + })); try std.testing.expectEqual(@as(u16, 7), queuedBannerRowsForFacts(.{ .queued_count = 2, .card_count = 2, diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index 7a3529ccc..10e5d19fc 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -2901,7 +2901,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { SPLIT_BOUNDARY_TEST_TIMEOUT, ); test( - "ordinary Enter waits for a running tool before steering the same turn", + "ordinary Enter keeps pending steering visible in submission order", async () => { root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-cooperative-steering-"))); const home = join(root, "home"); @@ -2915,7 +2915,8 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const command = `while [ ! -f ${JSON.stringify(releasePath)} ]; do sleep 0.05; done; ` + "printf COOPERATIVE_TOOL_DONE"; - const steering = "Use COOPERATIVE_STEERING_SENTINEL in the answer."; + const firstSteering = "Use COOPERATIVE_STEERING_SENTINEL in the answer."; + const secondSteering = "Keep the answer to one sentence."; const finalText = "COOPERATIVE_STEERING_COMPLETE"; const steeringGateway = startFakeGateway([ fakeGatewayToolCall("cooperative_steering_tool", "terminal", { @@ -2951,9 +2952,19 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { await session.waitForComposer(TIMEOUT); await session.sendText("Run the cooperative steering fixture."); await session.waitForText("Running while", TIMEOUT); - await session.sendText(steering); - await session.waitForText("Waiting for tool · Esc to steer now", TIMEOUT); - expect(await session.capturePane()).not.toContain("queued 1"); + await session.sendText(firstSteering); + await session.sendText(secondSteering); + await Bun.sleep(150); + const pendingPane = await session.capturePane(); + expect(pendingPane).toContain(firstSteering); + expect(pendingPane).toContain(`${secondSteering} · Esc to steer now`); + expect(pendingPane.indexOf(firstSteering)).toBeLessThan( + pendingPane.indexOf(secondSteering), + ); + expect(countOccurrences(pendingPane, "Esc to steer now")).toBe(1); + expect(pendingPane).not.toContain("Waiting for tool"); + expect(pendingPane).not.toContain("pending message"); + expect(pendingPane).not.toContain("queued 1"); expect(steeringGateway.requests).toHaveLength(1); writeFileSync(releasePath, "release\n"); @@ -2966,9 +2977,12 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const continuedBody = steeringGateway.requests[1]!.body; const trace = readFileSync(tracePath, "utf8"); expect(continuedBody.indexOf("COOPERATIVE_TOOL_DONE")).toBeGreaterThanOrEqual(0); - expect(continuedBody.indexOf(steering)).toBeGreaterThan( + expect(continuedBody.indexOf(firstSteering)).toBeGreaterThan( continuedBody.indexOf("COOPERATIVE_TOOL_DONE"), ); + expect(continuedBody.indexOf(secondSteering)).toBeGreaterThan( + continuedBody.indexOf(firstSteering), + ); expect(continuedBody).toContain("live user update"); expect(trace).toContain("event=prompt_steering_consumed"); expect(trace).not.toContain("event=queue_review_started"); @@ -3027,7 +3041,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { await session.sendText("Run the immediate steering fixture."); await session.waitForText("Running sleep 30", TIMEOUT); await session.sendText(steering); - await session.waitForText("Waiting for tool · Esc to steer now", TIMEOUT); + await session.waitForText(`${steering} · Esc to steer now`, TIMEOUT); expect(steeringGateway.requests).toHaveLength(1); await session.sendKeys("Escape"); From a7361acd4557b58ac793748cd6aa062a8f0ead24 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 18:48:57 -0400 Subject: [PATCH 06/14] Handle immediate and rich steering updates Interrupt tool-free responses after accepting guidance, keep rich updates in the steering flow, and preserve pending message identity in narrow terminals. --- src/core/agent/runtime/deps.zig | 3 + src/core/agent/runtime/orchestrator.zig | 28 +- src/core/agent/runtime/tests/gateway_flow.zig | 25 + src/core/agent/worker_runtime.zig | 278 +++++++++- src/core/app/app_callbacks.zig | 6 + src/ui/footer/input_presentation.zig | 15 +- src/ui/footer/paint_plan.zig | 61 +- .../e2e/tui-gateway-stream-lifecycle.test.ts | 519 ++++++++++-------- tests/e2e/tui-interrupt-recovery.test.ts | 44 +- 9 files changed, 696 insertions(+), 283 deletions(-) diff --git a/src/core/agent/runtime/deps.zig b/src/core/agent/runtime/deps.zig index 7f5226417..aa9c2274b 100644 --- a/src/core/agent/runtime/deps.zig +++ b/src/core/agent/runtime/deps.zig @@ -180,6 +180,9 @@ pub const AgentRuntimeDeps = struct { /// Drains user guidance admitted to the active turn. Returned text is owned /// by `arena` and is non-authoritative context, never permission evidence. take_steering: ?*const fn (ctx: *anyopaque, arena: Allocator, turn_id: u64) anyerror![]const []const u8 = null, + /// True when pending interactive guidance cannot be transferred into the + /// current request and must start after the active turn settles. + steering_handoff_required: ?*const fn (ctx: *anyopaque, turn_id: u64) bool = null, release_agent_terminal_lease: *const fn (ctx: *anyopaque, session_id: []const u8) anyerror!void = terminalLeaseCleanupUnavailable, prepare_parent_turn_context: ?*const fn (ctx: *anyopaque, arena: Allocator) anyerror!?PreparedParentTurnContext = null, acknowledge_parent_turn_context: ?*const fn (ctx: *anyopaque, arena: Allocator, acknowledgements: []const ParentTurnDeliveryAck) void = null, diff --git a/src/core/agent/runtime/orchestrator.zig b/src/core/agent/runtime/orchestrator.zig index fc4a5c4cd..cbe4c1a6c 100644 --- a/src/core/agent/runtime/orchestrator.zig +++ b/src/core/agent/runtime/orchestrator.zig @@ -3890,7 +3890,14 @@ fn processQueuedPromptInner( try local_grants.append(arena, .{ .tool_name = grant.tool_name, .target_path = grant.target_path }); } - const current_user_message: ChatMessage = .{ .role = .user, .content = job.prompt, .images = job.images }; + const current_user_message: ChatMessage = .{ + .role = .user, + .content = if (job.steering_continuation) + try runtime_execution_memory.steeringMessage(arena, job.prompt) + else + job.prompt, + .images = job.images, + }; var stop_state = CommonStopState{}; processQueuedPromptLoop( @@ -4357,6 +4364,25 @@ fn processQueuedPromptLoop( finish_trace.finish("interrupted"); return; } + if (deps.steering_handoff_required) |handoff_required| { + if (handoff_required(deps.ctx, turn_id)) { + try runtime_interruption.persistInterruptedTurnOnce( + deps, + finalization, + job, + null, + null, + completed_tool_names.items, + &interrupted_persisted, + step_ctx, + within_turn_suffix.items, + stop_state.retained_candidate, + &stop_state.terminal_materializing, + ); + finish_trace.finish("steering_handoff"); + return; + } + } _ = overlay_arena_state.reset(.retain_capacity); const overlay_arena = overlay_arena_state.allocator(); var ephemeral_overlay: std.ArrayList(ChatMessage) = .empty; diff --git a/src/core/agent/runtime/tests/gateway_flow.zig b/src/core/agent/runtime/tests/gateway_flow.zig index ae2f594a0..da2a276cb 100644 --- a/src/core/agent/runtime/tests/gateway_flow.zig +++ b/src/core/agent/runtime/tests/gateway_flow.zig @@ -245,6 +245,31 @@ test "terminal assistant completion continues with steering admitted during the try std.testing.expectEqualStrings("change direction", execution.steering[0]); } +test "promoted steering remains model marked across the worker handoff" { + const alloc = std.testing.allocator; + const completions = [_]FakeCompletion{.{ .content = "Updated answer" }}; + var gateway = FakeGateway.init(alloc, &completions); + defer gateway.deinit(); + var hooks = FakeAgentRuntimeDeps.init(alloc); + defer hooks.deinit(); + var fixture = PromptFixture{}; + var job = fixture.job(); + job.steering_continuation = true; + + try runFakePrompt(&gateway, &hooks, fixture.config(), job); + + try std.testing.expectEqual(@as(usize, 1), gateway.request_bodies.items.len); + try expectBodyContainsInOrder(&gateway, 0, &.{ + "user_steering", + "user prompt", + }); + try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); + try std.testing.expectEqualStrings( + "user prompt", + hooks.history_turns.items[0].assistant.user.text, + ); +} + fn makeOwnedProviderPrompt(alloc: Allocator, text: []const u8, model: []const u8) !QueuedPrompt { const prompt = try alloc.dupe(u8, text); errdefer alloc.free(prompt); diff --git a/src/core/agent/worker_runtime.zig b/src/core/agent/worker_runtime.zig index c342e3239..bc8842fc7 100644 --- a/src/core/agent/worker_runtime.zig +++ b/src/core/agent/worker_runtime.zig @@ -60,6 +60,9 @@ pub const QueuedPrompt = struct { /// The active turn that may consume this prompt as steering. When that turn /// finishes, the target is cleared in place so admission order is retained. steer_target_turn_id: ?u64 = null, + /// Set when steering must start as the next worker turn after the active + /// turn settles. The model still receives the prompt as live guidance. + steering_continuation: bool = false, prompt: []u8, images: []types.ImageAttachment, authorized_image_catalog: []types.ImageAttachment = &.{}, @@ -550,6 +553,8 @@ pub const WorkerRuntime = struct { worker_events: std.ArrayList(WorkerEvent) = .empty, worker_processing: bool = false, active_turn_id: u64 = 0, + active_tool_calls: std.StringHashMapUnmanaged(void) = .empty, + active_tool_allocator: ?std.mem.Allocator = null, worker_stop_requested: bool = false, finalization_failure: ?FinalizationFailure = null, worker_cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), @@ -594,6 +599,8 @@ pub const WorkerRuntime = struct { for (self.queued_prompts.items) |prompt| discardQueuedPrompt(alloc, prompt, &.{}); self.queued_prompts.deinit(alloc); + self.clearActiveToolCallsLocked(); + for (self.worker_events.items) |event| freeWorkerEvent(alloc, event); self.worker_events.deinit(alloc); } @@ -686,7 +693,11 @@ pub const WorkerRuntime = struct { pub fn pushOwnedEvent(self: *WorkerRuntime, alloc: std.mem.Allocator, event: WorkerEvent) !void { self.worker_mutex.lockUncancelable(io_mod.getIo()); defer self.worker_mutex.unlock(io_mod.getIo()); - try self.worker_events.append(alloc, event); + try self.worker_events.ensureUnusedCapacity(alloc, 1); + var tool_transition = try self.prepareActiveToolTransitionLocked(alloc, event); + errdefer tool_transition.deinit(alloc); + self.worker_events.appendAssumeCapacity(event); + self.applyActiveToolTransitionLocked(tool_transition); self.applyRecoveryStateEvent(event); self.worker_cond.broadcast(io_mod.getIo()); } @@ -831,16 +842,37 @@ pub const WorkerRuntime = struct { return error.RecoveryBusy; } queued.agent_settings = self.agent_turn_settings; - if (steer_if_active and - self.worker_processing and - self.active_turn_id != 0 and - queued.images.len == 0 and - queued.skill_bindings.len == 0 and - queued.skill_display_spans.len == 0) - { + var interrupt_after_admission = false; + if (steer_if_active and self.worker_processing and self.active_turn_id != 0) { queued.steer_target_turn_id = self.active_turn_id; + interrupt_after_admission = self.active_tool_calls.count() == 0; } try self.enqueuePromptLocked(alloc, queued); + if (interrupt_after_admission) { + self.worker_cancel_requested.store(true, .seq_cst); + } + } + + fn sameTurnSteeringEligible(prompt: QueuedPrompt) bool { + return prompt.images.len == 0 and + prompt.skill_bindings.len == 0 and + prompt.skill_display_spans.len == 0; + } + + fn isSteeringPrompt(prompt: QueuedPrompt) bool { + return prompt.steer_target_turn_id != null or prompt.steering_continuation; + } + + pub fn steeringHandoffRequired(self: *WorkerRuntime, turn_id: u64) bool { + self.worker_mutex.lockUncancelable(io_mod.getIo()); + defer self.worker_mutex.unlock(io_mod.getIo()); + if (!self.worker_processing or self.active_turn_id != turn_id) return false; + for (self.queued_prompts.items) |prompt| { + if (prompt.steer_target_turn_id == turn_id and !sameTurnSteeringEligible(prompt)) { + return true; + } + } + return false; } fn enqueuePromptLocked(self: *WorkerRuntime, alloc: std.mem.Allocator, queued: QueuedPrompt) !void { @@ -875,7 +907,9 @@ pub const WorkerRuntime = struct { var steering_count: usize = 0; for (self.queued_prompts.items) |prompt| { - if (prompt.steer_target_turn_id == turn_id) steering_count += 1; + if (prompt.steer_target_turn_id != turn_id) continue; + if (!sameTurnSteeringEligible(prompt)) return &.{}; + steering_count += 1; } if (steering_count == 0) return &.{}; @@ -929,7 +963,7 @@ pub const WorkerRuntime = struct { if (self.queued_prompts.items.len == 0) return false; if (reason == .manual) { for (self.queued_prompts.items) |prompt| { - if (prompt.steer_target_turn_id == self.active_turn_id) return false; + if (isSteeringPrompt(prompt)) return false; } } if (self.queue_admission) |current| { @@ -1009,7 +1043,7 @@ pub const WorkerRuntime = struct { null; drafts[filled] = .{ .turn_id = queued.turn_id, - .kind = if (queued.steer_target_turn_id != null) .steering else .queued, + .kind = if (isSteeringPrompt(queued)) .steering else .queued, .prompt = prompt, .images = images, .skill_display_spans = skill_display_spans, @@ -1127,7 +1161,7 @@ pub const WorkerRuntime = struct { var kind: PromptDraftKind = .queued; for (self.queued_prompts.items, 0..) |queued, index| { if (queued.turn_id != turn_id) continue; - kind = if (queued.steer_target_turn_id != null) .steering else .queued; + kind = if (isSteeringPrompt(queued)) .steering else .queued; removed = self.queued_prompts.orderedRemove(index); if (self.queued_prompt_count > 0) self.queued_prompt_count -= 1; break; @@ -1209,6 +1243,13 @@ pub const WorkerRuntime = struct { self.recovery_continuation_ready = false; self.worker_processing = true; self.active_turn_id = job.turn_id; + if (job.steering_continuation) { + for (self.queued_prompts.items) |*prompt| { + if (!prompt.steering_continuation) continue; + prompt.steer_target_turn_id = job.turn_id; + prompt.steering_continuation = false; + } + } debug_trace.logf( "worker", "begin prompt bytes={d} remaining_queue={d} fast_mode={s} effort={s}", @@ -1233,10 +1274,12 @@ pub const WorkerRuntime = struct { for (self.queued_prompts.items) |*prompt| { if (prompt.steer_target_turn_id == finished_turn_id) { prompt.steer_target_turn_id = null; + prompt.steering_continuation = true; } } self.worker_processing = false; self.active_turn_id = 0; + self.clearActiveToolCallsLocked(); self.worker_connectivity_wait_active.store(false, .seq_cst); self.worker_cond.broadcast(io_mod.getIo()); self.worker_mutex.unlock(io_mod.getIo()); @@ -1257,6 +1300,85 @@ pub const WorkerRuntime = struct { return true; } + const ActiveToolTransition = union(enum) { + none, + started: []u8, + terminal: []const u8, + clear, + + fn deinit(self: *ActiveToolTransition, alloc: std.mem.Allocator) void { + switch (self.*) { + .started => |call_id| alloc.free(call_id), + .none, .terminal, .clear => {}, + } + self.* = .none; + } + }; + + fn prepareActiveToolTransitionLocked( + self: *WorkerRuntime, + alloc: std.mem.Allocator, + event: WorkerEvent, + ) !ActiveToolTransition { + return switch (event) { + .tool_lifecycle => |lifecycle| switch (lifecycle) { + .authoritative_started => |started| blk: { + if (started.id.turn_id != self.active_turn_id or + self.active_tool_calls.contains(started.id.call_id)) + { + break :blk .none; + } + if (self.active_tool_allocator) |active_alloc| { + std.debug.assert(std.meta.eql(active_alloc, alloc)); + } else { + self.active_tool_allocator = alloc; + } + try self.active_tool_calls.ensureUnusedCapacity(alloc, 1); + break :blk .{ .started = try alloc.dupe(u8, started.id.call_id) }; + }, + .terminal => |terminal| if (terminal.id.turn_id == self.active_turn_id and + self.active_tool_calls.contains(terminal.id.call_id)) + .{ .terminal = terminal.id.call_id } + else + .none, + .turn_finished => |finished| if (finished.turn_id == self.active_turn_id) .clear else .none, + .provisional, .progress => .none, + }, + else => .none, + }; + } + + fn applyActiveToolTransitionLocked( + self: *WorkerRuntime, + transition: ActiveToolTransition, + ) void { + switch (transition) { + .none => {}, + .started => |call_id| { + self.active_tool_calls.putAssumeCapacity(call_id, {}); + }, + .terminal => |call_id| { + const removed = self.active_tool_calls.fetchRemove(call_id) orelse return; + std.debug.assert(self.active_tool_allocator != null); + const alloc = self.active_tool_allocator.?; + alloc.free(removed.key); + }, + .clear => self.clearActiveToolCallsLocked(), + } + } + + fn clearActiveToolCallsLocked(self: *WorkerRuntime) void { + const alloc = self.active_tool_allocator orelse { + std.debug.assert(self.active_tool_calls.count() == 0); + return; + }; + var keys = self.active_tool_calls.keyIterator(); + while (keys.next()) |key| alloc.free(@constCast(key.*)); + self.active_tool_calls.deinit(alloc); + self.active_tool_calls = .empty; + self.active_tool_allocator = null; + } + pub fn waitUntilIdle(self: *WorkerRuntime) void { self.worker_mutex.lockUncancelable(io_mod.getIo()); defer self.worker_mutex.unlock(io_mod.getIo()); @@ -1315,7 +1437,7 @@ pub const WorkerRuntime = struct { if (count == 0) return .{}; var steering_count: usize = 0; for (self.queued_prompts.items) |prompt| { - if (prompt.steer_target_turn_id != null) steering_count += 1; + if (isSteeringPrompt(prompt)) steering_count += 1; } return .{ .count = count, @@ -1332,7 +1454,7 @@ pub const WorkerRuntime = struct { var count: usize = 0; for (self.queued_prompts.items) |prompt| { - if (prompt.steer_target_turn_id != null) count += 1; + if (isSteeringPrompt(prompt)) count += 1; } if (count == 0) return &.{}; @@ -1343,7 +1465,7 @@ pub const WorkerRuntime = struct { alloc.free(messages); } for (self.queued_prompts.items) |prompt| { - if (prompt.steer_target_turn_id == null) continue; + if (!isSteeringPrompt(prompt)) continue; messages[copied] = try alloc.dupe(u8, prompt.prompt); copied += 1; } @@ -3232,6 +3354,104 @@ test "active prompt admission drains steering in FIFO order" { try std.testing.expectEqualStrings("second", runtime.worker_events.items[1].append_user_feedback); } +test "tool lifecycle decides whether interactive steering interrupts immediately" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + try runtime.pushEvent(alloc, .{ .tool_lifecycle = .{ .authoritative_started = .{ + .id = .{ .turn_id = 41, .call_id = "call_running" }, + .reconciles_provisional_call_id = null, + .tool_name = "terminal", + .activity_kind = .command, + } } }); + try runtime.pushEvent(alloc, .{ .tool_lifecycle = .{ .authoritative_started = .{ + .id = .{ .turn_id = 41, .call_id = "call_parallel" }, + .reconciles_provisional_call_id = null, + .tool_name = "read_file", + .activity_kind = .read, + } } }); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "wait for tool", "model")); + try std.testing.expect(!runtime.isCancelRequested()); + + try runtime.pushEvent(alloc, .{ .tool_lifecycle = .{ .terminal = .{ + .id = .{ .turn_id = 41, .call_id = "call_running" }, + .outcome = .{ .kind = .completed, .summary = "completed" }, + } } }); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "still wait", "model")); + try std.testing.expect(!runtime.isCancelRequested()); + + try runtime.pushEvent(alloc, .{ .tool_lifecycle = .{ .terminal = .{ + .id = .{ .turn_id = 41, .call_id = "call_parallel" }, + .outcome = .{ .kind = .completed, .summary = "completed" }, + } } }); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "interrupt generation", "model")); + try std.testing.expect(runtime.isCancelRequested()); +} + +test "failed steering admission does not cancel the active turn" { + var runtime = WorkerRuntime{}; + defer runtime.deinit(std.testing.allocator); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + var failing = std.testing.FailingAllocator.init( + std.testing.allocator, + .{ .fail_index = 0 }, + ); + const prompt = QueuedPrompt{ + .prompt = @constCast("retain me"), + .images = &.{}, + .model = @constCast("model"), + .api_key = @constCast("key"), + .permission_mode = .auto, + .history = &.{}, + .grants = &.{}, + }; + + try std.testing.expectError( + error.OutOfMemory, + runtime.admitInteractivePrompt(failing.allocator(), prompt), + ); + try std.testing.expect(!runtime.isCancelRequested()); + try std.testing.expectEqual(@as(usize, 0), runtime.queuedPromptCount()); +} + +test "rich interactive input remains steering and hands off at the tool boundary" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + try runtime.pushEvent(alloc, .{ .tool_lifecycle = .{ .authoritative_started = .{ + .id = .{ .turn_id = 41, .call_id = "call_running" }, + .reconciles_provisional_call_id = null, + .tool_name = "terminal", + .activity_kind = .command, + } } }); + + var prompt = try makePrompt(alloc, "inspect this image", "model"); + errdefer freeQueuedPrompt(alloc, prompt); + prompt.images = try alloc.alloc(types.ImageAttachment, 1); + prompt.images[0] = .{ + .path = try alloc.dupe(u8, "/tmp/steering.png"), + .media_type = try alloc.dupe(u8, "image/png"), + }; + try runtime.admitInteractivePrompt(alloc, prompt); + + const preview = runtime.queuePreview(); + try std.testing.expectEqual(@as(usize, 1), preview.steering_count); + try std.testing.expect(!runtime.isCancelRequested()); + try std.testing.expect(runtime.steeringHandoffRequired(41)); + + const guidance = try runtime.takeSteering(alloc, 41); + try std.testing.expectEqual(@as(usize, 0), guidance.len); + try std.testing.expectEqual(@as(usize, 1), runtime.queuedPromptCount()); +} + test "steering snapshot owns pending messages in admission order" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; @@ -3292,7 +3512,35 @@ test "late steering keeps admission order when demoted on finish" { try std.testing.expectEqual(@as(usize, 2), runtime.queuedPromptCount()); try std.testing.expectEqualStrings("steer first", runtime.queued_prompts.items[0].prompt); try std.testing.expect(runtime.queued_prompts.items[0].steer_target_turn_id == null); + try std.testing.expect(runtime.queued_prompts.items[0].steering_continuation); try std.testing.expectEqualStrings("queue second", runtime.queued_prompts.items[1].prompt); + try std.testing.expect(!runtime.queued_prompts.items[1].steering_continuation); +} + +test "promoted steering retargets remaining guidance without exposing a queue" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 9; + + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "second", "model")); + runtime.finishProcessing(); + + const promoted = (try runtime.tryTakeNextPrompt(alloc)).?; + defer freeQueuedPrompt(alloc, promoted); + try std.testing.expect(promoted.steering_continuation); + try std.testing.expectEqual(@as(usize, 1), runtime.queuePreview().steering_count); + + const guidance = try runtime.takeSteering(alloc, promoted.turn_id); + defer { + for (guidance) |text| alloc.free(text); + alloc.free(guidance); + } + try std.testing.expectEqual(@as(usize, 1), guidance.len); + try std.testing.expectEqualStrings("second", guidance[0]); + try std.testing.expectEqual(@as(usize, 0), runtime.queuedPromptCount()); } test "clear queued prompts also clears steering" { diff --git a/src/core/app/app_callbacks.zig b/src/core/app/app_callbacks.zig index d433033ac..032bc74e2 100644 --- a/src/core/app/app_callbacks.zig +++ b/src/core/app/app_callbacks.zig @@ -285,6 +285,7 @@ pub fn Bindings(comptime App: type) type { null, .finalize_turn = agentFinalizeTurn, .take_steering = if (comptime @hasDecl(@TypeOf(app.worker), "takeSteering")) agentTakeSteering else null, + .steering_handoff_required = if (comptime @hasDecl(@TypeOf(app.worker), "steeringHandoffRequired")) agentSteeringHandoffRequired else null, .append_runtime_context = agentAppendRuntimeContext, .append_static_context = agentAppendStaticContext, .validate_tool_call = agentValidateToolCall, @@ -575,6 +576,11 @@ pub fn Bindings(comptime App: type) type { return result; } + fn agentSteeringHandoffRequired(ctx: *anyopaque, turn_id: u64) bool { + const app: *App = @ptrCast(@alignCast(ctx)); + return app.worker.steeringHandoffRequired(turn_id); + } + fn agentAppendStaticContext(ctx: *anyopaque, arena: Allocator, messages: *std.ArrayList(ChatMessage)) !void { const app: *App = @ptrCast(@alignCast(ctx)); if (comptime @hasDecl(App, "appendStaticContextMessage")) { diff --git a/src/ui/footer/input_presentation.zig b/src/ui/footer/input_presentation.zig index 9203a7f17..4c17b3798 100644 --- a/src/ui/footer/input_presentation.zig +++ b/src/ui/footer/input_presentation.zig @@ -99,7 +99,7 @@ pub fn composeSteeringMessageRow( const width_usize: usize = width; const escape_width = display_width.visibleWidth(escape_hint); if (show_escape_hint and width_usize > escape_width) { - try row_text.appendSingleLineEllipsized( + try row_text.appendSingleLineMiddleEllipsized( alloc, &row, safe_message.bytes, @@ -107,7 +107,7 @@ pub fn composeSteeringMessageRow( ); try row.appendSlice(alloc, escape_hint); } else { - try row_text.appendSingleLineEllipsized(alloc, &row, safe_message.bytes, width_usize); + try row_text.appendSingleLineMiddleEllipsized(alloc, &row, safe_message.bytes, width_usize); } try row.appendSlice(alloc, ui_render.reset_style); return row; @@ -153,6 +153,17 @@ test "steering rows show actual messages and only the final escape hint" { try std.testing.expect(std.mem.find(u8, final.items, "Second steer · Esc to steer now") != null); } +test "narrow steering row preserves distinguishing message ends and the escape hint" { + const message = "BEGIN change the implementation direction and retain this unique END"; + var row = try composeSteeringMessageRow(std.testing.allocator, message, true, 48); + defer row.deinit(std.testing.allocator); + + try std.testing.expect(std.mem.find(u8, row.items, "BEGIN") != null); + try std.testing.expect(std.mem.find(u8, row.items, "END") != null); + try std.testing.expect(std.mem.find(u8, row.items, "Esc to steer now") != null); + try std.testing.expect(display_width.visibleWidthIgnoringAnsi(row.items) <= 48); +} + test "steering rows visibly escape terminal control bytes" { var unsafe = try composeSteeringMessageRow(std.testing.allocator, "before\x1b[2Jafter", true, 80); defer unsafe.deinit(std.testing.allocator); diff --git a/src/ui/footer/paint_plan.zig b/src/ui/footer/paint_plan.zig index 975ea6e12..166e15893 100644 --- a/src/ui/footer/paint_plan.zig +++ b/src/ui/footer/paint_plan.zig @@ -582,7 +582,12 @@ fn pushQueuedPromptBannerRows( if (ctx.queued_prompt_cards.len == 0) { var painted: u16 = 0; if (ctx.steering_messages.len > 0) { - for (ctx.steering_messages, 0..) |message, index| { + const visible_count = @min( + ctx.steering_messages.len, + @as(usize, plan.footer.banner_rows), + ); + const visible_start = ctx.steering_messages.len - visible_count; + for (ctx.steering_messages[visible_start..], visible_start..) |message, index| { if (painted >= plan.footer.banner_rows) break; var row = try input_presentation.composeSteeringMessageRow( alloc, @@ -1584,6 +1589,60 @@ test "queued prompts collapse to a single summary row until the review opens" { try std.testing.expect(frame_plan.paint.footer.input_base >= banner + 2); } +test "clamped steering banner keeps newest messages and escape hint" { + const alloc = std.testing.allocator; + + var input = InputRuntime{}; + defer input.deinit(alloc); + + var shell = TranscriptRuntime{ + .layout = .{ + .rows = 12, + .cols = 48, + .content_bottom = 6, + .divider_top_row = 7, + .input_row = 8, + .divider_bottom_row = 9, + .hint_row = 10, + }, + .owned_top_row = 1, + .viewport_top_row = 1, + .cursor_row = 4, + .cursor_col = 1, + }; + defer shell.deinit(alloc); + + const messages = [_][]const u8{ "first hidden", "second hidden", "third visible", "fourth visible" }; + var ctx = testContext(&input); + ctx.queued_count = messages.len; + ctx.steering_messages = &messages; + const planner_input: FooterPlannerInput = .{ + .active_label = null, + .ctx = ctx, + .place_mid_line_active = false, + .input_extra = 0, + .input_visible = true, + .composer_top_chrome_rows = composerTopChromeRows(), + .picker_rows = 0, + .footer_extra_rows = 2, + .banner_active = true, + .banner_rows = 2, + }; + + const frame_plan = planFooterPaint(&shell, planner_input); + var frame = try composeFooterFrame(alloc, &shell, planner_input, frame_plan.paint); + defer frame.deinit(alloc); + + const banner = frame_plan.paint.footer.banner; + try expectFrameRowTextTrimmed(&frame, banner, shell.layout.cols, "third visible"); + try expectFrameRowTextTrimmed( + &frame, + banner + 1, + shell.layout.cols, + "fourth visible · Esc to steer now", + ); +} + test "a hidden paused review keeps the summary row above its hint" { const alloc = std.testing.allocator; diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index 10e5d19fc..2d9cd8886 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -2794,7 +2794,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { ); test( - "complete assistant block precedes a queued user prompt in scrollback", + "visible assistant prefix precedes immediate steering in scrollback", async () => { root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-prompt-boundary-"))); const home = join(root, "home"); @@ -2859,11 +2859,13 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { await session.waitForText("SPLIT_OLD_TAIL_FINAL", TIMEOUT); await session.sendText(SPLIT_NEW_USER_PROMPT); - expect(splitGateway.requests).toHaveLength(1); - firstResponse.release?.(); + await waitForCondition( + () => firstResponse.cancelled, + "visible assistant steering cancellation", + ); await waitForCondition( () => splitGateway.requests.length === 2 && secondResponse.started, - "held second Gateway stream", + "immediate steering Gateway stream", ); const rawScrollback = await waitForEscapedScrollback( @@ -2886,6 +2888,10 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { expect(finalTailIndex).toBeLessThan(promptIndex); expect(countOccurrences(rawScrollback, SPLIT_NEW_USER_PROMPT)).toBe(1); + expect(splitGateway.requests[1]!.body).toContain(""); + expect(splitGateway.requests[1]!.body).toContain(SPLIT_NEW_USER_PROMPT); + const trace = readFileSync(tracePath, "utf8"); + expect(trace).not.toContain("event=queue_review_started"); expect(scrollback).toContain("SPLIT_OLD_TAIL_FINAL"); expect(readFileSync(stderrPath, "utf8")).toBe(""); expect(existsSync(tapePath)).toBe(true); @@ -2901,7 +2907,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { SPLIT_BOUNDARY_TEST_TIMEOUT, ); test( - "ordinary Enter keeps pending steering visible in submission order", + "ordinary Enter keeps pending steering visible through narrow resize", async () => { root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-cooperative-steering-"))); const home = join(root, "home"); @@ -2915,8 +2921,9 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const command = `while [ ! -f ${JSON.stringify(releasePath)} ]; do sleep 0.05; done; ` + "printf COOPERATIVE_TOOL_DONE"; - const firstSteering = "Use COOPERATIVE_STEERING_SENTINEL in the answer."; - const secondSteering = "Keep the answer to one sentence."; + const firstSteering = "FIRST_BEGIN use COOPERATIVE_STEERING_SENTINEL in the answer FIRST_END"; + const secondSteering = "SECOND_BEGIN keep the answer concise while preserving its result SECOND_END"; + const thirdSteering = "THIRD_BEGIN mention the completed command before the conclusion THIRD_END"; const finalText = "COOPERATIVE_STEERING_COMPLETE"; const steeringGateway = startFakeGateway([ fakeGatewayToolCall("cooperative_steering_tool", "terminal", { @@ -2954,19 +2961,40 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { await session.waitForText("Running while", TIMEOUT); await session.sendText(firstSteering); await session.sendText(secondSteering); + await session.sendText(thirdSteering); await Bun.sleep(150); const pendingPane = await session.capturePane(); expect(pendingPane).toContain(firstSteering); - expect(pendingPane).toContain(`${secondSteering} · Esc to steer now`); + expect(pendingPane).toContain(secondSteering); + expect(pendingPane).toContain(`${thirdSteering} · Esc to steer now`); expect(pendingPane.indexOf(firstSteering)).toBeLessThan( pendingPane.indexOf(secondSteering), ); + expect(pendingPane.indexOf(secondSteering)).toBeLessThan( + pendingPane.indexOf(thirdSteering), + ); expect(countOccurrences(pendingPane, "Esc to steer now")).toBe(1); expect(pendingPane).not.toContain("Waiting for tool"); expect(pendingPane).not.toContain("pending message"); expect(pendingPane).not.toContain("queued 1"); expect(steeringGateway.requests).toHaveLength(1); + await session.resizeWindow(60, 16); + const narrowPane = await session.capturePane(); + for (const marker of [ + "FIRST_BEGIN", + "FIRST_END", + "SECOND_BEGIN", + "SECOND_END", + "THIRD_BEGIN", + "THIRD_END", + ]) { + expect(narrowPane).toContain(marker); + } + expect(countOccurrences(narrowPane, "Esc to steer now")).toBe(1); + expect(narrowPane).not.toContain("queued message"); + await session.resizeWindow(120, 40); + writeFileSync(releasePath, "release\n"); await session.waitForText(finalText, TIMEOUT); await waitForCondition( @@ -2983,6 +3011,9 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { expect(continuedBody.indexOf(secondSteering)).toBeGreaterThan( continuedBody.indexOf(firstSteering), ); + expect(continuedBody.indexOf(thirdSteering)).toBeGreaterThan( + continuedBody.indexOf(secondSteering), + ); expect(continuedBody).toContain("live user update"); expect(trace).toContain("event=prompt_steering_consumed"); expect(trace).not.toContain("event=queue_review_started"); @@ -2993,6 +3024,183 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { TIMEOUT * 2, ); + test( + "rich steering waits for the running tool and hands off without queue UI", + async () => { + root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-rich-steering-tool-"))); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const tracePath = join(root, "trace.log"); + const stderrPath = join(root, "stderr.log"); + const releasePath = join(workspace, ".release-rich-steering-tool"); + const imagePath = join(workspace, "steering-image.png"); + mkdirSync(join(home, ".fx"), { recursive: true }); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(home, ".fx", "settings.json"), "{}"); + copyFileSync(join(REPO_ROOT, "tests/e2e/fixtures/favicon.png"), imagePath); + const expectedImageData = readFileSync(imagePath).toString("base64"); + const command = + `while [ ! -f ${JSON.stringify(releasePath)} ]; do sleep 0.05; done; ` + + "printf RICH_STEERING_TOOL_DONE"; + const steering = "Use the attached image after this command finishes."; + const finalText = "RICH_STEERING_HANDOFF_COMPLETE"; + const steeringGateway = startFakeGateway([ + fakeGatewayToolCall("rich_steering_tool", "terminal", { + action: "exec", + timeout_ms: 600_000, + command, + }), + fakeGatewayFinalText(finalText), + ], { + models: [{ id: MODEL, type: "language", tags: ["vision", "file-input", "tool-use"] }], + }); + gateway = steeringGateway; + + session = await TmuxSession.create({ + cwd: workspace, + stderrPath, + width: 120, + height: 40, + env: { + HOME: home, + AI_GATEWAY_API_KEY: "fake-rich-steering-tool-key", + VERCEL_OIDC_TOKEN: undefined, + FX_AUTO_UPGRADE: "0", + FX_SOUND: "0", + FX_PERMISSION_MODE: "yolo", + FX_GATEWAY_BASE_URL: steeringGateway.baseUrl, + FX_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_E2E_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_E2E_GATEWAY_MODELS_URL: `${steeringGateway.baseUrl}/coding-agent/v1/models`, + FX_MODEL: MODEL, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "agent,worker,input,tool,interrupt", + }, + }); + + await session.waitForComposer(TIMEOUT); + await session.sendText("Run the rich steering tool fixture."); + await session.waitForText("Running while", TIMEOUT); + await session.sendText(`/image ${imagePath}`); + await session.waitForText("attached image: steering-image.png", TIMEOUT); + await session.sendText(steering); + await session.waitForText(`${steering} · Esc to steer now`, TIMEOUT); + expect(steeringGateway.requests).toHaveLength(1); + + writeFileSync(releasePath, "release\n"); + await session.waitForText(finalText, TIMEOUT); + await waitForCondition( + () => steeringGateway.requests.length === 2, + "rich steering handoff request", + ); + + const continuedBody = steeringGateway.requests[1]!.body; + const continuedRequest = JSON.parse(continuedBody) as { + prompt: Array<{ role?: string; content?: unknown }>; + }; + const continuedUser = continuedRequest.prompt.filter((message) => + message.role === "user" + ).at(-1); + expect(continuedUser).toBeDefined(); + expect(Array.isArray(continuedUser!.content)).toBe(true); + const continuedParts = continuedUser!.content as Array>; + expect(continuedParts.filter((part) => part.type === "file")).toEqual([{ + type: "file", + mediaType: "image/png", + data: expectedImageData, + }]); + expect(continuedBody).toContain("RICH_STEERING_TOOL_DONE"); + expect(continuedBody).toContain(""); + expect(continuedBody).toContain(steering); + const trace = readFileSync(tracePath, "utf8"); + expect(trace).toContain("outcome_kind=steering_handoff"); + expect(trace).not.toContain("event=queue_review_started"); + const scrollback = await session.captureFullScrollback(); + expect(scrollback).not.toContain(queuedSummaryText(1)); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + expect(session.isAlive()).toBe(true); + expect(session.isPaneAlive()).toBe(true); + }, + TIMEOUT * 2, + ); + + test( + "failed tool result precedes pending steering", + async () => { + root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-failed-tool-steering-"))); + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const tracePath = join(root, "trace.log"); + const stderrPath = join(root, "stderr.log"); + const releasePath = join(workspace, ".release-failed-steering-tool"); + mkdirSync(join(home, ".fx"), { recursive: true }); + mkdirSync(workspace, { recursive: true }); + writeFileSync(join(home, ".fx", "settings.json"), "{}"); + const command = + `while [ ! -f ${JSON.stringify(releasePath)} ]; do sleep 0.05; done; ` + + "printf FAILED_TOOL_STEERING_RESULT; exit 7"; + const steering = "Respond exactly FAILED_TOOL_STEERING_COMPLETE."; + const steeringGateway = startFakeGateway([ + fakeGatewayToolCall("failed_steering_tool", "terminal", { + action: "exec", + timeout_ms: 600_000, + command, + }), + fakeGatewayFinalText("FAILED_TOOL_STEERING_COMPLETE"), + ]); + gateway = steeringGateway; + + session = await TmuxSession.create({ + cwd: workspace, + stderrPath, + width: 120, + height: 40, + env: { + HOME: home, + AI_GATEWAY_API_KEY: "fake-failed-tool-steering-key", + VERCEL_OIDC_TOKEN: undefined, + FX_AUTO_UPGRADE: "0", + FX_SOUND: "0", + FX_PERMISSION_MODE: "yolo", + FX_GATEWAY_BASE_URL: steeringGateway.baseUrl, + FX_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_E2E_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_MODEL: MODEL, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "agent,worker,input,tool,interrupt", + }, + }); + + await session.waitForComposer(TIMEOUT); + await session.sendText("Run the failed steering tool fixture."); + await session.waitForText("Running while", TIMEOUT); + await session.sendText(steering); + await session.waitForText(`${steering} · Esc to steer now`, TIMEOUT); + expect(steeringGateway.requests).toHaveLength(1); + + writeFileSync(releasePath, "release\n"); + await session.waitForText("FAILED_TOOL_STEERING_COMPLETE", TIMEOUT); + await waitForCondition( + () => steeringGateway.requests.length === 2, + "failed tool steering request", + ); + + const continuedBody = steeringGateway.requests[1]!.body; + expect(continuedBody.indexOf("FAILED_TOOL_STEERING_RESULT")).toBeGreaterThanOrEqual(0); + expect(continuedBody.indexOf(steering)).toBeGreaterThan( + continuedBody.indexOf("FAILED_TOOL_STEERING_RESULT"), + ); + expect(continuedBody).toContain(""); + const trace = readFileSync(tracePath, "utf8"); + expect(trace).toContain("event=prompt_steering_consumed"); + expect(trace).not.toContain("event=queue_review_started"); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + expect(session.isAlive()).toBe(true); + expect(session.isPaneAlive()).toBe(true); + }, + TIMEOUT * 2, + ); + test( "Escape interrupts a running tool and starts pending steering without review", async () => { @@ -3066,7 +3274,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { ); test( - "queued prompt stays pending until active assistant text completes", + "rich steering interrupts tool-free generation without queue UI", async () => { root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-order-"))); const home = join(root, "home"); @@ -3083,37 +3291,37 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const sibling = join(workspace, "sibling"); mkdirSync(nested, { recursive: true }); mkdirSync(sibling, { recursive: true }); - const imagePath = join(nested, "queued-snapshot.png"); + const imagePath = join(nested, "steering-snapshot.png"); copyFileSync( join(REPO_ROOT, "tests/e2e/fixtures/favicon.png"), imagePath, ); const image = realpathSync(imagePath); const expectedImageData = readFileSync(image).toString("base64"); - const oldGlobalRule = "QUEUED_SNAPSHOT_OLD_GLOBAL_RULE"; - const oldAncestorRule = "QUEUED_SNAPSHOT_OLD_ANCESTOR_RULE"; - const oldRootRule = "QUEUED_SNAPSHOT_OLD_ROOT_RULE"; - const oldNestedRule = "QUEUED_SNAPSHOT_OLD_NESTED_RULE"; - const oldSiblingRule = "QUEUED_SNAPSHOT_OLD_SIBLING_MUST_BE_ABSENT"; - const newGlobalRule = "QUEUED_SNAPSHOT_NEW_GLOBAL_MUST_BE_ABSENT"; - const newAncestorRule = "QUEUED_SNAPSHOT_NEW_ANCESTOR_MUST_BE_ABSENT"; - const newRootRule = "QUEUED_SNAPSHOT_NEW_ROOT_MUST_BE_ABSENT"; - const newNestedRule = "QUEUED_SNAPSHOT_NEW_NESTED_MUST_BE_ABSENT"; - const newSiblingRule = "QUEUED_SNAPSHOT_NEW_SIBLING_MUST_BE_ABSENT"; + const oldGlobalRule = "STEERING_SNAPSHOT_OLD_GLOBAL_RULE"; + const oldAncestorRule = "STEERING_SNAPSHOT_OLD_ANCESTOR_RULE"; + const oldRootRule = "STEERING_SNAPSHOT_OLD_ROOT_RULE"; + const oldNestedRule = "STEERING_SNAPSHOT_OLD_NESTED_RULE"; + const oldSiblingRule = "STEERING_SNAPSHOT_OLD_SIBLING_MUST_BE_ABSENT"; + const newGlobalRule = "STEERING_SNAPSHOT_NEW_GLOBAL_MUST_BE_ABSENT"; + const newAncestorRule = "STEERING_SNAPSHOT_NEW_ANCESTOR_MUST_BE_ABSENT"; + const newRootRule = "STEERING_SNAPSHOT_NEW_ROOT_MUST_BE_ABSENT"; + const newNestedRule = "STEERING_SNAPSHOT_NEW_NESTED_MUST_BE_ABSENT"; + const newSiblingRule = "STEERING_SNAPSHOT_NEW_SIBLING_MUST_BE_ABSENT"; writeFileSync(join(home, ".fx", "AGENTS.md"), `${oldGlobalRule}\n`); writeFileSync(join(launchAncestor, "AGENTS.md"), `${oldAncestorRule}\n`); writeFileSync(join(workspace, "AGENTS.md"), `${oldRootRule}\n`); writeFileSync(join(nested, "AGENTS.md"), `${oldNestedRule}\n`); writeFileSync(join(sibling, "AGENTS.md"), `${oldSiblingRule}\n`); const hold: HoldState = { started: false, cancelled: false }; - const activeBefore = "ACTIVE_ASSISTANT_BEFORE_QUEUE_SENTINEL\n"; - const activeAfter = "ACTIVE_ASSISTANT_AFTER_QUEUE_SENTINEL\n"; - const queuedPrompt = "QUEUED_PROMPT_CANONICAL_ORDER_SENTINEL"; - const queuedDone = "QUEUED_PROMPT_CANONICAL_ORDER_DONE"; - const queuedGateway = startFakeGateway( + const activeBefore = "ACTIVE_ASSISTANT_BEFORE_STEERING_SENTINEL\n"; + const activeAfter = "ACTIVE_ASSISTANT_AFTER_STEERING_MUST_BE_ABSENT\n"; + const steeringPrompt = "RICH_STEERING_CANONICAL_ORDER_SENTINEL"; + const steeringDone = "RICH_STEERING_CANONICAL_ORDER_DONE"; + const steeringGateway = startFakeGateway( [ () => splitHeldTextResponse(hold, activeBefore, activeAfter), - fakeGatewayFinalText(queuedDone), + fakeGatewayFinalText(steeringDone), ], { models: [{ @@ -3123,7 +3331,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { }], }, ); - gateway = queuedGateway; + gateway = steeringGateway; session = await TmuxSession.create({ cwd: workspace, @@ -3133,13 +3341,13 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { minimumHistoryLines: 2_000, env: { HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-transcript-key", + AI_GATEWAY_API_KEY: "fake-rich-steering-key", VERCEL_OIDC_TOKEN: undefined, FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_MODELS_URL: `${queuedGateway.baseUrl}/coding-agent/v1/models`, + FX_GATEWAY_BASE_URL: steeringGateway.baseUrl, + FX_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_E2E_GATEWAY_CHAT_URL: steeringGateway.chatUrl, + FX_E2E_GATEWAY_MODELS_URL: `${steeringGateway.baseUrl}/coding-agent/v1/models`, FX_MODEL: MODEL, FX_RECORD: tapePath, FX_RECORD_INPUT: "1", @@ -3151,30 +3359,14 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { await session.waitForComposer(TIMEOUT); await session.sendText("Hold the active turn open."); await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, + () => steeringGateway.requests.length === 1 && hold.started, "held active Gateway request", ); await session.waitForText("Generating", TIMEOUT); await session.sendText(`/image ${image}`); - await session.waitForText("attached image: queued-snapshot.png", TIMEOUT); - await session.sendText(queuedPrompt); - const heldScrollback = await waitForEscapedScrollback( - session, - (candidate) => - queuedGateway.requests.length === 1 && - hold.started && - candidate.includes(queuedSummaryText(1)) && - !candidate.includes(queuedPrompt), - "queued prompt count shown before active turn releases", - ); - - expect(heldScrollback).not.toContain(queuedPrompt); - expect(heldScrollback).not.toContain("next:"); - expect(countOccurrences(heldScrollback, queuedPrompt)).toBe(0); - expect(heldScrollback).not.toContain(activeBefore.trim()); - expect(heldScrollback).not.toContain(activeAfter.trim()); - expect(queuedGateway.requests).toHaveLength(1); + await session.waitForText("attached image: steering-snapshot.png", TIMEOUT); + await session.sendText(steeringPrompt); writeFileSync(join(home, ".fx", "AGENTS.md"), `${newGlobalRule}\n`); writeFileSync(join(launchAncestor, "AGENTS.md"), `${newAncestorRule}\n`); @@ -3182,67 +3374,58 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { writeFileSync(join(nested, "AGENTS.md"), `${newNestedRule}\n`); writeFileSync(join(sibling, "AGENTS.md"), `${newSiblingRule}\n`); - hold.release?.(); - await session.waitForText(queuedDone, TIMEOUT); + await waitForCondition(() => hold.cancelled, "rich steering cancellation"); + await session.waitForText(steeringDone, TIMEOUT); await waitForCondition( - () => queuedGateway.requests.length === 2, - "queued prompt drained", + () => steeringGateway.requests.length === 2, + "rich steering request", ); - const queuedBody = queuedGateway.requests[1]!.body; - const queuedRequest = JSON.parse(queuedBody) as { + const steeringBody = steeringGateway.requests[1]!.body; + const steeringRequest = JSON.parse(steeringBody) as { prompt: Array<{ role?: string; content?: unknown }>; }; - const queuedUser = queuedRequest.prompt.filter((message) => + const steeringUser = steeringRequest.prompt.filter((message) => message.role === "user" ).at(-1); - expect(queuedUser).toBeDefined(); - expect(Array.isArray(queuedUser!.content)).toBe(true); - const queuedParts = queuedUser!.content as Array>; - expect(queuedParts.filter((part) => part.type === "file")).toEqual([{ + expect(steeringUser).toBeDefined(); + expect(Array.isArray(steeringUser!.content)).toBe(true); + const steeringParts = steeringUser!.content as Array>; + expect(steeringParts.filter((part) => part.type === "file")).toEqual([{ type: "file", mediaType: "image/png", data: expectedImageData, }]); - expect(countOccurrences(queuedBody, oldGlobalRule)).toBe(1); - expect(countOccurrences(queuedBody, oldAncestorRule)).toBe(1); - expect(countOccurrences(queuedBody, oldRootRule)).toBe(1); - expect(countOccurrences(queuedBody, oldNestedRule)).toBe(1); - expect(queuedBody.indexOf(oldGlobalRule)).toBeLessThan( - queuedBody.indexOf(oldAncestorRule), + expect(steeringBody).toContain(""); + expect(countOccurrences(steeringBody, oldGlobalRule)).toBe(1); + expect(countOccurrences(steeringBody, oldAncestorRule)).toBe(1); + expect(countOccurrences(steeringBody, oldRootRule)).toBe(1); + expect(countOccurrences(steeringBody, oldNestedRule)).toBe(1); + expect(steeringBody.indexOf(oldGlobalRule)).toBeLessThan( + steeringBody.indexOf(oldAncestorRule), ); - expect(queuedBody.indexOf(oldAncestorRule)).toBeLessThan( - queuedBody.indexOf(oldRootRule), + expect(steeringBody.indexOf(oldAncestorRule)).toBeLessThan( + steeringBody.indexOf(oldRootRule), ); - expect(queuedBody.indexOf(oldRootRule)).toBeLessThan( - queuedBody.indexOf(oldNestedRule), + expect(steeringBody.indexOf(oldRootRule)).toBeLessThan( + steeringBody.indexOf(oldNestedRule), ); - expect(queuedBody).not.toContain(oldSiblingRule); - expect(queuedBody).not.toContain(newGlobalRule); - expect(queuedBody).not.toContain(newAncestorRule); - expect(queuedBody).not.toContain(newRootRule); - expect(queuedBody).not.toContain(newNestedRule); - expect(queuedBody).not.toContain(newSiblingRule); + expect(steeringBody).not.toContain(oldSiblingRule); + expect(steeringBody).not.toContain(newGlobalRule); + expect(steeringBody).not.toContain(newAncestorRule); + expect(steeringBody).not.toContain(newRootRule); + expect(steeringBody).not.toContain(newNestedRule); + expect(steeringBody).not.toContain(newSiblingRule); const finalScrollback = await session.captureFullScrollbackEscapes(); - const beforeIndex = finalScrollback.indexOf(activeBefore.trim()); - const afterIndex = finalScrollback.indexOf(activeAfter.trim()); - const queuedPromptIndex = finalScrollback.indexOf(queuedPrompt); - const queuedDoneIndex = finalScrollback.indexOf(queuedDone); - const summaryOffset = finalScrollback - .slice(afterIndex) - .search( - / {2}(?:\d+s|\d+m \d+s|\d+h \d{2}m) \(↑\d+(?:\.\d)?k? ↓\d+(?:\.\d)?k?\)/, - ); - const firstSummaryAfterActiveIndex = - summaryOffset < 0 ? -1 : afterIndex + summaryOffset; - expect(beforeIndex).toBeGreaterThanOrEqual(0); - expect(afterIndex).toBeGreaterThan(beforeIndex); - expect(firstSummaryAfterActiveIndex).toBeGreaterThan(afterIndex); - expect(firstSummaryAfterActiveIndex).toBeLessThan(queuedPromptIndex); - expect(queuedPromptIndex).toBeGreaterThan(afterIndex); - expect(queuedDoneIndex).toBeGreaterThan(queuedPromptIndex); - expect(countOccurrences(finalScrollback, queuedPrompt)).toBe(1); + const steeringPromptIndex = finalScrollback.indexOf(steeringPrompt); + const steeringDoneIndex = finalScrollback.indexOf(steeringDone); + expect(finalScrollback).not.toContain(activeBefore.trim()); + expect(finalScrollback).not.toContain(activeAfter.trim()); + expect(steeringPromptIndex).toBeGreaterThanOrEqual(0); + expect(steeringDoneIndex).toBeGreaterThan(steeringPromptIndex); + expect(countOccurrences(finalScrollback, steeringPrompt)).toBe(1); + expect(finalScrollback).not.toContain(queuedSummaryText(1)); expect(readFileSync(stderrPath, "utf8")).toBe(""); expect(existsSync(tapePath)).toBe(true); expect(session.isAlive()).toBe(true); @@ -3361,150 +3544,6 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { TIMEOUT * 2, ); - test( - "queued image yank survives deleting its queue card", - async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-queued-image-yank-"))); - const home = join(root, "home"); - const workspacePath = join(root, "workspace"); - const tracePath = join(root, "trace.log"); - const stderrPath = join(root, "stderr.log"); - const imagePath = join(workspacePath, "queued-image.png"); - mkdirSync(join(home, ".fx"), { recursive: true }); - mkdirSync(workspacePath, { recursive: true }); - writeFileSync(join(home, ".fx", "settings.json"), "{}"); - copyFileSync( - join(REPO_ROOT, "tests/e2e/fixtures/favicon.png"), - imagePath, - ); - const workspace = realpathSync(workspacePath); - const image = realpathSync(imagePath); - const expectedImageData = readFileSync(image).toString("base64"); - const hold: HoldState = { started: false, cancelled: false }; - const queuedPrompt = "Describe FXC141_QUEUED_IMAGE_YANK."; - const done = "FXC141_QUEUED_IMAGE_YANK_DONE"; - const queuedGateway = startFakeGateway( - [ - () => - heldGatewayResponse(hold, [ - { type: "text-start", id: "answer_1" }, - { - type: "text-delta", - id: "answer_1", - delta: "ACTIVE_QUEUED_IMAGE_YANK_STARTED\n", - }, - ]), - fakeGatewayFinalText(done), - ], - { - models: [{ - id: MODEL, - type: "language", - tags: ["vision", "file-input", "tool-use"], - }], - }, - ); - gateway = queuedGateway; - - session = await TmuxSession.create({ - cwd: workspace, - stderrPath, - width: 100, - height: 30, - env: { - HOME: home, - AI_GATEWAY_API_KEY: "fake-queued-image-yank-key", - VERCEL_OIDC_TOKEN: undefined, - FX_AUTO_UPGRADE: "0", - FX_GATEWAY_BASE_URL: queuedGateway.baseUrl, - FX_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_CHAT_URL: queuedGateway.chatUrl, - FX_E2E_GATEWAY_MODELS_URL: `${queuedGateway.baseUrl}/coding-agent/v1/models`, - FX_MODEL: MODEL, - FX_TRACE_LOG: tracePath, - FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt,interrupt", - }, - }); - - await session.waitForComposer(TIMEOUT); - await session.sendText("Hold the queued image yank turn open."); - await waitForCondition( - () => queuedGateway.requests.length === 1 && hold.started, - "held active request for queued image yank", - ); - await session.sendText(`/image ${image}`); - await session.waitForText("attached image: queued-image.png", TIMEOUT); - await session.sendText(queuedPrompt); - await session.waitForPane( - (pane) => - pane.includes(queuedSummaryText(1)) && - !pane.includes(queuedPrompt), - TIMEOUT, - ); - rmSync(image); - - await session.sendKeys("C-c"); - await waitForCondition(() => hold.cancelled, "queued image active request cancellation"); - await session.waitForPane( - (pane) => - pane.includes(queuedPrompt) && - pane.includes("paused") && - pane.includes("enter to send"), - TIMEOUT, - ); - - await session.sendKeys("End"); - await session.sendKeys("C-u"); - await session.waitForPane( - (pane) => - !pane.includes(queuedPrompt) && - pane.includes("delete again to remove queued prompt"), - TIMEOUT, - ); - await session.sendKeys("C-k"); - await waitForCondition( - () => - existsSync(tracePath) && - readFileSync(tracePath, "utf8").includes( - "event=queue_review_draft_deleted", - ), - "empty queued image card deletion", - ); - - await session.sendKeys("C-y"); - await session.waitForPane( - (pane) => - pane.includes("[Image 2]") && - pane.includes("FXC141_QUEUED_IMAGE_YANK"), - TIMEOUT, - ); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - - await session.sendKeys("Enter"); - await session.waitForText(done, TIMEOUT); - await waitForCondition( - () => queuedGateway.requests.length === 2, - "yanked image Gateway request", - ); - - const yankedBody = queuedGateway.requests[1]!.body; - const trace = readFileSync(tracePath, "utf8"); - expect(yankedBody.match(/"type":"file"/g) ?? []).toHaveLength(1); - expect(yankedBody).toContain(expectedImageData); - expect(yankedBody).toContain("[Image #2]" + queuedPrompt); - expect(yankedBody).not.toContain(image); - expect(trace).toContain("event=queue_review_started"); - expect(trace).toContain("reason=post_cancel"); - expect(trace).toContain("event=queue_review_deleted"); - expect(trace).toContain("event=queue_review_draft_deleted"); - expect(readFileSync(stderrPath, "utf8")).toBe(""); - expect(session.isAlive()).toBe(true); - expect(session.isPaneAlive()).toBe(true); - }, - TIMEOUT * 2, - ); test( "second Ctrl+C exits after active stream cancellation", async () => { diff --git a/tests/e2e/tui-interrupt-recovery.test.ts b/tests/e2e/tui-interrupt-recovery.test.ts index 8b85df7aa..cbc7f8cfb 100644 --- a/tests/e2e/tui-interrupt-recovery.test.ts +++ b/tests/e2e/tui-interrupt-recovery.test.ts @@ -62,9 +62,9 @@ afterEach(async () => { describe.skipIf(SKIP)("tui: interrupt recovery", () => { test( - "submitted status text steers an active response without cancellation", + "submitted text interrupts a tool-free response and continues immediately", async () => { - root = realpathSync(mkdtempSync(join(tmpdir(), "fx-text-queues-"))); + root = realpathSync(mkdtempSync(join(tmpdir(), "fx-text-steering-"))); const home = join(root, "home"); const workspace = join(root, "workspace"); const stderrPath = join(root, "stderr.log"); @@ -79,10 +79,10 @@ describe.skipIf(SKIP)("tui: interrupt recovery", () => { cancelCount: 0, released: false, }; - const queuedText = "What are you doing right now?"; + const steeringText = "What are you doing right now?"; gateway = startFakeGateway([ () => heldUntilReleasedResponse(held), - fakeGatewayFinalText("QUEUED_STATUS_PROMPT_COMPLETE"), + fakeGatewayFinalText("STEERING_STATUS_PROMPT_COMPLETE"), ]); session = await TmuxSession.create({ cwd: realpathSync(workspace), @@ -106,31 +106,24 @@ describe.skipIf(SKIP)("tui: interrupt recovery", () => { await session.sendText("Hold this response until the test releases it."); await waitForCondition(() => held.started, "held response start"); - await session.sendText(queuedText); - await Bun.sleep(250); - - expect(gateway.requests).toHaveLength(1); - expect(held.cancelled).toBe(false); - expect(held.cancelCount).toBe(0); - expect(readTrace(tracePath)).not.toContain("event=interrupt_persisted"); - - held.release!(); - await session.waitForText("QUEUED_STATUS_PROMPT_COMPLETE", TIMEOUT); + await session.sendText(steeringText); + await waitForCondition(() => held.cancelled, "tool-free steering cancellation"); + await session.waitForText("STEERING_STATUS_PROMPT_COMPLETE", TIMEOUT); await waitForCondition( () => countOccurrences(readTrace(tracePath), "finish processing queued=0") >= 1, - "both queued turns to finish", + "steering continuation to finish", ); - expect(held.released).toBe(true); - expect(held.cancelCount).toBe(0); + expect(held.released).toBe(false); + expect(held.cancelCount).toBe(1); expect(gateway.requests).toHaveLength(2); - const queuedRequest = JSON.parse(gateway.requests[1]!.body) as { + const steeringRequest = JSON.parse(gateway.requests[1]!.body) as { prompt: unknown; tools: unknown[]; }; - const queuedPrompt = JSON.stringify(queuedRequest.prompt); - expect(queuedPrompt).toContain(queuedText); - expect(queuedRequest.tools.length).toBeGreaterThan(0); + const steeringPrompt = JSON.stringify(steeringRequest.prompt); + expect(steeringPrompt).toContain(steeringText); + expect(steeringRequest.tools.length).toBeGreaterThan(0); expect(gateway.requests[1]!.body).not.toContain( "Treat it as interrupting any previous tool plan.", ); @@ -151,12 +144,15 @@ describe.skipIf(SKIP)("tui: interrupt recovery", () => { eventsPath = readdirSync(sessionRoot, { withFileTypes: true }) .filter((entry) => entry.isDirectory()) .map((entry) => join(sessionRoot, entry.name, "events.jsonl")) - .find((path) => existsSync(path) && readFileSync(path, "utf8").includes(queuedText)); + .find((path) => existsSync(path) && readFileSync(path, "utf8").includes(steeringText)); return eventsPath !== undefined; }, "steering history persistence"); const events = readFileSync(eventsPath!, "utf8"); - expect(events).not.toContain('"kind":"interrupted"'); - expect(events).toContain(queuedText); + expect(events).toContain('"kind":"interrupted"'); + expect(events).toContain(steeringText); + const scrollback = await session.captureFullScrollback(); + expect(countOccurrences(scrollback, steeringText)).toBe(1); + expect(scrollback).not.toContain("cancelled"); }, TIMEOUT * 2, ); From ac10762bfb67c0a36f6d7c22ac467e49cfb2908e Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 19:50:42 -0400 Subject: [PATCH 07/14] Keep rich steering ordered across handoffs Batch transferable text guidance into a promoted turn while retaining image-bearing updates as ordered steering for their own turn. --- src/core/agent/worker_runtime.zig | 40 +++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/core/agent/worker_runtime.zig b/src/core/agent/worker_runtime.zig index bc8842fc7..66dfa3be3 100644 --- a/src/core/agent/worker_runtime.zig +++ b/src/core/agent/worker_runtime.zig @@ -637,7 +637,8 @@ pub const WorkerRuntime = struct { defer self.worker_mutex.unlock(io_mod.getIo()); const steering_pending = for (self.queued_prompts.items) |prompt| { - if (prompt.steer_target_turn_id == self.active_turn_id) break true; + if (prompt.steer_target_turn_id == self.active_turn_id or + prompt.steering_continuation) break true; } else false; const paused = if (steering_pending) false @@ -1245,7 +1246,7 @@ pub const WorkerRuntime = struct { self.active_turn_id = job.turn_id; if (job.steering_continuation) { for (self.queued_prompts.items) |*prompt| { - if (!prompt.steering_continuation) continue; + if (!prompt.steering_continuation or !sameTurnSteeringEligible(prompt.*)) continue; prompt.steer_target_turn_id = job.turn_id; prompt.steering_continuation = false; } @@ -3543,6 +3544,41 @@ test "promoted steering retargets remaining guidance without exposing a queue" { try std.testing.expectEqual(@as(usize, 0), runtime.queuedPromptCount()); } +test "promoted steering keeps rich trailing guidance for its own turn" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 9; + + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); + var rich = try makePrompt(alloc, "second with image", "model"); + var owns_rich = true; + errdefer if (owns_rich) freeQueuedPrompt(alloc, rich); + rich.images = try alloc.alloc(types.ImageAttachment, 1); + rich.images[0] = .{ + .path = try alloc.dupe(u8, "/tmp/trailing.png"), + .media_type = try alloc.dupe(u8, "image/png"), + }; + try runtime.admitInteractivePrompt(alloc, rich); + owns_rich = false; + runtime.finishProcessing(); + + const promoted = (try runtime.tryTakeNextPrompt(alloc)).?; + defer freeQueuedPrompt(alloc, promoted); + try std.testing.expect(promoted.steering_continuation); + try std.testing.expectEqual(@as(usize, 1), runtime.queuePreview().steering_count); + try std.testing.expect(!runtime.steeringHandoffRequired(promoted.turn_id)); + try std.testing.expect(!runtime.requestInteractiveCancel()); + try std.testing.expect(runtime.queueReviewReason() == null); + + runtime.finishProcessing(); + const trailing = (try runtime.tryTakeNextPrompt(alloc)).?; + defer freeQueuedPrompt(alloc, trailing); + try std.testing.expect(trailing.steering_continuation); + try std.testing.expectEqualStrings("second with image", trailing.prompt); +} + test "clear queued prompts also clears steering" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; From 530f7885be9f0443c1c5d767358ecba8a7697b92 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 23:02:33 -0400 Subject: [PATCH 08/14] Continue steering within the active turn --- src/core/agent/runtime/deps.zig | 3 + src/core/agent/runtime/orchestrator.zig | 220 +++++++++++++++--- .../agent/runtime/tests/interruption_flow.zig | 37 +++ src/core/agent/runtime/tests/support.zig | 14 ++ src/core/agent/worker_runtime.zig | 86 ++++++- src/core/app/app_callbacks.zig | 11 + src/core/session/session.zig | 127 +++++++++- .../e2e/tui-gateway-stream-lifecycle.test.ts | 9 + tests/e2e/tui-interrupt-recovery.test.ts | 8 +- 9 files changed, 480 insertions(+), 35 deletions(-) diff --git a/src/core/agent/runtime/deps.zig b/src/core/agent/runtime/deps.zig index aa9c2274b..0e0a15ad0 100644 --- a/src/core/agent/runtime/deps.zig +++ b/src/core/agent/runtime/deps.zig @@ -180,6 +180,9 @@ pub const AgentRuntimeDeps = struct { /// Drains user guidance admitted to the active turn. Returned text is owned /// by `arena` and is non-authoritative context, never permission evidence. take_steering: ?*const fn (ctx: *anyopaque, arena: Allocator, turn_id: u64) anyerror![]const []const u8 = null, + /// Drains guidance whose admission cancelled the current provider wait. + /// A non-empty result also clears that host-owned cancellation request. + take_immediate_steering: ?*const fn (ctx: *anyopaque, arena: Allocator, turn_id: u64) anyerror![]const []const u8 = null, /// True when pending interactive guidance cannot be transferred into the /// current request and must start after the active turn settles. steering_handoff_required: ?*const fn (ctx: *anyopaque, turn_id: u64) bool = null, diff --git a/src/core/agent/runtime/orchestrator.zig b/src/core/agent/runtime/orchestrator.zig index cbe4c1a6c..76791ad42 100644 --- a/src/core/agent/runtime/orchestrator.zig +++ b/src/core/agent/runtime/orchestrator.zig @@ -107,6 +107,46 @@ fn append_pending_steering_after_assistant( return true; } +fn append_immediate_steering_after_cancel( + deps: *const AgentRuntimeDeps, + arena: Allocator, + within_turn_suffix: *std.ArrayList(ChatMessage), + turn_id: u64, + assistant_text: []const u8, +) !bool { + const take_immediate_steering = deps.take_immediate_steering orelse return false; + const guidance = try take_immediate_steering(deps.ctx, arena, turn_id); + if (guidance.len == 0) return false; + + if (assistant_text.len > 0) { + try within_turn_suffix.append(arena, .{ + .role = .assistant, + .content = try arena.dupe(u8, assistant_text), + }); + } + for (guidance) |text| { + try within_turn_suffix.append(arena, .{ + .role = .user, + .content = try runtime_execution_memory.steeringMessage(arena, text), + }); + } + return true; +} + +fn reset_recovery_after_immediate_steering( + latest_diagnostic: *?types.ModelFailureDiagnostic, + strategy: *?model_response_recovery.Strategy, + cause: *model_response_recovery.FailureCause, + pacing: *model_response_recovery.RetryPacingState, + tool_evidence: *model_response_recovery.ToolEvidence, +) void { + latest_diagnostic.* = null; + strategy.* = null; + cause.* = .transport_interrupted; + pacing.* = .idle; + tool_evidence.* = .none; +} + fn request_union_schema_advertised( advertised_functions: []const model_tool_schema.FunctionSchema, tool_name: []const u8, @@ -3821,8 +3861,45 @@ fn processQueuedPromptInner( config.fast_mode, request_capabilities, )) { - request_capabilities = deps.resolve_model_capabilities(deps.ctx, arena, job.model) catch |err| { - if (err != error.Cancelled) return err; + resolve_capabilities: while (true) { + request_capabilities = deps.resolve_model_capabilities(deps.ctx, arena, job.model) catch |err| { + if (err != error.Cancelled) return err; + if (try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + "", + )) continue :resolve_capabilities; + runtime_telemetry.traceCancelObserved(finish_trace.ctx, false); + var terminal_materializing = false; + try runtime_interruption.persistInterruptedTurnOnce( + deps, + finalization, + job, + null, + null, + completed_tool_names.items, + &interrupted_persisted, + finish_trace.ctx, + within_turn_suffix.items, + null, + &terminal_materializing, + ); + finish_trace.finish("interrupted"); + return; + }; + break :resolve_capabilities; + } + } + if (config.cancel_flag.load(.seq_cst)) { + if (!try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + "", + )) { runtime_telemetry.traceCancelObserved(finish_trace.ctx, false); var terminal_materializing = false; try runtime_interruption.persistInterruptedTurnOnce( @@ -3840,26 +3917,7 @@ fn processQueuedPromptInner( ); finish_trace.finish("interrupted"); return; - }; - } - if (config.cancel_flag.load(.seq_cst)) { - runtime_telemetry.traceCancelObserved(finish_trace.ctx, false); - var terminal_materializing = false; - try runtime_interruption.persistInterruptedTurnOnce( - deps, - finalization, - job, - null, - null, - completed_tool_names.items, - &interrupted_persisted, - finish_trace.ctx, - within_turn_suffix.items, - null, - &terminal_materializing, - ); - finish_trace.finish("interrupted"); - return; + } } const history_messages_before = stable_prefix.items.len; const interrupted_turns = runtime_interruption.countInterruptedHistory(job.history); @@ -3872,12 +3930,21 @@ fn processQueuedPromptInner( "history_turns={d} gateway_messages_before={d} interrupted_turns={d} history_turn_kinds={s}", .{ job.history.len, history_messages_before, interrupted_turns, history_turn_kinds }, ); - try session_runtime.appendHistoryChatMessagesBudgeted( - arena, - &history_messages, - job.history, - .{ .max_tokens = runtime_prompt_context.historyContextBudgetTokensForCapabilities(request_capabilities) }, - ); + if (job.steering_continuation) { + try session_runtime.appendSteeringContinuationHistoryChatMessagesBudgeted( + arena, + &history_messages, + job.history, + .{ .max_tokens = runtime_prompt_context.historyContextBudgetTokensForCapabilities(request_capabilities) }, + ); + } else { + try session_runtime.appendHistoryChatMessagesBudgeted( + arena, + &history_messages, + job.history, + .{ .max_tokens = runtime_prompt_context.historyContextBudgetTokensForCapabilities(request_capabilities) }, + ); + } const projected_roles = try runtime_telemetry.formatMessageRoles(arena, history_messages.items); debug_trace.eventf( "history", @@ -4349,7 +4416,7 @@ fn processQueuedPromptLoop( .none; var restore_recovery_source = job.recovery_checkpoint != null; var step: usize = 0; - while (agent_steps.allowsStep(config.agent_step_limit, step)) : (step += 1) { + agent_steps_loop: while (agent_steps.allowsStep(config.agent_step_limit, step)) : (step += 1) { current_step_index = step + 1; const step_ctx: TraceContext = .{ .turn_id = turn_id, .step_id = debug_trace.nextStepId(), .subagent_id = config.subagent_id }; const presentation_group_id = runtime_tool_presentation.presentationGroupForStep( @@ -4359,6 +4426,13 @@ fn processQueuedPromptLoop( ); last_step_ctx = step_ctx; if (config.cancel_flag.load(.seq_cst)) { + if (try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + "", + )) continue :agent_steps_loop; runtime_telemetry.traceCancelObserved(step_ctx, false); try runtime_interruption.persistInterruptedTurnOnce(deps, finalization, job, null, null, completed_tool_names.items, &interrupted_persisted, step_ctx, within_turn_suffix.items, stop_state.retained_candidate, &stop_state.terminal_materializing); finish_trace.finish("interrupted"); @@ -5000,6 +5074,26 @@ fn processQueuedPromptLoop( arena, turn_id, ); + try runtime_assistant_stream.flushAssistantStream(&stream_ctx); + if (try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + stream_ctx.raw_text.items, + )) { + reset_recovery_after_immediate_steering( + &latest_recovery_diagnostic, + &recovery_strategy, + &recovery_cause, + &retry_pacing, + &preserved_tool_evidence, + ); + if (stream_ctx.raw_text.items.len > 0) { + try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" }); + } + continue :agent_steps_loop; + } try runtime_interruption.persistInterruptedTurnOnce(deps, finalization, job, stream_ctx.raw_text.items, null, completed_tool_names.items, &interrupted_persisted, step_ctx, within_turn_suffix.items, stop_state.retained_candidate, &stop_state.terminal_materializing); finish_trace.finish("interrupted"); return; @@ -5461,6 +5555,25 @@ fn processQueuedPromptLoop( response_completion.tool_calls, advertised_dynamic_tool_names, ); + if (try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + stream_ctx.raw_text.items, + )) { + reset_recovery_after_immediate_steering( + &latest_recovery_diagnostic, + &recovery_strategy, + &recovery_cause, + &retry_pacing, + &preserved_tool_evidence, + ); + if (stream_ctx.raw_text.items.len > 0) { + try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" }); + } + continue :agent_steps_loop; + } try runtime_interruption.persistInterruptedTurnOnce(deps, finalization, job, stream_ctx.raw_text.items, null, completed_tool_names.items, &interrupted_persisted, step_ctx, within_turn_suffix.items, stop_state.retained_candidate, &stop_state.terminal_materializing); finish_trace.finish("interrupted"); return; @@ -5495,6 +5608,25 @@ fn processQueuedPromptLoop( attempt_completion.tool_calls, advertised_dynamic_tool_names, ); + if (try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + partial_assistant, + )) { + reset_recovery_after_immediate_steering( + &latest_recovery_diagnostic, + &recovery_strategy, + &recovery_cause, + &retry_pacing, + &preserved_tool_evidence, + ); + if (partial_assistant.len > 0) { + try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" }); + } + continue :agent_steps_loop; + } try runtime_interruption.persistInterruptedTurnOnce(deps, finalization, job, partial_assistant, null, completed_tool_names.items, &interrupted_persisted, step_ctx, within_turn_suffix.items, stop_state.retained_candidate, &stop_state.terminal_materializing); finish_trace.finish("interrupted"); return; @@ -5671,6 +5803,25 @@ fn processQueuedPromptLoop( attempt_completion.tool_calls, advertised_dynamic_tool_names, ); + if (try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + partial_assistant, + )) { + reset_recovery_after_immediate_steering( + &latest_recovery_diagnostic, + &recovery_strategy, + &recovery_cause, + &retry_pacing, + &preserved_tool_evidence, + ); + if (partial_assistant.len > 0) { + try deps.push_text(deps.ctx, .{ .assistant_rendered = "\n" }); + } + continue :agent_steps_loop; + } try runtime_interruption.persistInterruptedTurnOnce(deps, finalization, job, partial_assistant, null, completed_tool_names.items, &interrupted_persisted, step_ctx, within_turn_suffix.items, stop_state.retained_candidate, &stop_state.terminal_materializing); finish_trace.finish("interrupted"); return; @@ -6196,6 +6347,17 @@ fn processQueuedPromptLoop( }, ) catch |err| switch (err) { error.Cancelled => { + if (try append_immediate_steering_after_cancel( + deps, + arena, + &within_turn_suffix, + turn_id, + history_text, + )) { + stop_state.retained_candidate = null; + stop_state.latest_partial = null; + continue :agent_steps_loop; + } runtime_telemetry.traceCancelObserved(step_ctx, false); try runtime_interruption.persistInterruptedTurnOnce( deps, diff --git a/src/core/agent/runtime/tests/interruption_flow.zig b/src/core/agent/runtime/tests/interruption_flow.zig index 2ae960da8..614e32b2c 100644 --- a/src/core/agent/runtime/tests/interruption_flow.zig +++ b/src/core/agent/runtime/tests/interruption_flow.zig @@ -170,6 +170,43 @@ test "processQueuedPrompt persists partial-text cancellation as interrupted once try std.testing.expect(hooks.history_turns.items[0].interrupted.tool_call == null); } +test "automatic text steering resumes the same turn without interrupted history" { + const alloc = std.testing.allocator; + const chunks = [_][]const u8{"Partial answer"}; + const completions = [_]FakeCompletion{ + .{ .chunks = &chunks, .cancel_after_chunks = true }, + .{ .content = "Updated answer" }, + }; + var gateway = FakeGateway.init(alloc, &completions); + defer gateway.deinit(); + const steering = [_][]const u8{ "first update", "second update" }; + var hooks = FakeAgentRuntimeDeps.init(alloc); + defer hooks.deinit(); + var fixture = PromptFixture{}; + hooks.immediate_steering_messages = &steering; + hooks.immediate_steering_cancel_flag = &fixture.cancel_flag; + + try runFakePrompt(&gateway, &hooks, fixture.config(), fixture.job()); + + try std.testing.expectEqual(@as(usize, 2), gateway.request_bodies.items.len); + try expectBodyContainsInOrder(&gateway, 1, &.{ + "Partial answer", + "user_steering", + "first update", + "user_steering", + "second update", + }); + try std.testing.expectEqual(@as(usize, 0), hooks.interrupted_history_count); + try std.testing.expectEqual(@as(usize, 0), hooks.interrupted_event_count); + try std.testing.expectEqual(@as(usize, 1), hooks.history_turns.items.len); + try std.testing.expect(hooks.history_turns.items[0] == .assistant); + try std.testing.expectEqualStrings("Updated answer", hooks.finish_assistant_text.?); + const execution = hooks.history_turns.items[0].assistant.execution; + try std.testing.expectEqual(@as(usize, 2), execution.steering.len); + try std.testing.expectEqualStrings("first update", execution.steering[0]); + try std.testing.expectEqualStrings("second update", execution.steering[1]); +} + test "streamed presentation preserves raw partial through cancellation" { const alloc = std.testing.allocator; const invisible_chunks = [_][]const u8{" \t\r\n"}; diff --git a/src/core/agent/runtime/tests/support.zig b/src/core/agent/runtime/tests/support.zig index 8f03d56f1..e0640c6d1 100644 --- a/src/core/agent/runtime/tests/support.zig +++ b/src/core/agent/runtime/tests/support.zig @@ -663,6 +663,9 @@ pub const FakeAgentRuntimeDeps = struct { steering_messages: []const []const u8 = &.{}, steering_take_at: usize = 1, steering_take_count: usize = 0, + immediate_steering_messages: []const []const u8 = &.{}, + immediate_steering_cancel_flag: ?*std.atomic.Value(bool) = null, + immediate_steering_take_count: usize = 0, pub fn init(alloc: Allocator) FakeAgentRuntimeDeps { return .{ .alloc = alloc }; @@ -778,6 +781,7 @@ pub const FakeAgentRuntimeDeps = struct { .available_model_capabilities = availableModelCapabilities, .resolve_model_capabilities = resolveModelCapabilities, .take_steering = if (self.steering_messages.len > 0) takeSteering else null, + .take_immediate_steering = if (self.immediate_steering_messages.len > 0) takeImmediateSteering else null, .format_tool_execution_error = formatError, .record_tool_call_rejected = recordRejected, .report_inner_tool_usage = reportCapturedInnerToolUsage, @@ -867,6 +871,16 @@ pub const FakeAgentRuntimeDeps = struct { return messages; } + fn takeImmediateSteering(raw: *anyopaque, arena: Allocator, _: u64) ![]const []const u8 { + const self: *FakeAgentRuntimeDeps = @ptrCast(@alignCast(raw)); + self.immediate_steering_take_count += 1; + if (self.immediate_steering_take_count != 1) return &.{}; + const messages = try arena.alloc([]const u8, self.immediate_steering_messages.len); + @memcpy(messages, self.immediate_steering_messages); + if (self.immediate_steering_cancel_flag) |flag| flag.store(false, .seq_cst); + return messages; + } + fn requestRouteRecovery(raw: *anyopaque, _: Allocator, request: runtime_deps.RouteRecoveryRequest) !runtime_deps.RouteRecoveryDecision { const self: *FakeAgentRuntimeDeps = @ptrCast(@alignCast(raw)); self.route_recovery_count += 1; diff --git a/src/core/agent/worker_runtime.zig b/src/core/agent/worker_runtime.zig index 66dfa3be3..79589fd0e 100644 --- a/src/core/agent/worker_runtime.zig +++ b/src/core/agent/worker_runtime.zig @@ -558,6 +558,9 @@ pub const WorkerRuntime = struct { worker_stop_requested: bool = false, finalization_failure: ?FinalizationFailure = null, worker_cancel_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), + /// The active turn whose text steering admission owns the cancel flag. + /// Guarded by `worker_mutex`; explicit cancellation clears this ownership. + steering_cancel_turn_id: ?u64 = null, worker_recovery_pause_requested: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), worker_connectivity_wait_active: std.atomic.Value(bool) = std.atomic.Value(bool).init(false), /// Set under `worker_mutex` once the active turn publishes its terminal @@ -613,14 +616,14 @@ pub const WorkerRuntime = struct { } pub fn requestShutdown(self: *WorkerRuntime) void { - self.worker_cancel_requested.store(true, .seq_cst); + self.markExplicitCancellation(); self.requestStop(); } pub fn requestCancel(self: *WorkerRuntime) void { debug_trace.logf("worker", "cancel requested processing={s} queued={d}", .{ if (self.worker_processing) "true" else "false", self.queuedPromptCount() }); debug_trace.eventf("interrupt", "cancel_requested", .{}, "processing={s} queued={d} active_tool_known=false", .{ if (self.worker_processing) "true" else "false", self.queuedPromptCount() }); - self.worker_cancel_requested.store(true, .seq_cst); + self.markExplicitCancellation(); } /// Interrupt the current gateway wait while preserving its recovery @@ -629,6 +632,13 @@ pub const WorkerRuntime = struct { pub fn requestRecoveryPause(self: *WorkerRuntime) void { debug_trace.eventf("recovery", "pause_requested", .{}, "source=interactive_try_later", .{}); self.worker_recovery_pause_requested.store(true, .seq_cst); + self.markExplicitCancellation(); + } + + fn markExplicitCancellation(self: *WorkerRuntime) void { + self.worker_mutex.lockUncancelable(io_mod.getIo()); + defer self.worker_mutex.unlock(io_mod.getIo()); + self.steering_cancel_turn_id = null; self.worker_cancel_requested.store(true, .seq_cst); } @@ -656,6 +666,7 @@ pub const WorkerRuntime = struct { if (steering_pending) "true" else "false", if (paused) "true" else "false", }); + self.steering_cancel_turn_id = null; self.worker_cancel_requested.store(true, .seq_cst); return paused; } @@ -665,6 +676,7 @@ pub const WorkerRuntime = struct { defer self.worker_mutex.unlock(io_mod.getIo()); _ = self.beginQueueReviewLocked(.post_cancel); + self.steering_cancel_turn_id = null; self.worker_cancel_requested.store(true, .seq_cst); _ = self.resolvePendingPermissionLocked( permission_request.OwnedPermissionResponse.init( @@ -780,6 +792,7 @@ pub const WorkerRuntime = struct { ); } self.worker_stop_requested = true; + self.steering_cancel_turn_id = null; self.worker_cancel_requested.store(true, .seq_cst); self.worker_cond.broadcast(io_mod.getIo()); } @@ -850,6 +863,9 @@ pub const WorkerRuntime = struct { } try self.enqueuePromptLocked(alloc, queued); if (interrupt_after_admission) { + if (sameTurnSteeringEligible(queued)) { + self.steering_cancel_turn_id = self.active_turn_id; + } self.worker_cancel_requested.store(true, .seq_cst); } } @@ -906,6 +922,31 @@ pub const WorkerRuntime = struct { return &.{}; } + return self.takeSteeringLocked(alloc, turn_id); + } + + /// Consumes only text steering that owns the current provider cancellation. + /// A successful drain atomically returns the cancel flag to the active turn. + pub fn takeImmediateSteering(self: *WorkerRuntime, alloc: std.mem.Allocator, turn_id: u64) ![][]u8 { + self.worker_mutex.lockUncancelable(io_mod.getIo()); + defer self.worker_mutex.unlock(io_mod.getIo()); + if (!self.worker_processing or + self.active_turn_id != turn_id or + self.queue_admission != null or + self.steering_cancel_turn_id != turn_id or + !self.worker_cancel_requested.load(.seq_cst)) + { + return &.{}; + } + + const messages = try self.takeSteeringLocked(alloc, turn_id); + if (messages.len == 0) return &.{}; + self.steering_cancel_turn_id = null; + self.worker_cancel_requested.store(false, .seq_cst); + return messages; + } + + fn takeSteeringLocked(self: *WorkerRuntime, alloc: std.mem.Allocator, turn_id: u64) ![][]u8 { var steering_count: usize = 0; for (self.queued_prompts.items) |prompt| { if (prompt.steer_target_turn_id != turn_id) continue; @@ -1238,6 +1279,7 @@ pub const WorkerRuntime = struct { freeQueueReviewDraftOpt(alloc, job.review_draft); job.review_draft = null; if (self.queued_prompt_count > 0) self.queued_prompt_count -= 1; + self.steering_cancel_turn_id = null; self.worker_cancel_requested.store(false, .seq_cst); self.worker_recovery_pause_requested.store(false, .seq_cst); self.worker_connectivity_wait_active.store(false, .seq_cst); @@ -1280,6 +1322,7 @@ pub const WorkerRuntime = struct { } self.worker_processing = false; self.active_turn_id = 0; + self.steering_cancel_turn_id = null; self.clearActiveToolCallsLocked(); self.worker_connectivity_wait_active.store(false, .seq_cst); self.worker_cond.broadcast(io_mod.getIo()); @@ -3355,6 +3398,45 @@ test "active prompt admission drains steering in FIFO order" { try std.testing.expectEqualStrings("second", runtime.worker_events.items[1].append_user_feedback); } +test "immediate steering owns and clears only its cancellation" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "second", "model")); + try std.testing.expect(runtime.isCancelRequested()); + + const guidance = try runtime.takeImmediateSteering(alloc, 41); + defer { + for (guidance) |text| alloc.free(text); + alloc.free(guidance); + } + try std.testing.expectEqual(@as(usize, 2), guidance.len); + try std.testing.expectEqualStrings("first", guidance[0]); + try std.testing.expectEqualStrings("second", guidance[1]); + try std.testing.expect(!runtime.isCancelRequested()); + try std.testing.expectEqual(@as(usize, 0), runtime.queuedPromptCount()); +} + +test "explicit interrupt overrides immediate steering cancellation" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "steer", "model")); + _ = runtime.requestInteractiveCancel(); + + const guidance = try runtime.takeImmediateSteering(alloc, 41); + try std.testing.expectEqual(@as(usize, 0), guidance.len); + try std.testing.expect(runtime.isCancelRequested()); + try std.testing.expectEqual(@as(usize, 1), runtime.queuedPromptCount()); +} + test "tool lifecycle decides whether interactive steering interrupts immediately" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; diff --git a/src/core/app/app_callbacks.zig b/src/core/app/app_callbacks.zig index 032bc74e2..d6cb7da83 100644 --- a/src/core/app/app_callbacks.zig +++ b/src/core/app/app_callbacks.zig @@ -285,6 +285,7 @@ pub fn Bindings(comptime App: type) type { null, .finalize_turn = agentFinalizeTurn, .take_steering = if (comptime @hasDecl(@TypeOf(app.worker), "takeSteering")) agentTakeSteering else null, + .take_immediate_steering = if (comptime @hasDecl(@TypeOf(app.worker), "takeImmediateSteering")) agentTakeImmediateSteering else null, .steering_handoff_required = if (comptime @hasDecl(@TypeOf(app.worker), "steeringHandoffRequired")) agentSteeringHandoffRequired else null, .append_runtime_context = agentAppendRuntimeContext, .append_static_context = agentAppendStaticContext, @@ -566,6 +567,16 @@ pub fn Bindings(comptime App: type) type { fn agentTakeSteering(ctx: *anyopaque, arena: std.mem.Allocator, turn_id: u64) ![]const []const u8 { const app: *App = @ptrCast(@alignCast(ctx)); const owned = try app.worker.takeSteering(std.heap.c_allocator, turn_id); + return copyOwnedSteering(arena, owned); + } + + fn agentTakeImmediateSteering(ctx: *anyopaque, arena: std.mem.Allocator, turn_id: u64) ![]const []const u8 { + const app: *App = @ptrCast(@alignCast(ctx)); + const owned = try app.worker.takeImmediateSteering(std.heap.c_allocator, turn_id); + return copyOwnedSteering(arena, owned); + } + + fn copyOwnedSteering(arena: std.mem.Allocator, owned: [][]u8) ![]const []const u8 { if (owned.len == 0) return &.{}; defer { for (owned) |text| std.heap.c_allocator.free(text); diff --git a/src/core/session/session.zig b/src/core/session/session.zig index 5ef47ed5e..18531684f 100644 --- a/src/core/session/session.zig +++ b/src/core/session/session.zig @@ -1917,7 +1917,7 @@ pub const SessionRuntime = struct { messages: *std.ArrayList(core_types.ChatMessage), history: []const HistoryTurn, ) !void { - _ = try appendHistoryChatMessagesImpl(alloc, messages, history, true); + _ = try appendHistoryChatMessagesImpl(alloc, messages, history, true, .closed); } pub fn setConversationLanguageFromUserMessage(self: *SessionRuntime, text: []const u8) void { @@ -2410,7 +2410,7 @@ pub fn appendHistoryChatMessages( messages: *std.ArrayList(core_types.ChatMessage), history: []const HistoryTurn, ) !void { - _ = try appendHistoryChatMessagesImpl(alloc, messages, history, true); + _ = try appendHistoryChatMessagesImpl(alloc, messages, history, true, .closed); } pub const HistoryBudgetOptions = struct { @@ -2460,9 +2460,53 @@ pub fn appendHistoryChatMessagesBudgeted( messages: *std.ArrayList(core_types.ChatMessage), history: []const HistoryTurn, opts: HistoryBudgetOptions, +) !void { + return appendHistoryChatMessagesBudgetedImpl( + alloc, + messages, + history, + opts, + .closed, + ); +} + +/// Projects a handoff turn without telling the model that the user aborted it. +/// Stored execution evidence remains unchanged; only the trailing interruption +/// closure is omitted because the current prompt already carries steering intent. +pub fn appendSteeringContinuationHistoryChatMessagesBudgeted( + alloc: Allocator, + messages: *std.ArrayList(core_types.ChatMessage), + history: []const HistoryTurn, + opts: HistoryBudgetOptions, +) !void { + return appendHistoryChatMessagesBudgetedImpl( + alloc, + messages, + history, + opts, + .steering_continuation, + ); +} + +const InterruptedChatProjection = enum { + closed, + steering_continuation, +}; + +fn appendHistoryChatMessagesBudgetedImpl( + alloc: Allocator, + messages: *std.ArrayList(core_types.ChatMessage), + history: []const HistoryTurn, + opts: HistoryBudgetOptions, + trailing_interrupted_projection: InterruptedChatProjection, ) !void { const keep = try selectBudgetedHistoryTurns(alloc, history, opts) orelse - return appendHistoryChatMessages(alloc, messages, history); + return appendHistoryChatMessagesWithTrailingProjection( + alloc, + messages, + history, + trailing_interrupted_projection, + ); defer alloc.free(keep); const trimmed_context = try formatBudgetTrimmedHistoryContext(alloc, history, keep); @@ -2480,10 +2524,44 @@ pub fn appendHistoryChatMessagesBudgeted( messages, history[idx .. idx + 1], in_leading_summary_prefix, + if (idx + 1 == history.len) + trailing_interrupted_projection + else + .closed, ); } } +fn appendHistoryChatMessagesWithTrailingProjection( + alloc: Allocator, + messages: *std.ArrayList(core_types.ChatMessage), + history: []const HistoryTurn, + trailing_interrupted_projection: InterruptedChatProjection, +) !void { + if (history.len == 0 or trailing_interrupted_projection == .closed) { + _ = try appendHistoryChatMessagesImpl(alloc, messages, history, true, .closed); + return; + } + + var in_leading_summary_prefix = true; + if (history.len > 1) { + in_leading_summary_prefix = try appendHistoryChatMessagesImpl( + alloc, + messages, + history[0 .. history.len - 1], + true, + .closed, + ); + } + _ = try appendHistoryChatMessagesImpl( + alloc, + messages, + history[history.len - 1 ..], + in_leading_summary_prefix, + trailing_interrupted_projection, + ); +} + fn continuesLeadingSummaryPrefix(in_leading_summary_prefix: bool, turn: HistoryTurn) bool { return in_leading_summary_prefix and turn == .compacted_summary; } @@ -2684,6 +2762,7 @@ fn appendHistoryChatMessagesImpl( messages: *std.ArrayList(core_types.ChatMessage), history: []const HistoryTurn, starts_in_leading_summary_prefix: bool, + interrupted_projection: InterruptedChatProjection, ) !bool { var in_leading_summary_prefix = starts_in_leading_summary_prefix; for (history) |turn| { @@ -2721,10 +2800,17 @@ fn appendHistoryChatMessagesImpl( .tool_name = tool_call.name, .tool_result_status = .failure, }); + } else if (interrupted_projection == .steering_continuation) { + if (entry.assistant) |assistant| { + if (assistant.len > 0) { + try messages.append(alloc, .{ .role = .assistant, .content = assistant }); + } + } } else { const assistant_content = try formatInterruptedAssistantClosedContent(alloc, entry); try messages.append(alloc, .{ .role = .assistant, .content = assistant_content }); } + if (interrupted_projection == .steering_continuation) continue; const text = try formatInterruptedHistoryContext(alloc, entry); errdefer alloc.free(text); try messages.append(alloc, .{ .role = .user, .content = text }); @@ -4420,6 +4506,41 @@ test "no-output interrupted history projects synthetic assistant before marker" try std.testing.expect(std.mem.find(u8, chat_messages.items[2].content.?, "user interrupted") == null); } +test "steering continuation omits only the trailing interrupted closure" { + const alloc = std.testing.allocator; + const history = [_]HistoryTurn{ + .{ .interrupted = .{ + .user = .{ .text = @constCast("older interrupted request") }, + .assistant = @constCast("older partial"), + } }, + .{ .interrupted = .{ + .user = .{ .text = @constCast("active request") }, + .assistant = @constCast("active partial"), + } }, + }; + + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var messages: std.ArrayList(core_types.ChatMessage) = .empty; + defer messages.deinit(arena); + + try appendSteeringContinuationHistoryChatMessagesBudgeted( + arena, + &messages, + &history, + .{}, + ); + + try std.testing.expectEqual(@as(usize, 5), messages.items.len); + try std.testing.expect(std.mem.find(u8, messages.items[2].content.?, "") != null); + try std.testing.expectEqual(core_types.ChatRole.user, messages.items[3].role); + try std.testing.expectEqualStrings("active request", messages.items[3].content.?); + try std.testing.expectEqual(core_types.ChatRole.assistant, messages.items[4].role); + try std.testing.expectEqualStrings("active partial", messages.items[4].content.?); + try std.testing.expect(std.mem.find(u8, messages.items[4].content.?, interrupted_before_completion_output) == null); +} + test "partial-text interrupted history projects partial assistant with closure before marker" { const alloc = std.testing.allocator; const expected_assistant = diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index 2d9cd8886..388029549 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -3112,6 +3112,11 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { expect(continuedBody).toContain("RICH_STEERING_TOOL_DONE"); expect(continuedBody).toContain(""); expect(continuedBody).toContain(steering); + expect(continuedBody).not.toContain(""); + expect(continuedBody).not.toContain( + "The previous response ended before completion.", + ); + expect(continuedBody).not.toContain("Interrupted by user after completing"); const trace = readFileSync(tracePath, "utf8"); expect(trace).toContain("outcome_kind=steering_handoff"); expect(trace).not.toContain("event=queue_review_started"); @@ -3397,6 +3402,10 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { data: expectedImageData, }]); expect(steeringBody).toContain(""); + expect(steeringBody).not.toContain(""); + expect(steeringBody).not.toContain( + "The previous response ended before completion.", + ); expect(countOccurrences(steeringBody, oldGlobalRule)).toBe(1); expect(countOccurrences(steeringBody, oldAncestorRule)).toBe(1); expect(countOccurrences(steeringBody, oldRootRule)).toBe(1); diff --git a/tests/e2e/tui-interrupt-recovery.test.ts b/tests/e2e/tui-interrupt-recovery.test.ts index cbc7f8cfb..a9620d063 100644 --- a/tests/e2e/tui-interrupt-recovery.test.ts +++ b/tests/e2e/tui-interrupt-recovery.test.ts @@ -134,6 +134,11 @@ describe.skipIf(SKIP)("tui: interrupt recovery", () => { expect(gateway.requests[1]!.body).toContain( "Apply this live user update to the current task.", ); + expect(gateway.requests[1]!.body).toContain("ACTIVE_RESPONSE_HELD"); + expect(gateway.requests[1]!.body).not.toContain(""); + expect(gateway.requests[1]!.body).not.toContain( + "The previous response ended before completion.", + ); expect(readFileSync(stderrPath, "utf8")).toBe(""); expect(session.isAlive()).toBe(true); expect(session.isPaneAlive()).toBe(true); @@ -148,10 +153,11 @@ describe.skipIf(SKIP)("tui: interrupt recovery", () => { return eventsPath !== undefined; }, "steering history persistence"); const events = readFileSync(eventsPath!, "utf8"); - expect(events).toContain('"kind":"interrupted"'); + expect(events).not.toContain('"kind":"interrupted"'); expect(events).toContain(steeringText); const scrollback = await session.captureFullScrollback(); expect(countOccurrences(scrollback, steeringText)).toBe(1); + expect(countOccurrences(scrollback, "ACTIVE_RESPONSE_HELD")).toBe(1); expect(scrollback).not.toContain("cancelled"); }, TIMEOUT * 2, From fce8194f9eec567c294b27c1dbd3932bac243bee Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 23:39:03 -0400 Subject: [PATCH 09/14] Align steering integration tests --- sdk/tests/test-term.mjs | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/sdk/tests/test-term.mjs b/sdk/tests/test-term.mjs index 4b6de9532..0b10b77fe 100644 --- a/sdk/tests/test-term.mjs +++ b/sdk/tests/test-term.mjs @@ -153,17 +153,19 @@ while (pendingVisibleAt === undefined) { await new Promise((resolve) => setTimeout(resolve, 10)); } observeZeroTimeouts = false; -if (secondRequestAt !== undefined) throw new Error("steering started before the active response finished"); -releaseFirstStream(); -while (streamFinishedAt === undefined) { - if (performance.now() >= deadline) throw new Error("timed out waiting for streamed fx-term response"); - await new Promise((resolve) => setTimeout(resolve, 10)); -} const steeringDeadline = performance.now() + 5000; -while (secondRequestAt === undefined || !streamedText.includes(steeringAnswer)) { +while ( + secondRequestAt === undefined || + !streamedText.includes(steeringAnswer) +) { if (performance.now() >= steeringDeadline) throw new Error("timed out waiting for steered fx-term response"); await new Promise((resolve) => setTimeout(resolve, 10)); } +releaseFirstStream(); +while (streamFinishedAt === undefined) { + if (performance.now() >= steeringDeadline) throw new Error("timed out releasing held fx-term response"); + await new Promise((resolve) => setTimeout(resolve, 10)); +} runtime.write("/exit\r"); const exitCode = await Promise.race([ runtime.exited, @@ -176,12 +178,13 @@ if (exitCode !== 0) throw new Error(`fx-term exited with code ${exitCode}`); if (!text.includes("𝒇x")) throw new Error("shared fx welcome frame was not observed"); if (!text.includes("Run /help for commands")) throw new Error("shared fx welcome guidance was not observed"); if (requestedModel !== "sdk/term-model") throw new Error(`terminal prompt did not use the host-restored model: ${requestedModel}`); -if (!(streamStartedAt < streamFinishedAt)) throw new Error("terminal fetch did not remain active for continuous streaming"); -if (!(draftVisibleAt < streamFinishedAt)) throw new Error("terminal rendered follow-up input only after continuous streaming finished"); -if (!(pendingVisibleAt < streamFinishedAt)) throw new Error("terminal showed pending steering only after continuous streaming finished"); -if (!(secondRequestAt >= streamFinishedAt)) throw new Error("terminal started steering before continuous streaming finished"); +if (!(streamStartedAt < secondRequestAt)) throw new Error("terminal started steering before the active response"); +if (!(draftVisibleAt < secondRequestAt)) throw new Error("terminal rendered follow-up input only after steering started"); +if (!(pendingVisibleAt < secondRequestAt)) throw new Error("terminal showed pending steering only after steering started"); +if (!(secondRequestAt < streamFinishedAt)) throw new Error("terminal waited for the active response before steering"); const steeringUser = secondRequestBody.prompt?.filter((message) => message.role === "user").at(-1); const steeringText = steeringUser?.content?.filter((part) => part.type === "text").map((part) => part.text); +const steeringRequest = JSON.stringify(secondRequestBody.prompt); if ( steeringText?.length !== 1 || !steeringText[0].includes("") || @@ -190,6 +193,10 @@ if ( ) { throw new Error(`steering request changed the submitted draft or directive: ${JSON.stringify(steeringText)}`); } +if (!steeringRequest.includes("hello")) throw new Error("steering request omitted the visible assistant prefix"); +if (steeringRequest.includes("") || steeringRequest.includes("The previous response ended before completion.")) { + throw new Error("steering request included an interruption marker"); +} if (requestCount !== 2) throw new Error(`terminal sent ${requestCount} requests instead of the active and steered steps`); if (zeroTimeoutCount !== 0) throw new Error(`terminal allocated ${zeroTimeoutCount} zero-timeout poll timer(s)`); if (!events.some((event) => event.type === "config.restore" && event.configId === "model")) throw new Error("terminal model restore event was not emitted"); From 1aac0d20db96fecf39430b74f5ebc03b93e9d272 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 00:51:13 -0400 Subject: [PATCH 10/14] Preserve steering in resumed model context --- src/core/agent/runtime/tests/gateway_flow.zig | 24 ++++++++++ src/core/session/session.zig | 48 +++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/src/core/agent/runtime/tests/gateway_flow.zig b/src/core/agent/runtime/tests/gateway_flow.zig index da2a276cb..bd753393c 100644 --- a/src/core/agent/runtime/tests/gateway_flow.zig +++ b/src/core/agent/runtime/tests/gateway_flow.zig @@ -243,6 +243,30 @@ test "terminal assistant completion continues with steering admitted during the const execution = hooks.history_turns.items[0].assistant.execution; try std.testing.expectEqual(@as(usize, 1), execution.steering.len); try std.testing.expectEqualStrings("change direction", execution.steering[0]); + + const resumed_completions = [_]FakeCompletion{.{ .content = "Follow-up answer" }}; + var resumed_gateway = FakeGateway.init(alloc, &resumed_completions); + defer resumed_gateway.deinit(); + var resumed_hooks = FakeAgentRuntimeDeps.init(alloc); + defer resumed_hooks.deinit(); + var resumed_fixture = PromptFixture{}; + var resumed_job = resumed_fixture.job(); + resumed_job.prompt = @constCast("follow up"); + resumed_job.history = hooks.history_turns.items; + + try runFakePrompt( + &resumed_gateway, + &resumed_hooks, + resumed_fixture.config(), + resumed_job, + ); + + try expectBodyContainsInOrder(&resumed_gateway, 0, &.{ + "user prompt", + "change direction", + "Updated answer", + "follow up", + }); } test "promoted steering remains model marked across the worker handoff" { diff --git a/src/core/session/session.zig b/src/core/session/session.zig index 18531684f..06488ac6c 100644 --- a/src/core/session/session.zig +++ b/src/core/session/session.zig @@ -2721,6 +2721,10 @@ fn appendExecutionMemoryMessages( errdefer alloc.free(text); try messages.append(alloc, message.Message.userOwned(text)); } + for (execution.steering) |text| { + if (text.len == 0) continue; + try messages.append(alloc, message.Message.userText(text)); + } } pub fn appendExecutionMemoryChatMessages( @@ -2755,6 +2759,10 @@ pub fn appendExecutionMemoryChatMessages( errdefer alloc.free(text); try messages.append(alloc, .{ .role = .user, .content = text }); } + for (execution.steering) |text| { + if (text.len == 0) continue; + try messages.append(alloc, .{ .role = .user, .content = text }); + } } fn appendHistoryChatMessagesImpl( @@ -3776,6 +3784,46 @@ test "history projection keeps system role only for leading summaries" { try std.testing.expect(saw_interruption_marker); } +test "resume history projects steering before the final assistant" { + const alloc = std.testing.allocator; + var steering = [_][]u8{ + @constCast("use file B instead"), + @constCast("keep the result concise"), + }; + const history = [_]HistoryTurn{.{ .assistant = .{ + .user = .{ .text = @constCast("modify file A") }, + .assistant = @constCast("updated file B"), + .execution = .{ .steering = steering[0..] }, + } }}; + + var messages: std.ArrayList(message.Message) = .empty; + defer deinitMessages(alloc, &messages); + try appendHistoryMessages(alloc, &messages, &history); + + var arena_state = std.heap.ArenaAllocator.init(alloc); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + var chat_messages: std.ArrayList(core_types.ChatMessage) = .empty; + defer chat_messages.deinit(arena); + try appendHistoryChatMessages(arena, &chat_messages, &history); + + try std.testing.expectEqual(@as(usize, 4), messages.items.len); + try std.testing.expectEqual(messages.items.len, chat_messages.items.len); + const expected_roles = [_]message.Role{ .user, .user, .user, .assistant }; + const expected_text = [_][]const u8{ + "modify file A", + "use file B instead", + "keep the result concise", + "updated file B", + }; + for (messages.items, chat_messages.items, expected_roles, expected_text) |projected, chat, role, text| { + try std.testing.expectEqual(role, projected.role); + try std.testing.expectEqualStrings(@tagName(role), @tagName(chat.role)); + try std.testing.expectEqualStrings(text, projected.content.?.asText()); + try std.testing.expectEqualStrings(text, chat.content.?); + } +} + test "resume projection replays assistant tool execution memory before final answer" { const alloc = std.testing.allocator; var calls = [_]ToolCall{.{ From 462fba2827a5e1b2c106427f9babf35d959c31cc Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 02:01:13 -0400 Subject: [PATCH 11/14] Align steering fixtures with the shell contract --- .../e2e/tui-gateway-stream-lifecycle.test.ts | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index 388029549..efd8f0624 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -2926,10 +2926,13 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const thirdSteering = "THIRD_BEGIN mention the completed command before the conclusion THIRD_END"; const finalText = "COOPERATIVE_STEERING_COMPLETE"; const steeringGateway = startFakeGateway([ - fakeGatewayToolCall("cooperative_steering_tool", "terminal", { - action: "exec", - timeout_ms: 600_000, - command, + fakeGatewayToolCall("cooperative_steering_tool", "shell", { + request: { + action: "run", + yield_time_ms: 30_000, + timeout_ms: 600_000, + command, + }, }), fakeGatewayFinalText(finalText), ]); @@ -3045,10 +3048,13 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const steering = "Use the attached image after this command finishes."; const finalText = "RICH_STEERING_HANDOFF_COMPLETE"; const steeringGateway = startFakeGateway([ - fakeGatewayToolCall("rich_steering_tool", "terminal", { - action: "exec", - timeout_ms: 600_000, - command, + fakeGatewayToolCall("rich_steering_tool", "shell", { + request: { + action: "run", + yield_time_ms: 30_000, + timeout_ms: 600_000, + command, + }, }), fakeGatewayFinalText(finalText), ], { @@ -3146,10 +3152,13 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { "printf FAILED_TOOL_STEERING_RESULT; exit 7"; const steering = "Respond exactly FAILED_TOOL_STEERING_COMPLETE."; const steeringGateway = startFakeGateway([ - fakeGatewayToolCall("failed_steering_tool", "terminal", { - action: "exec", - timeout_ms: 600_000, - command, + fakeGatewayToolCall("failed_steering_tool", "shell", { + request: { + action: "run", + yield_time_ms: 30_000, + timeout_ms: 600_000, + command, + }, }), fakeGatewayFinalText("FAILED_TOOL_STEERING_COMPLETE"), ]); @@ -3220,10 +3229,13 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const steering = "Apply IMMEDIATE_STEERING_SENTINEL now."; const finalText = "IMMEDIATE_STEERING_COMPLETE"; const steeringGateway = startFakeGateway([ - fakeGatewayToolCall("immediate_steering_tool", "terminal", { - action: "exec", - timeout_ms: 600_000, - command: "sleep 30", + fakeGatewayToolCall("immediate_steering_tool", "shell", { + request: { + action: "run", + yield_time_ms: 30_000, + timeout_ms: 600_000, + command: "sleep 30", + }, }), fakeGatewayFinalText(finalText), ]); From 796f0115f7d4c3a409d4b291ff805e6cdda31f74 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 02:23:56 -0400 Subject: [PATCH 12/14] Keep cancelled output above steering prompts --- src/core/agent/worker_runtime.zig | 59 ++++++++-- src/core/app/app_render_runtime.zig | 4 +- src/core/app/app_worker_runtime.zig | 76 ++++++++++++- .../e2e/tui-gateway-stream-lifecycle.test.ts | 107 ++++++++++++++++++ 4 files changed, 234 insertions(+), 12 deletions(-) diff --git a/src/core/agent/worker_runtime.zig b/src/core/agent/worker_runtime.zig index 79589fd0e..52a8352ef 100644 --- a/src/core/agent/worker_runtime.zig +++ b/src/core/agent/worker_runtime.zig @@ -437,6 +437,7 @@ pub const StateSnapshot = struct { pending_event_count: usize = 0, queue_review_reason: ?QueueReviewReason = null, cancel_requested: bool = false, + cancel_continues_turn: bool = false, pending_permission_request: ?permission_request.OwnedPermissionRequest = null, pending_permission_review: ?PendingPermissionReview = null, @@ -1443,13 +1444,16 @@ pub const WorkerRuntime = struct { ) permission_request.RequestCloneError!StateSnapshot { self.worker_mutex.lockUncancelable(io_mod.getIo()); defer self.worker_mutex.unlock(io_mod.getIo()); + const cancel_requested = self.worker_cancel_requested.load(.seq_cst); return .{ .processing = self.worker_processing, .active_turn_id = self.active_turn_id, .queued_count = self.queued_prompt_count, .pending_event_count = self.worker_events.items.len, .queue_review_reason = self.queue_admission, - .cancel_requested = self.worker_cancel_requested.load(.seq_cst), + .cancel_requested = cancel_requested, + .cancel_continues_turn = cancel_requested and + self.steering_cancel_turn_id == self.active_turn_id, .pending_permission_request = if (self.permissionRequestAwaitingDecisionLocked()) blk: { const request = &self.pending_permission_request_shared.?; break :blk try permission_request.OwnedPermissionRequest.dupe(alloc, request.view()); @@ -1490,15 +1494,16 @@ pub const WorkerRuntime = struct { }; } - /// Returns allocator-owned copies of pending steering text in admission - /// order. The caller frees every message and the returned slice. - pub fn snapshotSteeringMessages(self: *WorkerRuntime, alloc: std.mem.Allocator) ![][]u8 { + /// Returns allocator-owned copies of steering that is visibly waiting on + /// the active tool boundary. Immediate steering remains hidden until it is + /// committed as a user turn after the model cutoff. + pub fn snapshotVisibleSteeringMessages(self: *WorkerRuntime, alloc: std.mem.Allocator) ![][]u8 { self.worker_mutex.lockUncancelable(io_mod.getIo()); defer self.worker_mutex.unlock(io_mod.getIo()); var count: usize = 0; for (self.queued_prompts.items) |prompt| { - if (isSteeringPrompt(prompt)) count += 1; + if (self.pendingSteeringVisibleLocked(prompt)) count += 1; } if (count == 0) return &.{}; @@ -1509,13 +1514,19 @@ pub const WorkerRuntime = struct { alloc.free(messages); } for (self.queued_prompts.items) |prompt| { - if (!isSteeringPrompt(prompt)) continue; + if (!self.pendingSteeringVisibleLocked(prompt)) continue; messages[copied] = try alloc.dupe(u8, prompt.prompt); copied += 1; } return messages; } + fn pendingSteeringVisibleLocked(self: *const WorkerRuntime, prompt: QueuedPrompt) bool { + if (!isSteeringPrompt(prompt)) return false; + return self.steering_cancel_turn_id == null or + prompt.steer_target_turn_id != self.steering_cancel_turn_id; + } + pub fn queuedPromptCount(self: *WorkerRuntime) usize { self.worker_mutex.lockUncancelable(io_mod.getIo()); defer self.worker_mutex.unlock(io_mod.getIo()); @@ -3408,6 +3419,10 @@ test "immediate steering owns and clears only its cancellation" { try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "second", "model")); try std.testing.expect(runtime.isCancelRequested()); + var steering_snapshot = try runtime.snapshotState(alloc); + defer steering_snapshot.deinit(alloc); + try std.testing.expect(steering_snapshot.cancel_requested); + try std.testing.expect(steering_snapshot.cancel_continues_turn); const guidance = try runtime.takeImmediateSteering(alloc, 41); defer { @@ -3430,6 +3445,10 @@ test "explicit interrupt overrides immediate steering cancellation" { try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "steer", "model")); _ = runtime.requestInteractiveCancel(); + var interrupt_snapshot = try runtime.snapshotState(alloc); + defer interrupt_snapshot.deinit(alloc); + try std.testing.expect(interrupt_snapshot.cancel_requested); + try std.testing.expect(!interrupt_snapshot.cancel_continues_turn); const guidance = try runtime.takeImmediateSteering(alloc, 41); try std.testing.expectEqual(@as(usize, 0), guidance.len); @@ -3535,7 +3554,7 @@ test "rich interactive input remains steering and hands off at the tool boundary try std.testing.expectEqual(@as(usize, 1), runtime.queuedPromptCount()); } -test "steering snapshot owns pending messages in admission order" { +test "immediate steering stays hidden until the cutoff commits its user turn" { const alloc = std.testing.allocator; var runtime = WorkerRuntime{}; defer runtime.deinit(alloc); @@ -3544,7 +3563,7 @@ test "steering snapshot owns pending messages in admission order" { try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "second", "model")); - const messages = try runtime.snapshotSteeringMessages(alloc); + const messages = try runtime.snapshotVisibleSteeringMessages(alloc); defer { for (messages) |message| alloc.free(message); alloc.free(messages); @@ -3555,6 +3574,30 @@ test "steering snapshot owns pending messages in admission order" { for (guidance) |text| alloc.free(text); alloc.free(guidance); } + try std.testing.expectEqual(@as(usize, 0), messages.len); +} + +test "tool-blocked steering snapshot owns visible messages in admission order" { + const alloc = std.testing.allocator; + var runtime = WorkerRuntime{}; + defer runtime.deinit(alloc); + runtime.worker_processing = true; + runtime.active_turn_id = 41; + + try runtime.pushEvent(alloc, .{ .tool_lifecycle = .{ .authoritative_started = .{ + .id = .{ .turn_id = 41, .call_id = "call_running" }, + .reconciles_provisional_call_id = null, + .tool_name = "terminal", + .activity_kind = .command, + } } }); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "first", "model")); + try runtime.admitInteractivePrompt(alloc, try makePrompt(alloc, "second", "model")); + + const messages = try runtime.snapshotVisibleSteeringMessages(alloc); + defer { + for (messages) |message| alloc.free(message); + alloc.free(messages); + } try std.testing.expectEqual(@as(usize, 2), messages.len); try std.testing.expectEqualStrings("first", messages[0]); try std.testing.expectEqualStrings("second", messages[1]); diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 1f0073e0e..1f51ff32b 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -331,9 +331,9 @@ fn buildQueuedCardProjection(comptime App: type, app: *App) !QueuedCardProjectio queue_preview.paused else false; - if (comptime @hasDecl(@TypeOf(app.worker), "snapshotSteeringMessages")) { + if (comptime @hasDecl(@TypeOf(app.worker), "snapshotVisibleSteeringMessages")) { if (steering_count > 0) { - projection.steering_messages = try app.worker.snapshotSteeringMessages(app.alloc); + projection.steering_messages = try app.worker.snapshotVisibleSteeringMessages(app.alloc); } } if (comptime !@hasField(App, "queued_prompt_review")) return projection; diff --git a/src/core/app/app_worker_runtime.zig b/src/core/app/app_worker_runtime.zig index f2ed8cd43..7a2ba7150 100644 --- a/src/core/app/app_worker_runtime.zig +++ b/src/core/app/app_worker_runtime.zig @@ -495,8 +495,10 @@ pub fn Runtime(comptime App: type) type { snapshot.pending_event_count > 0 else false; + const cancellation_stops_turn = snapshot.cancel_requested and + !snapshot.cancel_continues_turn; const visible_worker_active = app.stream.active and - !snapshot.cancel_requested and + !cancellation_stops_turn and (snapshot.processing or worker_events_pending or (snapshot.queued_count > 0 and !queue_review_active)); @@ -711,7 +713,16 @@ pub fn Runtime(comptime App: type) type { } }, .append_user_feedback => |text| { + if (!try requireAssistantTextDrain(handlers)) { + try retainClaimedEventAndSuffix(app, &batch, "assistant_text_drain_blocked"); + drain_owns_current = false; + break :events; + } try handlers.write_user_prompt(handlers.ctx, .{ .text = text }); + if (app.stream.active and app.stream.phase != .thinking) { + app.stream.phase = .thinking; + app.shell.render_requests.request(.footer); + } }, .assistant_presentation => |presentation| { if (presentation.requiresTextDrain() and !try requireAssistantTextDrain(handlers)) { @@ -1079,6 +1090,7 @@ const FakeSnapshot = struct { queued_count: usize = 0, pending_event_count: usize = 0, cancel_requested: bool = false, + cancel_continues_turn: bool = false, pending_permission_request: ?permission_request.OwnedPermissionRequest = null, pending_permission_review: ?worker_runtime.PendingPermissionReview = null, @@ -1105,6 +1117,7 @@ const FakeWorker = struct { events: std.ArrayList(WorkerEvent) = .empty, queued_count: usize = 0, processing: bool = false, + cancel_continues_turn: bool = false, pending_permission_request: ?permission_request.PermissionRequest = null, pending_permission_review: ?*const diff_mod.FileReview = null, pending_question: bool = false, @@ -1168,6 +1181,7 @@ const FakeWorker = struct { .queued_count = self.queued_count, .pending_event_count = self.events.items.len, .cancel_requested = self.worker_cancel_requested.load(.seq_cst), + .cancel_continues_turn = self.cancel_continues_turn, .pending_permission_request = if (self.pending_permission_request) |request| try permission_request.OwnedPermissionRequest.dupe(alloc, request) else @@ -1715,6 +1729,7 @@ const PacedTranscriptBridge = struct { drain_count: usize = 0, user_prompt_count: usize = 0, pacer_pending_when_user_prompt_written: bool = false, + assistant_present_when_user_prompt_written: bool = false, notice_count: usize = 0, history_count: usize = 0, finish_count: usize = 0, @@ -1774,6 +1789,9 @@ const PacedTranscriptBridge = struct { self.user_prompt_count += 1; self.record('U'); self.pacer_pending_when_user_prompt_written = self.pacer.hasPending(); + self.assistant_present_when_user_prompt_written = + self.app.shell.lifecycle.entries.items.len > 0 and + self.app.shell.lifecycle.entries.items[self.app.shell.lifecycle.entries.items.len - 1] == .assistant_turn; } fn drainAssistantText(raw: *anyopaque) !AssistantTextDrainResult { @@ -2514,6 +2532,26 @@ test "core.app_worker_runtime syncState preserves already visible processing act try std.testing.expect(!app.shell.render_requests.hasReason(.footer)); } +test "core.app_worker_runtime syncState preserves activity when cancellation continues the turn" { + var app = FakeApp.init(std.testing.allocator); + defer app.deinit(); + + app.worker.processing = true; + app.worker.worker_cancel_requested.store(true, .seq_cst); + app.worker.cancel_continues_turn = true; + app.stream = .{ + .active = true, + .phase = .generating, + .token_progress = .{ .input_tokens = 8, .output_tokens = 13 }, + }; + + Runtime(FakeApp).syncState(&app, NoopBridge.lifecyclePresenter(&app)); + + try std.testing.expect(app.stream.active); + try std.testing.expectEqual(types.TurnPhase.generating, app.stream.phase); + try std.testing.expectEqual(@as(u64, 13), app.stream.token_progress.output_tokens); +} + test "core.app_worker_runtime syncState preserves footer until cancelled tool terminal" { var app = FakeApp.init(std.testing.allocator); defer app.deinit(); @@ -3273,6 +3311,39 @@ test "core.app_worker_runtime prompt boundary drains paced text before writing t ); } +test "core.app_worker_runtime feedback boundary drains paced text before writing the user card" { + var app = FakeApp.init(std.testing.allocator); + defer app.deinit(); + app.worker.processing = true; + app.stream = .{ .active = true, .phase = .generating }; + app.shell.render_requests.clearReason(.footer); + + var bridge = PacedTranscriptBridge.init(&app); + defer bridge.deinit(); + + try app.worker.pushEvent(std.heap.c_allocator, .{ + .assistant_presentation = .{ .text = @constCast("cancelled response tail") }, + }); + try app.worker.pushEvent(std.heap.c_allocator, .{ + .append_user_feedback = @constCast("steer now"), + }); + + try Runtime(FakeApp).tick(&app, bridge.handlers()); + + try std.testing.expectEqual(@as(usize, 1), bridge.drain_count); + try std.testing.expectEqual(@as(usize, 1), bridge.user_prompt_count); + try std.testing.expect(!bridge.pacer_pending_when_user_prompt_written); + try std.testing.expect(bridge.assistant_present_when_user_prompt_written); + try std.testing.expect(!bridge.pacer.hasPending()); + try std.testing.expectEqual(types.TurnPhase.thinking, app.stream.phase); + try std.testing.expect(app.shell.render_requests.hasReason(.footer)); + try std.testing.expectEqual(@as(usize, 1), app.shell.lifecycle.entries.items.len); + try std.testing.expectEqualStrings( + "cancelled response tail", + app.shell.lifecycle.entries.items[0].assistant_turn.segments.text.items, + ); +} + test "core.app_worker_runtime blocked prompt drain retains the prompt before reset or write" { var app = FakeApp.init(std.testing.allocator); defer app.deinit(); @@ -3883,6 +3954,7 @@ test "core.app_worker_runtime blocked error drain retains the error and suffix i const self: *@This() = @ptrCast(@alignCast(raw)); self.feedback_count += 1; try std.testing.expectEqual(@as(usize, 1), self.error_count); + try std.testing.expectEqual(@as(usize, 3), self.drain_count); } }; @@ -3920,7 +3992,7 @@ test "core.app_worker_runtime blocked error drain retains the error and suffix i try Runtime(FakeApp).tick(&app, handlers); - try std.testing.expectEqual(@as(usize, 2), capture.drain_count); + try std.testing.expectEqual(@as(usize, 3), capture.drain_count); try std.testing.expectEqual(@as(usize, 1), capture.error_count); try std.testing.expectEqual(@as(usize, 1), capture.feedback_count); try std.testing.expect(!app.stream.active); diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index efd8f0624..4830bcf89 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -2859,6 +2859,11 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { await session.waitForText("SPLIT_OLD_TAIL_FINAL", TIMEOUT); await session.sendText(SPLIT_NEW_USER_PROMPT); + const cutoffPane = await session.capturePane(); + expect(cutoffPane).not.toContain( + `${SPLIT_NEW_USER_PROMPT} · Esc to steer now`, + ); + expect(cutoffPane).toMatch(/Thinking|Generating/); await waitForCondition( () => firstResponse.cancelled, "visible assistant steering cancellation", @@ -2867,6 +2872,18 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { () => splitGateway.requests.length === 2 && secondResponse.started, "immediate steering Gateway stream", ); + const handoffScrollback = await waitForEscapedScrollback( + session, + (candidate) => { + const promptIndex = candidate.lastIndexOf(SPLIT_NEW_USER_PROMPT); + return promptIndex >= 0 && candidate.lastIndexOf("Thinking") > promptIndex; + }, + "thinking activity after the steering user row", + 3_000, + ); + expect(handoffScrollback.lastIndexOf("Thinking")).toBeGreaterThan( + handoffScrollback.lastIndexOf(SPLIT_NEW_USER_PROMPT), + ); const rawScrollback = await waitForEscapedScrollback( session, @@ -2906,6 +2923,96 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { }, SPLIT_BOUNDARY_TEST_TIMEOUT, ); + + test( + "cancelled buffered assistant tail stays before steering and the next answer", + async () => { + root = realpathSync(mkdtempSync(join(tmpdir(), "fx-tui-steering-late-tail-"))); + const home = join(root, "home"); + const workspacePath = join(root, "workspace"); + const stderrPath = join(root, "stderr.log"); + const tracePath = join(root, "trace.log"); + const firstResponse: HoldState = { started: false, cancelled: false }; + const visiblePrefix = "CANCELLED_RESPONSE_VISIBLE_PREFIX"; + const bufferedTail = "CANCELLED_RESPONSE_BUFFERED_TAIL"; + const steering = "Respond with exactly STEERED_RESPONSE_FRESH."; + const finalText = "STEERED_RESPONSE_FRESH"; + mkdirSync(join(home, ".fx"), { recursive: true }); + mkdirSync(workspacePath, { recursive: true }); + writeFileSync(join(home, ".fx", "settings.json"), "{}"); + const workspace = realpathSync(workspacePath); + + const lateTailGateway = startFakeGateway([ + () => + heldGatewayResponse(firstResponse, [{ + type: "text-delta", + id: "cancelled_response", + delta: `${visiblePrefix}\n\n${bufferedTail}`, + }]), + fakeGatewayFinalText(finalText), + ]); + gateway = lateTailGateway; + session = await TmuxSession.create({ + cwd: workspace, + width: 120, + height: 34, + minimumHistoryLines: 2_000, + stderrPath, + env: { + HOME: home, + AI_GATEWAY_API_KEY: "fake-steering-late-tail-key", + VERCEL_OIDC_TOKEN: undefined, + FX_AUTO_UPGRADE: "0", + FX_SOUND: "0", + FX_GATEWAY_BASE_URL: lateTailGateway.baseUrl, + FX_GATEWAY_CHAT_URL: lateTailGateway.chatUrl, + FX_E2E_GATEWAY_CHAT_URL: lateTailGateway.chatUrl, + FX_MODEL: MODEL, + FX_TRACE_LOG: tracePath, + FX_TRACE_SCOPES: "agent,gateway,stream,worker,input,prompt", + }, + }); + + await session.waitForComposer(TIMEOUT); + await session.sendText("Start the cancelled response fixture."); + await session.waitForText(visiblePrefix, TIMEOUT); + expect(await session.captureFullScrollback()).not.toContain(bufferedTail); + + await session.sendText(steering); + await session.waitForText(finalText, TIMEOUT); + await waitForCondition( + () => lateTailGateway.requests.length === 2, + "steered response request", + ); + + const scrollback = await session.captureFullScrollback(); + const visibleIndex = scrollback.lastIndexOf(visiblePrefix); + const tailIndex = scrollback.lastIndexOf(bufferedTail); + const steeringIndex = scrollback.lastIndexOf(steering); + const finalIndex = scrollback.lastIndexOf(finalText); + expect(visibleIndex).toBeGreaterThanOrEqual(0); + expect(tailIndex).toBeGreaterThan(visibleIndex); + expect(steeringIndex).toBeGreaterThan(tailIndex); + expect(finalIndex).toBeGreaterThan(steeringIndex); + expect(scrollback.slice(steeringIndex)).not.toContain(bufferedTail); + + const lines = scrollback.split("\n").map((line) => line.trimEnd()); + const steeringLine = lines.findIndex((line) => line.includes(steering)); + const finalLine = lines.findIndex((line, index) => + index > steeringLine && line.includes(finalText) + ); + expect(steeringLine).toBeGreaterThanOrEqual(0); + expect(finalLine).toBeGreaterThan(steeringLine); + expect( + lines.slice(steeringLine + 1, finalLine).some((line) => line.trim() === ""), + ).toBe(true); + expect(readFileSync(stderrPath, "utf8")).toBe(""); + expect(session.isAlive()).toBe(true); + expect(session.isPaneAlive()).toBe(true); + }, + TIMEOUT * 2, + ); + test( "ordinary Enter keeps pending steering visible through narrow resize", async () => { From 5769574b6e754709eca33c4a4f3601f139bb4194 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 03:15:24 -0400 Subject: [PATCH 13/14] Align terminal SDK steering coverage --- sdk/tests/test-term.mjs | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/sdk/tests/test-term.mjs b/sdk/tests/test-term.mjs index 0b10b77fe..d886fb7dc 100644 --- a/sdk/tests/test-term.mjs +++ b/sdk/tests/test-term.mjs @@ -19,7 +19,8 @@ let streamedText = ""; const liveDraft = "steering draft"; const steeringAnswer = "§"; let draftVisibleAt; -let pendingVisibleAt; +let steeringSubmittedAt; +let postSubmitText = ""; const originalSetTimeout = globalThis.setTimeout; let observeZeroTimeouts = false; let zeroTimeoutCount = 0; @@ -37,9 +38,10 @@ const terminal = { write(bytes) { const chunk = bytes instanceof Uint8Array ? bytes : new TextEncoder().encode(bytes); output.push(chunk); - streamedText += streamedDecoder.decode(chunk, { stream: true }); + const decoded = streamedDecoder.decode(chunk, { stream: true }); + streamedText += decoded; + if (steeringSubmittedAt !== undefined) postSubmitText += decoded; if (draftVisibleAt === undefined && streamedText.includes(liveDraft)) draftVisibleAt = performance.now(); - if (pendingVisibleAt === undefined && streamedText.includes(`${liveDraft} · Esc to steer now`)) pendingVisibleAt = performance.now(); process.stdout.write(chunk); }, async drain() { @@ -146,12 +148,8 @@ while (draftVisibleAt === undefined) { if (performance.now() >= deadline) throw new Error("timed out waiting for live follow-up input"); await new Promise((resolve) => setTimeout(resolve, 10)); } +steeringSubmittedAt = performance.now(); runtime.write("\r"); -while (pendingVisibleAt === undefined) { - if (streamFinishedAt !== undefined) throw new Error("terminal did not hold steering while the response was active"); - if (performance.now() >= deadline) throw new Error("timed out waiting for pending steering"); - await new Promise((resolve) => setTimeout(resolve, 10)); -} observeZeroTimeouts = false; const steeringDeadline = performance.now() + 5000; while ( @@ -179,9 +177,12 @@ if (!text.includes("𝒇x")) throw new Error("shared fx welcome frame was not ob if (!text.includes("Run /help for commands")) throw new Error("shared fx welcome guidance was not observed"); if (requestedModel !== "sdk/term-model") throw new Error(`terminal prompt did not use the host-restored model: ${requestedModel}`); if (!(streamStartedAt < secondRequestAt)) throw new Error("terminal started steering before the active response"); -if (!(draftVisibleAt < secondRequestAt)) throw new Error("terminal rendered follow-up input only after steering started"); -if (!(pendingVisibleAt < secondRequestAt)) throw new Error("terminal showed pending steering only after steering started"); +if (!(draftVisibleAt < steeringSubmittedAt)) throw new Error("terminal did not render the steering draft before submission"); +if (!(steeringSubmittedAt <= secondRequestAt)) throw new Error("terminal started steering before submission"); if (!(secondRequestAt < streamFinishedAt)) throw new Error("terminal waited for the active response before steering"); +if (postSubmitText.includes(`${liveDraft} · Esc to steer now`)) throw new Error("terminal exposed tool-only pending UI during immediate steering"); +if (!postSubmitText.includes(liveDraft)) throw new Error("terminal did not commit the steering user row after cutoff"); +if (!postSubmitText.includes("Thinking")) throw new Error("terminal hid activity during immediate steering"); const steeringUser = secondRequestBody.prompt?.filter((message) => message.role === "user").at(-1); const steeringText = steeringUser?.content?.filter((part) => part.type === "text").map((part) => part.text); const steeringRequest = JSON.stringify(secondRequestBody.prompt); From f97b1cf1eff4927dc3afc739d8284b9ce52a0f27 Mon Sep 17 00:00:00 2001 From: Pranit Date: Wed, 2 Sep 2026 03:35:19 -0400 Subject: [PATCH 14/14] Wait for the steered assistant response --- tests/e2e/tui-gateway-stream-lifecycle.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/tests/e2e/tui-gateway-stream-lifecycle.test.ts b/tests/e2e/tui-gateway-stream-lifecycle.test.ts index 4830bcf89..7d570ef11 100644 --- a/tests/e2e/tui-gateway-stream-lifecycle.test.ts +++ b/tests/e2e/tui-gateway-stream-lifecycle.test.ts @@ -2935,7 +2935,7 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { const firstResponse: HoldState = { started: false, cancelled: false }; const visiblePrefix = "CANCELLED_RESPONSE_VISIBLE_PREFIX"; const bufferedTail = "CANCELLED_RESPONSE_BUFFERED_TAIL"; - const steering = "Respond with exactly STEERED_RESPONSE_FRESH."; + const steering = "Replace the cancelled response with the short corrected answer."; const finalText = "STEERED_RESPONSE_FRESH"; mkdirSync(join(home, ".fx"), { recursive: true }); mkdirSync(workspacePath, { recursive: true }); @@ -2979,7 +2979,11 @@ describe.skipIf(!tmuxAvailable())("TUI gateway stream lifecycle", () => { expect(await session.captureFullScrollback()).not.toContain(bufferedTail); await session.sendText(steering); - await session.waitForText(finalText, TIMEOUT); + await waitForScrollback( + session, + (candidate) => candidate.split("\n").some((line) => line.trim() === finalText), + "fresh steered assistant response", + ); await waitForCondition( () => lateTailGateway.requests.length === 2, "steered response request",