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.
This commit is contained in:
Pranit committed 2026-09-26 00:26:39 -04:00
1 parent 59bf4372bd
commit 95965eead3
2 files changed
+107 -2

No files matched your search

+47 -1
View File
@@ -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{
+60 -1
View File
@@ -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);