mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
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:
2 files changed
+107
-2
No files matched your search
@@ -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{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user