From 95965eead319ee70f25970e298d237bffe5d24b2 Mon Sep 17 00:00:00 2001 From: Pranit Date: Sat, 26 Sep 2026 00:26:39 -0400 Subject: [PATCH] Fix crash after clearing the screen in conversations with tool calls Clearing the display hides earlier transcript entries. The compact tool grouping pass could still start a group on a hidden tool entry, skip every hidden entry after it, and hand an empty group to formatGroupBlock, which indexed out of bounds on the next render. Skip hidden entries before starting a group. Hidden entries keep their projection action, and a visible tool call that follows hidden history now keeps its own group. --- src/ui/transcript/tool_group_projection.zig | 48 +++++++++++++++- tests/e2e/tui-native-clear-recovery.test.ts | 61 ++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/ui/transcript/tool_group_projection.zig b/src/ui/transcript/tool_group_projection.zig index d0c765dca..6a57374cc 100644 --- a/src/ui/transcript/tool_group_projection.zig +++ b/src/ui/transcript/tool_group_projection.zig @@ -1279,7 +1279,11 @@ fn buildWithStyleAndStats( var index: usize = 0; while (index < entries.len) { try build_checkpoint.tick(checkpoint); - if (projection.entry_actions.items[index] != .keep) { + // Hidden entries keep their action, but grouping below skips them, so + // they must not anchor a group either. + if (projection.entry_actions.items[index] != .keep or + !transcript_blocks.isEntryVisibleInCompactPresentation(entries[index])) + { index += 1; continue; } @@ -2495,6 +2499,48 @@ test "entries hidden by compact presentation do not split tool groups" { try std.testing.expect(projection.entry_actions.items[3] == .hide); } +test "tool status hidden by a display clear does not start a compact group" { + const alloc = std.testing.allocator; + const entries = [_]TranscriptEntry{ + .{ .raw_bytes = .{ .id = 1, .bytes = "welcome", .class = .welcome } }, + .{ .raw_bytes = .{ .id = 2, .bytes = "command", .class = .tool_status, .inline_hidden = true } }, + .{ .raw_bytes = .{ .id = 3, .bytes = "output", .class = .command_output, .inline_hidden = true } }, + .{ .raw_bytes = .{ .id = 4, .bytes = "4s", .class = .turn_summary, .inline_hidden = true } }, + }; + const details = [_]ToolDetailRecord{ + .{ .entry_id = 2, .tool_name = @constCast("run_command"), .activity_kind = .command, .outcome = .completed }, + }; + + var projection = try build(alloc, &entries, &details, 120); + defer projection.deinit(alloc); + + try std.testing.expect(projection.entry_actions.items[0] == .keep); + try std.testing.expect(projection.entry_actions.items[1] == .keep); + try std.testing.expect(projection.entry_actions.items[2] == .hide); + try std.testing.expect(projection.entry_actions.items[3] == .keep); +} + +test "visible tool status after a display clear keeps its compact group" { + const alloc = std.testing.allocator; + const entries = [_]TranscriptEntry{ + .{ .raw_bytes = .{ .id = 1, .bytes = "command", .class = .tool_status, .inline_hidden = true } }, + .{ .raw_bytes = .{ .id = 2, .bytes = "read", .class = .tool_status } }, + }; + const details = [_]ToolDetailRecord{ + .{ .entry_id = 1, .tool_name = @constCast("run_command"), .activity_kind = .command, .outcome = .completed }, + .{ .entry_id = 2, .tool_name = @constCast("read_file"), .activity_kind = .read, .outcome = .completed }, + }; + + var projection = try build(alloc, &entries, &details, 120); + defer projection.deinit(alloc); + + try std.testing.expect(projection.entry_actions.items[0] == .keep); + try std.testing.expect(projection.entry_actions.items[1] == .override); + const block = projection.entry_actions.items[1].override.bytes; + try std.testing.expect(std.mem.find(u8, block, "1 tool call") != null); + try std.testing.expect(std.mem.find(u8, block, "run_command") == null); +} + test "visible assistant messages split groups while silent entries do not" { const alloc = std.testing.allocator; var entries = [_]TranscriptEntry{ diff --git a/tests/e2e/tui-native-clear-recovery.test.ts b/tests/e2e/tui-native-clear-recovery.test.ts index 87abb2773..89a6c8026 100644 --- a/tests/e2e/tui-native-clear-recovery.test.ts +++ b/tests/e2e/tui-native-clear-recovery.test.ts @@ -4,7 +4,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from "node:os"; import { join } from "node:path"; import { FX_BIN } from "../evals/eval-helpers"; -import { composerContains, FAKE_GATEWAY_MODEL, fakeGatewayFinalText, startFakeGateway, TmuxSession, tmuxAvailable } from "./tmux-helpers"; +import { composerContains, FAKE_GATEWAY_MODEL, fakeGatewayFinalText, fakeShellRun, startFakeGateway, TmuxSession, tmuxAvailable } from "./tmux-helpers"; const tmuxTest = test.skipIf(!tmuxAvailable()); const PASTE_START = ["1b", "5b", "32", "30", "30", "7e"] as const; @@ -78,6 +78,65 @@ tmuxTest("direct native-clear recovery resets the view and replays the held draf expect(readFileSync(stderr_path, "utf8")).toBe(""); }, 30_000); +tmuxTest("native-clear recovery after a tool call keeps fx running and the tool in full history", async () => { + const dir = mkdtempSync(join(tmpdir(), "fx-native-clear-tool-")); + temp_dirs.push(dir); + const trace_path = join(dir, "trace.log"); + const stderr_path = join(dir, "stderr.log"); + mkdirSync(join(dir, ".fx"), { recursive: true }); + writeFileSync(join(dir, ".fx", "settings.json"), JSON.stringify({ sandbox: "none" })); + const gateway = startFakeGateway([ + fakeShellRun("native_clear_tool", "echo TOOL_BEFORE_CLEAR"), + fakeGatewayFinalText("TOOL_TURN_DONE"), + ]); + + session = await TmuxSession.create({ + cwd: dir, + width: 100, + height: 30, + stderrPath: stderr_path, + env: { + HOME: dir, + AI_GATEWAY_API_KEY: "fake-native-clear-tool-key", + VERCEL_OIDC_TOKEN: undefined, + FX_THEME: undefined, + FX_GATEWAY_BASE_URL: gateway.baseUrl, + FX_GATEWAY_CHAT_URL: gateway.chatUrl, + FX_MODEL: FAKE_GATEWAY_MODEL, + FX_AUTO_UPGRADE: "0", + FX_PERMISSION_MODE: "yolo", + TMUX: undefined, + FX_TRACE_LOG: trace_path, + FX_TRACE_SCOPES: "native_clear", + }, + }); + try { + await session.waitForComposer(10_000); + await session.sendLiteral("run the check"); + await session.sendKeys("Enter"); + await session.waitForText("TOOL_TURN_DONE", 10_000); + + clearPaneTerminal(session); + await session.sendLiteral("abc"); + await waitForTrace(trace_path, "native_clear_recovery_requested"); + await session.waitForPane((pane) => composerContains(pane, "abc"), 10_000); + + const history = await session.captureFullScrollback(); + expect(history).not.toContain("TOOL_TURN_DONE"); + expect(history).toContain("𝒇x v"); + + await session.sendKeys("C-o"); + await session.waitForText("echo TOOL_BEFORE_CLEAR", 10_000); + await session.sendKeys("C-o"); + await session.waitForPane((pane) => composerContains(pane, "abc"), 10_000); + + expect(session.isAlive()).toBe(true); + expect(readFileSync(stderr_path, "utf8")).toBe(""); + } finally { + gateway.stop(); + } +}, 30_000); + tmuxTest("direct healthy screens retain an ordinary burst without resetting", async () => { const dir = mkdtempSync(join(tmpdir(), "fx-native-clear-match-")); temp_dirs.push(dir);