mirror of
https://github.com/vercel-labs/fx.git
synced 2026-10-28 10:15:17 +08:00
Show the session ID in a /status panel
- /status opens a panel in the footer instead of writing a notice to the transcript. Esc or Enter closes it and leaves nothing behind. - The panel's first line shows the open session's ID and whether it is saved yet, so it can be resumed with fx --resume <id>. A shell that can't save sessions shows none. - The open panel updates when a running turn saves the session or finishes. - When the panel doesn't fit, the arrow keys scroll it and the hint says so. Long values such as the workspace path wrap instead of being cut off. - When profile settings can't be read, the panel shows a Settings line and leaves out auth, instead of printing an error with no status. - A fork made before the first prompt now counts as saved: /status shows it as saved, and the exit line prints its resume command.
This commit is contained in:
59 files changed
+2811
-248
No files matched your search
@@ -127,6 +127,7 @@
|
||||
{"name": "verify-tui-slash-commands", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-slash-commands.test.ts"], "test_file": "tui-slash-commands.test.ts"},
|
||||
{"name": "verify-tui-slash-extra", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-slash-extra.test.ts"], "test_file": "tui-slash-extra.test.ts"},
|
||||
{"name": "verify-tui-slash-menu", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-slash-menu.test.ts"], "test_file": "tui-slash-menu.test.ts"},
|
||||
{"name": "verify-tui-status-session", "argv": ["bun", "test", "--max-concurrency", "1", "./tui-status-session.test.ts"], "test_file": "tui-status-session.test.ts"},
|
||||
{"name": "verify-ultrafast-fake-gateway", "argv": ["bun", "test", "--max-concurrency", "1", "./ultrafast-fake-gateway.test.ts"], "test_file": "ultrafast-fake-gateway.test.ts"},
|
||||
{"name": "verify-web-fetch-permission-progress", "argv": ["bun", "test", "--max-concurrency", "1", "./web-fetch-permission-progress.test.ts"], "test_file": "web-fetch-permission-progress.test.ts", "requires_tmux": false},
|
||||
{"name": "verify-web-search-permission-progress", "argv": ["bun", "test", "--max-concurrency", "1", "./web-search-permission-progress.test.ts"], "test_file": "web-search-permission-progress.test.ts", "requires_tmux": false},
|
||||
|
||||
@@ -451,7 +451,7 @@ pub const slash_specs = [_]SlashSpec{
|
||||
.{ .kind = .provider, .command = "/provider", .aliases = &.{"/setup"}, .help_entry = "/provider (/setup)", .completion_description = "choose the model provider and how it signs in", .presentation_category = .account, .has_args = true },
|
||||
.{ .kind = .stats, .command = "/stats", .help_entry = "/stats", .completion_description = "show token and turn statistics", .presentation_category = .account },
|
||||
.{ .kind = .usage, .command = "/usage", .aliases = &.{}, .help_entry = "/usage", .completion_description = "show your provider's usage and spend", .presentation_category = .account },
|
||||
.{ .kind = .status, .command = "/status", .help_entry = "/status", .completion_description = "show runtime configuration", .presentation_category = .general, .show_in_welcome = true },
|
||||
.{ .kind = .status, .command = "/status", .help_entry = "/status", .completion_description = "show runtime configuration and the session ID", .presentation_category = .general, .show_in_welcome = true },
|
||||
.{ .kind = .image, .command = "/image", .aliases = &.{"/img"}, .help_entry = "/image <path> (/img)", .completion_description = "attach an image by path", .presentation_category = .media, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .images, .command = "/images", .help_entry = "/images [clear]", .completion_description = "manage pending image attachments", .presentation_category = .media, .has_args = true, .accepts_payload = true },
|
||||
.{ .kind = .model, .command = "/model", .help_entry = "/model <id-or-query>", .completion_description = "choose what model and reasoning effort to use", .presentation_category = .model, .has_args = true, .accepts_payload = true },
|
||||
|
||||
@@ -515,9 +515,29 @@ pub fn Handlers(comptime App: type) type {
|
||||
}
|
||||
}
|
||||
|
||||
/// `/status` opens its panel in the footer, like `/usage`, and
|
||||
/// writes nothing to the transcript.
|
||||
fn commandShowStatus(ctx: *anyopaque) !void {
|
||||
const app: *App = @ptrCast(@alignCast(ctx));
|
||||
try session_commands.Commands(App).showStatus(app);
|
||||
const snapshot = try session_commands.Commands(App).openStatusSnapshot(app);
|
||||
if (comptime @hasField(App, "skills")) app.skills.closeMenu();
|
||||
if (comptime @hasField(App, "model_cache")) app.model_cache.closeMenu();
|
||||
closeHelpMenuIfPresent(app);
|
||||
app.input_runtime.settings_menu.close();
|
||||
closeInlineCommandMenusIfPresent(app);
|
||||
app.input_runtime.status_menu.open(snapshot);
|
||||
app.shell.render_requests.request(.footer);
|
||||
}
|
||||
|
||||
/// Rebuilds the open `/status` panel when a running turn saved the
|
||||
/// session or ended. Returns whether it did, so the footer repaints.
|
||||
pub fn collectStatusMenuFacts(app: *App) !bool {
|
||||
const menu = &app.input_runtime.status_menu;
|
||||
if (!menu.active) return false;
|
||||
const shown = if (menu.snapshot) |*snapshot| snapshot else return false;
|
||||
const next = (try session_commands.Commands(App).refreshedStatusSnapshot(app, shown)) orelse return false;
|
||||
menu.replace(next);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn commandAttachImage(ctx: *anyopaque, path: []const u8) !void {
|
||||
@@ -1162,6 +1182,7 @@ pub fn Handlers(comptime App: type) type {
|
||||
if (comptime !@hasField(App, "input_runtime")) return;
|
||||
const InputRuntime = @TypeOf(app.input_runtime);
|
||||
if (comptime @hasField(InputRuntime, "statusline_menu")) app.input_runtime.statusline_menu.close();
|
||||
if (comptime @hasField(InputRuntime, "status_menu")) app.input_runtime.status_menu.close();
|
||||
if (comptime @hasField(InputRuntime, "usage_menu")) closeUsageMenu(app);
|
||||
if (comptime @hasField(InputRuntime, "workspace_menu")) app.input_runtime.workspace_menu.close();
|
||||
}
|
||||
|
||||
@@ -55,6 +55,7 @@ const approval_prompt = @import("../permissions/approval_prompt.zig");
|
||||
const picker_presentation = @import("../../ui/footer/picker_presentation.zig");
|
||||
const compact_command_menu_presentation = @import("../../ui/footer/compact_command_menu_presentation.zig");
|
||||
const render_input = @import("../../ui/footer/render_input.zig");
|
||||
const status_menu_presentation = @import("../../ui/footer/status_menu_presentation.zig");
|
||||
const paste_blocks = @import("../input/pasted_blocks.zig");
|
||||
const registered_entities = @import("../input/registered_entities.zig");
|
||||
const prompt_history_runtime = @import("prompt_history_runtime.zig");
|
||||
@@ -1828,12 +1829,14 @@ pub fn Runtime(comptime App: type) type {
|
||||
|
||||
const CompactCommandMenuKind = enum {
|
||||
statusline,
|
||||
status,
|
||||
usage,
|
||||
workspace,
|
||||
};
|
||||
|
||||
fn activeCompactCommandMenu(app: *App) ?CompactCommandMenuKind {
|
||||
if (app.input_runtime.statusline_menu.active) return .statusline;
|
||||
if (app.input_runtime.status_menu.active) return .status;
|
||||
if (app.input_runtime.usage_menu.active) return .usage;
|
||||
if (app.input_runtime.workspace_menu.active) return .workspace;
|
||||
return null;
|
||||
@@ -2110,6 +2113,12 @@ pub fn Runtime(comptime App: type) type {
|
||||
try prepareWorkspaceMenuCommand(app, max_input_len);
|
||||
return;
|
||||
}
|
||||
// The panel only shows; Enter closes it as Esc does.
|
||||
if (menu == .status) {
|
||||
app.input_runtime.status_menu.close();
|
||||
app.shell.render_requests.request(.footer);
|
||||
return;
|
||||
}
|
||||
if (menu == .usage) {
|
||||
if (app.input_runtime.usage_menu.openDetail()) app.shell.render_requests.request(.footer);
|
||||
return;
|
||||
@@ -2117,7 +2126,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
const snapshot = app_commands.settingsCatalogSnapshot(app);
|
||||
const change = switch (menu) {
|
||||
.statusline => app.input_runtime.statusline_menu.selectedChange(snapshot),
|
||||
.usage, .workspace => unreachable,
|
||||
.status, .usage, .workspace => unreachable,
|
||||
} orelse return;
|
||||
if (comptime @hasDecl(App, "notificationPreferences")) {
|
||||
try app_commands.applySettingsCatalogMenuChange(app, change);
|
||||
@@ -2159,6 +2168,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
fn moveCompactCommandMenu(app: *App, menu: CompactCommandMenuKind, delta: i32) bool {
|
||||
return switch (menu) {
|
||||
.statusline => app.input_runtime.statusline_menu.move(delta),
|
||||
.status => scrollStatusMenu(app, delta),
|
||||
.usage => app.input_runtime.usage_menu.moveModel(
|
||||
delta,
|
||||
usageMenuVisibleModelItems(app),
|
||||
@@ -2173,6 +2183,18 @@ pub fn Runtime(comptime App: type) type {
|
||||
};
|
||||
}
|
||||
|
||||
/// ↑↓ in the `/status` panel, over the lines the footer shows it.
|
||||
fn scrollStatusMenu(app: *App, delta: i32) bool {
|
||||
const cols = app.shell.layout.cols;
|
||||
const projection = render_input.statusMenuProjection(&app.input_runtime.status_menu, cols, app.shell.layout.rows);
|
||||
const visible_rows = status_menu_presentation.footerRows(projection, cols, app.shell.layout.rows);
|
||||
return app.input_runtime.status_menu.scrollBy(
|
||||
delta,
|
||||
status_menu_presentation.lineCount(projection.rows, cols),
|
||||
status_menu_presentation.visibleLineCount(visible_rows),
|
||||
);
|
||||
}
|
||||
|
||||
fn usageMenuVisibleModelItems(app: *App) u16 {
|
||||
const projection = render_input.usageMenuProjection(
|
||||
&app.input_runtime.usage_menu,
|
||||
@@ -2222,7 +2244,7 @@ pub fn Runtime(comptime App: type) type {
|
||||
const delta: i32 = if (resolved == .cursor_left) -1 else 1;
|
||||
const change = switch (menu) {
|
||||
.statusline => app.input_runtime.statusline_menu.changeSelectedOption(&snapshot, delta),
|
||||
.usage, .workspace => unreachable,
|
||||
.status, .usage, .workspace => unreachable,
|
||||
} orelse return;
|
||||
if (comptime @hasDecl(App, "notificationPreferences")) {
|
||||
try app_commands.applySettingsCatalogMenuChange(app, change);
|
||||
@@ -2791,6 +2813,10 @@ pub fn Runtime(comptime App: type) type {
|
||||
app.input_runtime.statusline_menu.close();
|
||||
return true;
|
||||
}
|
||||
if (app.input_runtime.status_menu.active) {
|
||||
app.input_runtime.status_menu.close();
|
||||
return true;
|
||||
}
|
||||
if (app.input_runtime.usage_menu.active) {
|
||||
// Esc on a model's detail screen goes back to the tables.
|
||||
if (app.input_runtime.usage_menu.closeDetail()) {
|
||||
|
||||
@@ -813,6 +813,11 @@ pub fn Runtime(comptime App: type) type {
|
||||
&app.input_runtime.statusline_menu,
|
||||
settings_snapshot,
|
||||
),
|
||||
.status_menu = render_input.statusMenuProjection(
|
||||
&app.input_runtime.status_menu,
|
||||
app.shell.layout.cols,
|
||||
app.shell.layout.rows,
|
||||
),
|
||||
.usage_menu = render_input.usageMenuProjection(
|
||||
&app.input_runtime.usage_menu,
|
||||
),
|
||||
|
||||
@@ -1919,7 +1919,7 @@ fn takePromptRunResult(ctx: *AskContext, alloc: Allocator) !PromptRunResult {
|
||||
@constCast(&.{});
|
||||
errdefer if (resolved_provider.len > 0) alloc.free(resolved_provider);
|
||||
const session_id = if (ctx.v2) |v2|
|
||||
// A session exists once its first turn is written.
|
||||
// A session exists once its log is on disk (D90).
|
||||
try alloc.dupe(u8, if (v2.saved()) v2.id() else "")
|
||||
else
|
||||
try alloc.dupe(u8, "");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const std = @import("std");
|
||||
const command_specs = @import("../slash_commands/command_specs.zig");
|
||||
const settings_catalog = @import("../config/settings_catalog.zig");
|
||||
const status_menu = @import("../session/status_menu.zig");
|
||||
const usage_menu = @import("../session/usage_menu.zig");
|
||||
const workspace_menu = @import("../workspace/workspace_menu.zig");
|
||||
const composer_deletion = @import("composer_deletion.zig");
|
||||
@@ -38,6 +39,7 @@ pub const Runtime = struct {
|
||||
help_menu: command_specs.HelpMenu = .{},
|
||||
settings_menu: settings_catalog.Menu = .{},
|
||||
statusline_menu: settings_catalog.StatuslineMenu = .{},
|
||||
status_menu: status_menu.State = .{},
|
||||
usage_menu: usage_menu.State = .{},
|
||||
workspace_menu: workspace_menu.State = .{},
|
||||
composer_history: composer_history.State = .{},
|
||||
@@ -66,6 +68,7 @@ pub const Runtime = struct {
|
||||
pub fn deinit(self: *Runtime, alloc: Allocator) void {
|
||||
input_reset.resetPendingTextScalarWithTrace(&self.text_scalar, "shutdown");
|
||||
self.paste.deinit(alloc);
|
||||
self.status_menu.close();
|
||||
self.usage_menu.close();
|
||||
self.edit_state.deinit(alloc);
|
||||
self.picker.deinit(alloc);
|
||||
|
||||
@@ -572,6 +572,11 @@ fn writeTerminalSafe(writer: *std.Io.Writer, alloc: Allocator, raw: []const u8)
|
||||
try writer.writeAll(encoded.bytes);
|
||||
}
|
||||
|
||||
/// A terminal-safe copy of `raw`, allocated with `alloc`.
|
||||
fn safeText(alloc: Allocator, raw: []const u8) ![]const u8 {
|
||||
return (try text_utils.encodeTerminalSafe(alloc, raw, std.math.maxInt(usize))).bytes;
|
||||
}
|
||||
|
||||
fn gatewayProviderConnected(auth: auth_runtime.StatusSnapshot) bool {
|
||||
const source = auth.active_source orelse return auth.gateway_connected;
|
||||
return auth.gateway_connected or (source != .chatgpt_subscription and source != .grok_subscription and source != .configured);
|
||||
@@ -689,6 +694,12 @@ pub const StatusSnapshot = struct {
|
||||
/// when the caller has a mode registry with modes.
|
||||
modes: ?SessionModes = null,
|
||||
workspace_root: []const u8,
|
||||
/// The interactive shell's session, which only the `/status` panel
|
||||
/// shows. `fx status` runs outside any session and leaves it null.
|
||||
session: ?ShellSession = null,
|
||||
/// Why the profile settings could not be read, for the `/status` panel.
|
||||
/// The credential they select is then unknown, so no auth row is shown.
|
||||
settings_error: ?[]const u8 = null,
|
||||
history_turns: usize,
|
||||
session_permission_grants: usize,
|
||||
agent_step_limit: usize,
|
||||
@@ -700,6 +711,78 @@ pub const StatusSnapshot = struct {
|
||||
all: []const mode_contract.ModeSpec,
|
||||
};
|
||||
|
||||
pub const ShellSession = union(enum) {
|
||||
/// No session could be created, so the shell saves nothing.
|
||||
unavailable,
|
||||
open: struct {
|
||||
id: []const u8,
|
||||
/// `fx --resume <id>` opens it now: its log is on disk (D90).
|
||||
saved: bool,
|
||||
},
|
||||
};
|
||||
|
||||
/// One row of the `/status` panel. Both texts are terminal-safe.
|
||||
pub const PanelRow = struct {
|
||||
label: []const u8,
|
||||
value: []const u8,
|
||||
};
|
||||
|
||||
/// The `/status` panel's rows, in order, the session first. Everything
|
||||
/// is allocated with `alloc`, which should be an arena.
|
||||
pub fn panelRows(self: StatusSnapshot, alloc: Allocator) ![]PanelRow {
|
||||
var rows: std.ArrayList(PanelRow) = .empty;
|
||||
if (self.session) |session| try rows.append(alloc, .{ .label = "Session", .value = switch (session) {
|
||||
.unavailable => "none · this shell can't save sessions",
|
||||
.open => |open| try alloc.print("{s} · {s}", .{
|
||||
try safeText(alloc, open.id),
|
||||
if (open.saved) "saved" else "not saved yet",
|
||||
}),
|
||||
} });
|
||||
try rows.append(alloc, .{ .label = "Model", .value = try safeText(alloc, self.model) });
|
||||
if (self.provider != .gateway) {
|
||||
// A configured provider's name lives in `self.provider`, this
|
||||
// call's copy, so it is copied out with the rest.
|
||||
try rows.append(alloc, .{ .label = "Provider", .value = try safeText(alloc, providerDisplayName(&self.provider)) });
|
||||
}
|
||||
if (self.provider_endpoint) |endpoint| {
|
||||
try rows.append(alloc, .{ .label = "Endpoint", .value = try safeText(alloc, endpoint) });
|
||||
}
|
||||
if (self.provider != .gateway) {
|
||||
var connections: std.Io.Writer.Allocating = .init(alloc);
|
||||
try self.writeConnections(&connections.writer);
|
||||
try rows.append(alloc, .{ .label = "Connected", .value = try safeText(alloc, connections.written()) });
|
||||
}
|
||||
if (self.settings_error) |error_name| {
|
||||
try rows.append(alloc, .{ .label = "Settings", .value = try alloc.print("couldn't be read: {s}", .{error_name}) });
|
||||
} else {
|
||||
var auth: std.Io.Writer.Allocating = .init(alloc);
|
||||
try writeTerminalSafe(&auth.writer, alloc, self.auth.activeSourceLabel());
|
||||
if (self.auth.expired) try auth.writer.writeAll(" · expired");
|
||||
if (self.auth.refreshable()) try auth.writer.writeAll(" · refreshable");
|
||||
try rows.append(alloc, .{ .label = "Auth", .value = auth.written() });
|
||||
if (self.auth_help) |help| try rows.append(alloc, .{ .label = "Auth help", .value = try safeText(alloc, help) });
|
||||
if (self.auth.team) |team| try rows.append(alloc, .{ .label = "Team", .value = try safeText(alloc, team) });
|
||||
}
|
||||
try rows.append(alloc, .{ .label = "Permissions", .value = permissions.permissionModeDisplayLabel(self.permission_mode) });
|
||||
try rows.append(alloc, .{ .label = "Workspace", .value = try safeText(alloc, self.workspace_root) });
|
||||
try rows.append(alloc, .{ .label = "Turns", .value = try alloc.print("{d}", .{self.history_turns}) });
|
||||
try rows.append(alloc, .{ .label = "Session grants", .value = try alloc.print("{d}", .{self.session_permission_grants}) });
|
||||
try rows.append(alloc, .{
|
||||
.label = "Step limit",
|
||||
.value = if (self.agent_step_limit == 0) "unlimited" else try alloc.print("{d}", .{self.agent_step_limit}),
|
||||
});
|
||||
try rows.append(alloc, .{ .label = "Ultrafast", .value = if (self.ultrafast_requested) "requested" else "not requested" });
|
||||
try rows.append(alloc, .{ .label = "Updates", .value = try safeText(alloc, self.update_channel) });
|
||||
try rows.append(alloc, .{
|
||||
.label = "Build",
|
||||
.value = if (self.build_revision.len > 0)
|
||||
try alloc.print("{s} · {s}", .{ try safeText(alloc, self.build_channel), try safeText(alloc, self.build_revision) })
|
||||
else
|
||||
try safeText(alloc, self.build_channel),
|
||||
});
|
||||
return rows.toOwnedSlice(alloc);
|
||||
}
|
||||
|
||||
pub fn render(self: StatusSnapshot, alloc: Allocator, format: OutputFormat) ![]u8 {
|
||||
return switch (format) {
|
||||
.text => self.renderText(alloc),
|
||||
@@ -764,38 +847,6 @@ pub const StatusSnapshot = struct {
|
||||
return try out.toOwnedSlice();
|
||||
}
|
||||
|
||||
pub fn renderInteractiveBody(self: StatusSnapshot, alloc: Allocator) ![]u8 {
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer out.deinit();
|
||||
|
||||
try out.writer.print("model={s}\n", .{self.model});
|
||||
if (self.provider != .gateway) {
|
||||
try out.writer.print("model_source={s}\n", .{providerDisplayName(&self.provider)});
|
||||
}
|
||||
if (self.provider_endpoint) |endpoint| try out.writer.print("provider_endpoint={s}\n", .{endpoint});
|
||||
try out.writer.print("update_channel={s}\n", .{self.update_channel});
|
||||
try out.writer.print("build_channel={s}\n", .{self.build_channel});
|
||||
if (self.build_revision.len > 0) {
|
||||
try out.writer.print("build_revision={s}\n", .{self.build_revision});
|
||||
}
|
||||
try out.writer.print("auth={s}\n", .{self.auth.activeSourceLabel()});
|
||||
if (self.provider != .gateway) {
|
||||
try out.writer.writeAll("connected_providers=");
|
||||
try self.writeConnections(&out.writer);
|
||||
try out.writer.writeByte('\n');
|
||||
}
|
||||
try out.writer.print("auth_refreshable={}\n", .{self.auth.refreshable()});
|
||||
if (self.auth.expired) try out.writer.writeAll("auth_expired=true\n");
|
||||
if (self.auth_help) |help| try out.writer.print("auth_help={s}\n", .{help});
|
||||
if (self.auth.team) |team| try out.writer.print("team={s}\n", .{team});
|
||||
try out.writer.print("permission_mode={s}\n", .{permissions.permissionModeDisplayLabel(self.permission_mode)});
|
||||
try out.writer.print("workspace={s}\n", .{self.workspace_root});
|
||||
try out.writer.print("history_turns={d}\n", .{self.history_turns});
|
||||
try out.writer.print("session_permission_grants={d}\n", .{self.session_permission_grants});
|
||||
try out.writer.print("agent_step_limit={d}\nultrafast_requested={}", .{ self.agent_step_limit, self.ultrafast_requested });
|
||||
return try out.toOwnedSlice();
|
||||
}
|
||||
|
||||
pub fn renderJson(self: StatusSnapshot, alloc: Allocator) ![]u8 {
|
||||
var out: std.Io.Writer.Allocating = .init(alloc);
|
||||
defer out.deinit();
|
||||
@@ -2464,9 +2515,11 @@ test "MCP config diagnostic renders in status text and JSON but not interactive
|
||||
"\"mcp_config_error\":\"McpConfigInvalidJson\"",
|
||||
) != null);
|
||||
|
||||
const interactive = try snapshot.renderInteractiveBody(std.testing.allocator);
|
||||
defer std.testing.allocator.free(interactive);
|
||||
try std.testing.expect(std.mem.find(u8, interactive, "mcp_config_error") == null);
|
||||
var arena = std.heap.ArenaAllocator.init(std.testing.allocator);
|
||||
defer arena.deinit();
|
||||
for (try snapshot.panelRows(arena.allocator())) |row| {
|
||||
try std.testing.expect(std.mem.find(u8, row.value, "McpConfigInvalidJson") == null);
|
||||
}
|
||||
}
|
||||
|
||||
test "MCP config warning renders bounded status text and JSON" {
|
||||
|
||||
@@ -1500,9 +1500,11 @@ pub const Session = struct {
|
||||
return lines.items;
|
||||
}
|
||||
|
||||
/// The session is on disk: it has a turn, ended or open.
|
||||
/// The session is on disk, so `fx --resume` opens it: from its first
|
||||
/// turn, ended or open, or from its open when it was resumed or forked.
|
||||
/// A fork before the first prompt holds no turn and is saved (D90).
|
||||
pub fn saved(self: *const Session) bool {
|
||||
return self.last_turn > 0;
|
||||
return self.handle.onDisk();
|
||||
}
|
||||
|
||||
/// The session's folder under `~/.fx/sessions/v2`, for display. Caller owns it.
|
||||
@@ -1521,12 +1523,14 @@ pub const Session = struct {
|
||||
/// A copy of its capability that a background reader still holds keeps
|
||||
/// reading, and stores nothing more (D44).
|
||||
pub fn close(self: *Session) void {
|
||||
// Before the close, which forgets whether the log is on disk.
|
||||
const was_saved = self.saved();
|
||||
self.host.detach();
|
||||
self.handle.close() catch |err| debug_trace.logf("session", "event=sessions_v2_close_failed session={s} err={s}", .{ self.id(), @errorName(err) });
|
||||
if (self.root) traceWiring("Close", self.id(), "", self.store.pid);
|
||||
if (self.turn_open) debug_trace.logf("session", "event=sessions_v2_turn_closed_open session={s} streamed={d}", .{ self.id(), self.streamed.items.len });
|
||||
// Before `release`: the id lives in the handle.
|
||||
if (!self.saved()) self.removeUnsavedFiles();
|
||||
if (!was_saved) self.removeUnsavedFiles();
|
||||
self.handle.release();
|
||||
self.destroyInner();
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ const tool_dispatch = @import("../tooling/tool_dispatch.zig");
|
||||
const text_utils = @import("../shared/text_utils.zig");
|
||||
const types = @import("../shared/types.zig");
|
||||
const render_request = @import("../../ui/render_request.zig");
|
||||
const runtime_profile = @import("../hosts/runtime_profile.zig");
|
||||
const status_menu = @import("status_menu.zig");
|
||||
|
||||
const freeStringList = collections.freeStringList;
|
||||
const containsIgnoreCase = text_utils.containsIgnoreCase;
|
||||
@@ -275,19 +277,36 @@ pub fn Commands(comptime App: type) type {
|
||||
return "stable";
|
||||
}
|
||||
|
||||
pub fn showStatus(app: *App) !void {
|
||||
const provider = provider_runtime.provider(app);
|
||||
/// The rows the `/status` panel opens with. The profile settings are
|
||||
/// read here, once, for the credential they select; a panel whose
|
||||
/// settings cannot be read says so in a row instead of showing auth.
|
||||
pub fn openStatusSnapshot(app: *App) !status_menu.Snapshot {
|
||||
var preferred: ?types.CredentialSource = null;
|
||||
if (provider == .gateway and app.auth.credentialSource() == null) {
|
||||
var settings = config_runtime.loadMergedSettings(app.alloc, app.workspace_root) catch |err| {
|
||||
try writeSettingsLoadError(app, err);
|
||||
return;
|
||||
};
|
||||
defer settings.deinit(app.alloc);
|
||||
preferred = settings.credential_source;
|
||||
var settings_error: ?[]const u8 = null;
|
||||
if (provider_runtime.provider(app) == .gateway and app.auth.credentialSource() == null) {
|
||||
if (config_runtime.loadMergedSettings(app.alloc, app.workspace_root)) |loaded| {
|
||||
var settings = loaded;
|
||||
defer settings.deinit(app.alloc);
|
||||
preferred = settings.credential_source;
|
||||
} else |err| {
|
||||
settings_error = @errorName(err);
|
||||
}
|
||||
}
|
||||
return statusSnapshot(app, preferred, settings_error);
|
||||
}
|
||||
|
||||
/// New rows for the open `/status` panel when a running turn changed
|
||||
/// what `shown` was built from; null while nothing did. Reads no file.
|
||||
pub fn refreshedStatusSnapshot(app: *App, shown: *const status_menu.Snapshot) !?status_menu.Snapshot {
|
||||
if (std.meta.eql(statusFacts(app, shellSession(app)), shown.facts)) return null;
|
||||
return try statusSnapshot(app, shown.preferred, shown.settings_error);
|
||||
}
|
||||
|
||||
fn statusSnapshot(app: *App, preferred: ?types.CredentialSource, settings_error: ?[]const u8) !status_menu.Snapshot {
|
||||
const provider = provider_runtime.provider(app);
|
||||
const auth = app.auth.statusSnapshot(provider, preferred);
|
||||
const text = try (output_contracts.StatusSnapshot{
|
||||
const session = shellSession(app);
|
||||
const status: output_contracts.StatusSnapshot = .{
|
||||
.model = provider_runtime.model(app),
|
||||
.provider = provider,
|
||||
.provider_endpoint = if (comptime @hasField(App, "provider_selection"))
|
||||
@@ -301,13 +320,42 @@ pub fn Commands(comptime App: type) type {
|
||||
.auth_help = auth.missingHelp(.interactive),
|
||||
.permission_mode = app.permission_engine.mode,
|
||||
.workspace_root = app.workspace_root,
|
||||
.session = session,
|
||||
.settings_error = settings_error,
|
||||
.history_turns = app.session.historyLen(),
|
||||
.session_permission_grants = permissions.liveSessionGrantCount(app.permission_engine.grants.items),
|
||||
.agent_step_limit = app.agent_step_limit,
|
||||
.ultrafast_requested = app.worker.agent_turn_settings.ultrafast_mode,
|
||||
}).renderInteractiveBody(app.alloc);
|
||||
defer app.alloc.free(text);
|
||||
try app.writeDomainNotice(.{ .topic = "status", .tone = .neutral, .body = text }, true);
|
||||
};
|
||||
var arena = std.heap.ArenaAllocator.init(app.alloc);
|
||||
errdefer arena.deinit();
|
||||
const rows = try status.panelRows(arena.allocator());
|
||||
return .{
|
||||
.arena = arena,
|
||||
.rows = rows,
|
||||
.facts = statusFacts(app, session),
|
||||
.preferred = preferred,
|
||||
.settings_error = settings_error,
|
||||
};
|
||||
}
|
||||
|
||||
fn statusFacts(app: *App, session: ?output_contracts.StatusSnapshot.ShellSession) status_menu.Facts {
|
||||
return .{
|
||||
.history_turns = app.session.historyLen(),
|
||||
.session_saved = if (session) |shown| switch (shown) {
|
||||
.unavailable => null,
|
||||
.open => |open| open.saved,
|
||||
} else null,
|
||||
};
|
||||
}
|
||||
|
||||
/// The open session as `/status` shows it, so its id can be copied
|
||||
/// before quitting. Saved means a resume opens it
|
||||
/// (`tests/tla/session/ShellSession.tla` StatusSavedIsOnDisk).
|
||||
fn shellSession(app: *App) ?output_contracts.StatusSnapshot.ShellSession {
|
||||
if (comptime !runtime_profile.allows(App, .durable_sessions) or !@hasField(App, "session_persistence")) return null;
|
||||
const v2 = app.session_persistence.v2 orelse return .unavailable;
|
||||
return .{ .open = .{ .id = v2.id(), .saved = v2.saved() } };
|
||||
}
|
||||
|
||||
pub fn handleSettings(app: *App, rest: []const u8) !void {
|
||||
@@ -2045,7 +2093,7 @@ fn writeFixtureFile(dir: std.Io.Dir, sub_path: []const u8, text: []const u8) !vo
|
||||
try file.writeStreamingAll(io_mod.getIo(), text);
|
||||
}
|
||||
|
||||
test "session_commands showStatus writes session status snapshot" {
|
||||
test "session_commands status snapshot builds the panel rows and writes no transcript" {
|
||||
const alloc = std.testing.allocator;
|
||||
var tmp = std.testing.tmpDir(.{});
|
||||
defer tmp.cleanup();
|
||||
@@ -2058,19 +2106,29 @@ test "session_commands showStatus writes session status snapshot" {
|
||||
app.permission_engine.mode = .auto;
|
||||
app.session.history_len = 7;
|
||||
|
||||
try Commands(FakeApp).showStatus(&app);
|
||||
var snapshot = try Commands(FakeApp).openStatusSnapshot(&app);
|
||||
defer snapshot.deinit();
|
||||
try expectStatusRow(snapshot, "Model", "anthropic/test-model");
|
||||
try expectStatusRow(snapshot, "Auth", "missing");
|
||||
try expectStatusRow(snapshot, "Permissions", "auto");
|
||||
try expectStatusRow(snapshot, "Turns", "7");
|
||||
try expectStatusRow(snapshot, "Step limit", "24");
|
||||
try std.testing.expectEqual(@as(usize, 0), app.transcript.items.len);
|
||||
try std.testing.expect(try Commands(FakeApp).refreshedStatusSnapshot(&app, &snapshot) == null);
|
||||
|
||||
try expectTranscriptContains(&app, "* status: model=anthropic/test-model\n");
|
||||
try expectTranscriptContains(&app, "auth=missing\n");
|
||||
try expectTranscriptContains(&app, "auth_refreshable=false\n");
|
||||
try expectTranscriptContains(&app, "permission_mode=auto\n");
|
||||
try expectTranscriptContains(&app, "history_turns=7\n");
|
||||
try expectTranscriptContains(&app, "agent_step_limit=24\n");
|
||||
|
||||
app.clearTranscript();
|
||||
app.permission_engine.mode = .yolo;
|
||||
try Commands(FakeApp).showStatus(&app);
|
||||
try expectTranscriptContains(&app, "permission_mode=full access\n");
|
||||
app.session.history_len = 8;
|
||||
var refreshed = (try Commands(FakeApp).refreshedStatusSnapshot(&app, &snapshot)).?;
|
||||
defer refreshed.deinit();
|
||||
try expectStatusRow(refreshed, "Permissions", "full access");
|
||||
try expectStatusRow(refreshed, "Turns", "8");
|
||||
}
|
||||
|
||||
fn expectStatusRow(snapshot: status_menu.Snapshot, label: []const u8, value: []const u8) !void {
|
||||
for (snapshot.rows) |row| {
|
||||
if (std.mem.eql(u8, row.label, label)) return std.testing.expectEqualStrings(value, row.value);
|
||||
}
|
||||
return error.TestExpectedEqual;
|
||||
}
|
||||
|
||||
test "session_commands history setting toggles durable input history" {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
//! State of the `/status` panel: the rows it shows, built from
|
||||
//! `StatusSnapshot.panelRows` when it opens, and how far they are scrolled.
|
||||
//! The panel lives in the footer and writes nothing to the transcript.
|
||||
|
||||
const std = @import("std");
|
||||
const output_contracts = @import("../output/output_contracts.zig");
|
||||
const types = @import("../shared/types.zig");
|
||||
|
||||
pub const Row = output_contracts.StatusSnapshot.PanelRow;
|
||||
|
||||
/// What the panel's rows depend on that changes with no keystroke while it
|
||||
/// is open: a running turn that saves the session or ends. The rows are
|
||||
/// rebuilt when these differ from the ones they were built from.
|
||||
pub const Facts = struct {
|
||||
history_turns: usize = 0,
|
||||
/// Null when the shell has no session.
|
||||
session_saved: ?bool = null,
|
||||
};
|
||||
|
||||
/// The panel's rows and what they were built from. Owns everything.
|
||||
pub const Snapshot = struct {
|
||||
arena: std.heap.ArenaAllocator,
|
||||
rows: []const Row,
|
||||
facts: Facts,
|
||||
/// The credential the profile settings select, read once at open so a
|
||||
/// rebuild reads no file.
|
||||
preferred: ?types.CredentialSource,
|
||||
/// Why the settings could not be read at open, an error name.
|
||||
settings_error: ?[]const u8,
|
||||
|
||||
pub fn deinit(self: *Snapshot) void {
|
||||
self.arena.deinit();
|
||||
self.* = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
pub const State = struct {
|
||||
active: bool = false,
|
||||
/// The first panel line shown when they do not all fit.
|
||||
scroll: usize = 0,
|
||||
snapshot: ?Snapshot = null,
|
||||
|
||||
/// Takes ownership of `snapshot`.
|
||||
pub fn open(self: *State, snapshot: Snapshot) void {
|
||||
self.close();
|
||||
self.* = .{ .active = true, .snapshot = snapshot };
|
||||
}
|
||||
|
||||
/// Replaces the rows and keeps the scroll position. Takes ownership of
|
||||
/// `snapshot`.
|
||||
pub fn replace(self: *State, snapshot: Snapshot) void {
|
||||
if (self.snapshot) |*old| old.deinit();
|
||||
self.snapshot = snapshot;
|
||||
}
|
||||
|
||||
pub fn close(self: *State) void {
|
||||
if (self.snapshot) |*old| old.deinit();
|
||||
self.* = .{};
|
||||
}
|
||||
|
||||
pub fn rows(self: *const State) []const Row {
|
||||
return if (self.snapshot) |*snapshot| snapshot.rows else &.{};
|
||||
}
|
||||
|
||||
/// ↑↓ when the panel's `total_lines` do not fit its `visible_lines`.
|
||||
/// Returns whether the panel moved.
|
||||
pub fn scrollBy(self: *State, delta: i32, total_lines: usize, visible_lines: usize) bool {
|
||||
if (!self.active or delta == 0) return false;
|
||||
const last_start = total_lines -| @max(visible_lines, 1);
|
||||
const start = @min(self.scroll, last_start);
|
||||
const next = if (delta > 0) @min(start +| 1, last_start) else start -| 1;
|
||||
if (next == self.scroll) return false;
|
||||
self.scroll = next;
|
||||
return true;
|
||||
}
|
||||
};
|
||||
@@ -555,6 +555,11 @@ pub const Session = struct {
|
||||
return s.inner.readPage(gpa, from, direction, limit);
|
||||
}
|
||||
|
||||
/// Whether the session's log is on disk now, so a resume finds it.
|
||||
pub fn onDisk(s: Session) bool {
|
||||
return s.inner.onDisk();
|
||||
}
|
||||
|
||||
/// An owned copy of the folded state; free with `State.deinit`.
|
||||
pub fn state(s: Session, gpa: std.mem.Allocator) error{OutOfMemory}!State {
|
||||
return s.inner.stateCopy(gpa);
|
||||
|
||||
@@ -249,6 +249,22 @@ pub const Session = struct {
|
||||
return session.identity.id;
|
||||
}
|
||||
|
||||
/// Whether this open session's log is on disk, so a resume finds it:
|
||||
/// from the publish of its first turn (D2), or from its open when it
|
||||
/// was resumed or forked (D58). A failed session keeps the log it had.
|
||||
/// One whose first publish failed counts as not on disk, though the
|
||||
/// publish may have landed before the failure (D90).
|
||||
pub fn onDisk(session: *Session) bool {
|
||||
const io = session.env.s.io;
|
||||
session.mutex.lockUncancelable(io);
|
||||
defer session.mutex.unlock(io);
|
||||
return switch (session.phase) {
|
||||
.held, .closed => false,
|
||||
.live => true,
|
||||
.failed => |live| live != null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Whether appending `events` leaves this session held in memory, so it
|
||||
/// needs no disk, not even the root folder (D2). A publishing thread
|
||||
/// always opens the root first, so a stale answer is harmless.
|
||||
|
||||
@@ -2864,6 +2864,9 @@ const App = struct {
|
||||
if (try app_commands.Handlers(App).collectUsageDashboardFacts(self)) {
|
||||
RenderAppRuntime.requestActiveSurfaceFrame(self, .footer);
|
||||
}
|
||||
if (try app_commands.Handlers(App).collectStatusMenuFacts(self)) {
|
||||
RenderAppRuntime.requestActiveSurfaceFrame(self, .footer);
|
||||
}
|
||||
try app_commands.Handlers(App).collectMcpHostFacts(self);
|
||||
try app_commands.Handlers(App).collectShellSnapshotFacts(self);
|
||||
if (comptime host_profile.native_auth or host_profile.js_host_auth) {
|
||||
|
||||
@@ -5,6 +5,7 @@ const text_utils = @import("../../core/shared/text_utils.zig");
|
||||
const workspace_access = @import("../../core/workspace/workspace_access.zig");
|
||||
const workspace_menu = @import("../../core/workspace/workspace_menu.zig");
|
||||
const render_input = @import("render_input.zig");
|
||||
const status_menu_presentation = @import("status_menu_presentation.zig");
|
||||
const usage_presentation = @import("usage_presentation.zig");
|
||||
|
||||
pub const usageVisibleModelItems = usage_presentation.visibleModelItems;
|
||||
@@ -27,9 +28,9 @@ const ChoiceView = struct {
|
||||
};
|
||||
|
||||
pub fn desiredRowCount(projection: CompactCommandMenuProjection, width: u16) u16 {
|
||||
_ = width;
|
||||
const count: usize = switch (projection) {
|
||||
.statusline => settings_row_offset + settings_catalog.statuslineChoiceCount(),
|
||||
.status => |status| status_menu_presentation.desiredRowCount(status, width),
|
||||
.usage => |usage| usage_presentation.desiredRowCount(usage),
|
||||
.workspace => |workspace| workspace_pinned_rows +
|
||||
workspace_menu.State.rowCount(workspace.entries),
|
||||
@@ -48,6 +49,7 @@ pub noinline fn composeCompactCommandMenuRow(
|
||||
if (width == 0 or row_index >= visible_rows) return empty;
|
||||
return switch (projection) {
|
||||
.statusline => composeSettingsRow(alloc, projection, row_index, visible_rows, width),
|
||||
.status => |status| status_menu_presentation.composeRow(alloc, status, row_index, visible_rows, width),
|
||||
.usage => |usage| usage_presentation.composeRow(alloc, usage, row_index, visible_rows, width),
|
||||
.workspace => |workspace| composeWorkspaceRow(alloc, workspace, row_index, visible_rows, width),
|
||||
};
|
||||
@@ -63,7 +65,7 @@ fn composeSettingsRow(
|
||||
const empty: std.ArrayList(u8) = .empty;
|
||||
const statusline = switch (projection) {
|
||||
.statusline => |value| value,
|
||||
.usage, .workspace => return empty,
|
||||
.status, .usage, .workspace => return empty,
|
||||
};
|
||||
const choice_count = settings_catalog.statuslineChoiceCount();
|
||||
if (choice_count == 0) return empty;
|
||||
@@ -103,7 +105,7 @@ fn choiceView(projection: CompactCommandMenuProjection, choice_index: usize) ?Ch
|
||||
.selected = choice_index == statusline.selected_index % settings_catalog.statuslineChoiceCount(),
|
||||
};
|
||||
},
|
||||
.usage, .workspace => null,
|
||||
.status, .usage, .workspace => null,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -749,6 +749,15 @@ pub fn composeCompactCommandMenuHintRow(
|
||||
"↑↓ move ←→ change esc",
|
||||
"←→ esc",
|
||||
},
|
||||
.status => |status_menu| if (status_menu.overflows) [_][]const u8{
|
||||
"↑↓ scroll esc close",
|
||||
"↑↓ scroll esc",
|
||||
"↑↓ esc",
|
||||
} else [_][]const u8{
|
||||
"esc close",
|
||||
"esc close",
|
||||
"esc",
|
||||
},
|
||||
.usage => |usage_menu| if (!render_input.usageShowsTables(usage_menu)) [_][]const u8{
|
||||
"esc close",
|
||||
"esc close",
|
||||
|
||||
@@ -10,6 +10,7 @@ const picker_state = @import("../../core/input/picker_state.zig");
|
||||
const session_adapter = @import("../../core/session/session_adapter.zig");
|
||||
const session_catalog = @import("../../core/session/session_catalog.zig");
|
||||
const session_store = @import("../../core/session/session_store.zig");
|
||||
const status_menu_presentation = @import("status_menu_presentation.zig");
|
||||
const usage_presentation = @import("usage_presentation.zig");
|
||||
const command_specs = @import("../../core/slash_commands/command_specs.zig");
|
||||
const settings_catalog = @import("../../core/config/settings_catalog.zig");
|
||||
@@ -22,6 +23,7 @@ const file_index = @import("../../core/workspace/file_index.zig");
|
||||
const workspace_menu = @import("../../core/workspace/workspace_menu.zig");
|
||||
const activity_runtime = @import("../../core/output/activity_runtime.zig");
|
||||
const transcript_presentation = @import("../../core/output/transcript_presentation.zig");
|
||||
const status_menu = @import("../../core/session/status_menu.zig");
|
||||
const usage_menu = @import("../../core/session/usage_menu.zig");
|
||||
const core_input_runtime = @import("../../core/input/runtime.zig");
|
||||
const ui_render = @import("../render.zig");
|
||||
@@ -231,8 +233,24 @@ pub fn workspaceMenuProjection(
|
||||
};
|
||||
}
|
||||
|
||||
/// The `/status` panel: its rows, how far they are scrolled, and whether
|
||||
/// they do not all fit, so its hint offers ↑↓.
|
||||
pub const StatusMenuProjection = struct {
|
||||
active: bool = false,
|
||||
rows: []const status_menu.Row = &.{},
|
||||
scroll: usize = 0,
|
||||
overflows: bool = false,
|
||||
};
|
||||
|
||||
pub fn statusMenuProjection(menu: *const status_menu.State, cols: u16, terminal_rows: u16) StatusMenuProjection {
|
||||
var projection: StatusMenuProjection = .{ .active = menu.active, .rows = menu.rows(), .scroll = menu.scroll };
|
||||
projection.overflows = status_menu_presentation.overflows(projection, cols, terminal_rows);
|
||||
return projection;
|
||||
}
|
||||
|
||||
pub const CompactCommandMenuProjection = union(enum) {
|
||||
statusline: StatuslineMenuProjection,
|
||||
status: StatusMenuProjection,
|
||||
usage: UsageMenuProjection,
|
||||
workspace: WorkspaceMenuProjection,
|
||||
};
|
||||
@@ -357,6 +375,7 @@ pub const RenderContext = struct {
|
||||
session_menu: SessionMenuProjection = .{},
|
||||
fork_menu: ForkMenuProjection = .{},
|
||||
statusline_menu: StatuslineMenuProjection = .{},
|
||||
status_menu: StatusMenuProjection = .{},
|
||||
usage_menu: UsageMenuProjection = .{},
|
||||
workspace_menu: WorkspaceMenuProjection = .{},
|
||||
upgrade_status: []const u8 = "",
|
||||
@@ -375,6 +394,7 @@ pub const RenderContext = struct {
|
||||
|
||||
pub fn activeCompactCommandMenu(ctx: RenderContext) ?CompactCommandMenuProjection {
|
||||
if (ctx.statusline_menu.active) return .{ .statusline = ctx.statusline_menu };
|
||||
if (ctx.status_menu.active) return .{ .status = ctx.status_menu };
|
||||
if (ctx.usage_menu.active) return .{ .usage = ctx.usage_menu };
|
||||
if (ctx.workspace_menu.active) return .{ .workspace = ctx.workspace_menu };
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
//! The `/status` panel in the footer: a title, then one line per row with
|
||||
//! its label and value. A value too long for its column continues on the
|
||||
//! next lines, under the value, so nothing is cut off; when the lines do
|
||||
//! not all fit, ↑↓ scrolls them.
|
||||
|
||||
const std = @import("std");
|
||||
const display_width = @import("../../core/shared/display_width.zig");
|
||||
const status_menu = @import("../../core/session/status_menu.zig");
|
||||
const render_input = @import("render_input.zig");
|
||||
const row_text = @import("row_text.zig");
|
||||
const ui_render = @import("../render.zig");
|
||||
|
||||
const Allocator = std.mem.Allocator;
|
||||
const Projection = render_input.StatusMenuProjection;
|
||||
const Row = status_menu.Row;
|
||||
|
||||
const indent: usize = 2;
|
||||
const column_gap: usize = 4;
|
||||
/// The narrowest value column a terminal gets before the gap shrinks.
|
||||
const min_value_width: usize = 24;
|
||||
/// The title and the blank row under it.
|
||||
const header_rows: u16 = 2;
|
||||
|
||||
/// Rows the panel asks for: its header and every line.
|
||||
pub fn desiredRowCount(projection: Projection, width: u16) u16 {
|
||||
return std.math.cast(u16, header_rows + lineCount(projection.rows, width)) orelse std.math.maxInt(u16);
|
||||
}
|
||||
|
||||
/// Rows the footer gives the panel in a terminal of `terminal_rows`, as
|
||||
/// `surface_frame.zig` sizes every compact command menu.
|
||||
pub fn footerRows(projection: Projection, width: u16, terminal_rows: u16) u16 {
|
||||
return @min(desiredRowCount(projection, width), terminal_rows -| 3);
|
||||
}
|
||||
|
||||
/// Lines that fit in the `visible_rows` the footer gives the panel.
|
||||
pub fn visibleLineCount(visible_rows: u16) usize {
|
||||
return visible_rows -| headerRowsFor(visible_rows);
|
||||
}
|
||||
|
||||
/// Whether the panel's lines do not all fit in its footer rows, so ↑↓
|
||||
/// scrolls them.
|
||||
pub fn overflows(projection: Projection, width: u16, terminal_rows: u16) bool {
|
||||
return lineCount(projection.rows, width) > visibleLineCount(footerRows(projection, width, terminal_rows));
|
||||
}
|
||||
|
||||
/// The panel's lines at `width`, each row's value wrapped under its column.
|
||||
pub fn lineCount(rows: []const Row, width: u16) usize {
|
||||
const value_width = valueWidth(rows, width);
|
||||
var count: usize = 0;
|
||||
for (rows) |row| count += wrappedLineCount(row.value, value_width);
|
||||
return count;
|
||||
}
|
||||
|
||||
pub fn composeRow(
|
||||
alloc: Allocator,
|
||||
projection: Projection,
|
||||
row_index: u16,
|
||||
visible_rows: u16,
|
||||
width: u16,
|
||||
) !std.ArrayList(u8) {
|
||||
var row: std.ArrayList(u8) = .empty;
|
||||
errdefer row.deinit(alloc);
|
||||
if (width == 0 or row_index >= visible_rows) return row;
|
||||
const header = headerRowsFor(visible_rows);
|
||||
if (row_index < header) {
|
||||
if (row_index == 0) {
|
||||
try row.appendSlice(alloc, ui_render.selected_completion_style);
|
||||
try row_text.appendSingleLineEllipsized(alloc, &row, "Status", width);
|
||||
try row.appendSlice(alloc, ui_render.reset_style);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
const total = lineCount(projection.rows, width);
|
||||
const visible = visibleLineCount(visible_rows);
|
||||
const start = @min(projection.scroll, total -| visible);
|
||||
const wanted = start + (row_index - header);
|
||||
const value_col = valueColumn(projection.rows, width);
|
||||
const value_width = valueWidth(projection.rows, width);
|
||||
|
||||
var line: usize = 0;
|
||||
for (projection.rows) |panel_row| {
|
||||
var rest = panel_row.value;
|
||||
var first = true;
|
||||
while (first or rest.len > 0) : (first = false) {
|
||||
const cut = nextBreak(rest, value_width);
|
||||
if (line == wanted) {
|
||||
try composeLine(alloc, &row, if (first) panel_row.label else "", rest[0..cut.end], value_col, width);
|
||||
return row;
|
||||
}
|
||||
rest = rest[cut.next..];
|
||||
line += 1;
|
||||
}
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
fn composeLine(
|
||||
alloc: Allocator,
|
||||
row: *std.ArrayList(u8),
|
||||
label: []const u8,
|
||||
value: []const u8,
|
||||
value_col: usize,
|
||||
width: u16,
|
||||
) !void {
|
||||
const total_width: usize = width;
|
||||
try row.appendSlice(alloc, ui_render.system_notice_label_style);
|
||||
try row_text.appendClipped(alloc, row, " ", width);
|
||||
try row_text.appendSingleLineEllipsized(alloc, row, label, value_col -| indent -| 1);
|
||||
try row.appendSlice(alloc, ui_render.reset_style);
|
||||
const used = display_width.visibleWidthIgnoringAnsi(row.items);
|
||||
if (used >= total_width or value_col >= total_width) return;
|
||||
try row.appendNTimes(alloc, ' ', value_col -| used);
|
||||
try row.appendSlice(alloc, ui_render.dim_style);
|
||||
try row_text.appendSingleLineEllipsized(alloc, row, value, total_width - value_col);
|
||||
try row.appendSlice(alloc, ui_render.reset_style);
|
||||
}
|
||||
|
||||
/// The title and its blank row while they leave room for a line; just the
|
||||
/// title when only a little does; none in a footer of one row.
|
||||
fn headerRowsFor(visible_rows: u16) u16 {
|
||||
if (visible_rows > header_rows + 1) return header_rows;
|
||||
return @intFromBool(visible_rows > 1);
|
||||
}
|
||||
|
||||
fn valueColumn(rows: []const Row, width: u16) usize {
|
||||
var longest: usize = 0;
|
||||
for (rows) |row| longest = @max(longest, display_width.visibleWidth(row.label));
|
||||
const total_width: usize = width;
|
||||
const roomy = indent + longest + column_gap;
|
||||
if (roomy + min_value_width <= total_width) return roomy;
|
||||
return @min(indent + longest + 2, total_width);
|
||||
}
|
||||
|
||||
fn valueWidth(rows: []const Row, width: u16) usize {
|
||||
return @max(@as(usize, width) -| valueColumn(rows, width), 1);
|
||||
}
|
||||
|
||||
fn wrappedLineCount(value: []const u8, value_width: usize) usize {
|
||||
var rest = value;
|
||||
var count: usize = 0;
|
||||
while (true) {
|
||||
count += 1;
|
||||
const cut = nextBreak(rest, value_width);
|
||||
rest = rest[cut.next..];
|
||||
if (rest.len == 0) return count;
|
||||
}
|
||||
}
|
||||
|
||||
const Break = struct {
|
||||
/// The end of this line.
|
||||
end: usize,
|
||||
/// Where the next line starts.
|
||||
next: usize,
|
||||
};
|
||||
|
||||
/// Where the first line of `text` ends when it may take `width` columns: at
|
||||
/// the last space that fits, or after the last `/`, so a path breaks between
|
||||
/// its folders, or else where the columns run out. A line always takes at
|
||||
/// least one character. A line broken at a space never fills its columns,
|
||||
/// so a full line is always one broken inside a word.
|
||||
fn nextBreak(text: []const u8, width: usize) Break {
|
||||
if (display_width.visibleWidth(text) <= width) return .{ .end = text.len, .next = text.len };
|
||||
var col: usize = 0;
|
||||
var index: usize = 0;
|
||||
var last_space: ?usize = null;
|
||||
var last_slash: ?usize = null;
|
||||
while (index < text.len) {
|
||||
const len = display_width.decodeNextRune(text, index).len;
|
||||
const rune_width = display_width.visibleWidth(text[index .. index + len]);
|
||||
if (col + rune_width > width) break;
|
||||
if (text[index] == ' ') last_space = index;
|
||||
if (text[index] == '/') last_slash = index + 1;
|
||||
col += rune_width;
|
||||
index += len;
|
||||
}
|
||||
if (index == 0) {
|
||||
const len = display_width.decodeNextRune(text, 0).len;
|
||||
return .{ .end = len, .next = len };
|
||||
}
|
||||
if (last_space) |space| if (space > 0) return .{ .end = space, .next = space + 1 };
|
||||
if (last_slash) |slash| return .{ .end = slash, .next = slash };
|
||||
return .{ .end = index, .next = if (text[index] == ' ') index + 1 else index };
|
||||
}
|
||||
@@ -58,6 +58,7 @@
|
||||
{ "file": "tui-slash-extra.test.ts", "weight": 4 },
|
||||
{ "file": "tui-slash-menu.test.ts", "weight": 88 },
|
||||
{ "file": "tui-startup.test.ts", "weight": 7 },
|
||||
{ "file": "tui-status-session.test.ts", "weight": 45 },
|
||||
{ "file": "tui-terminal-tool.test.ts", "weight": 177 },
|
||||
{ "file": "ultrafast-fake-gateway.test.ts", "weight": 8 },
|
||||
{ "file": "vision-route-fake-gateway.test.ts", "weight": 20 },
|
||||
|
||||
@@ -17,6 +17,7 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { FX_BIN, runFx } from "../evals/eval-helpers";
|
||||
import { savedSessionIds, sessionDir, storeRoot } from "./saved-sessions";
|
||||
import { readStatusPanel } from "./status-panel";
|
||||
import {
|
||||
composerContains,
|
||||
FAKE_GATEWAY_MODEL,
|
||||
@@ -238,8 +239,9 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => {
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
}
|
||||
expect(fixture.requests).toHaveLength(6);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText(`provider_endpoint=${fixture.settings.providers.local.base_url}`, TIMEOUT);
|
||||
const status = await readStatusPanel(session, TIMEOUT);
|
||||
expect(status.fields.Provider).toBe("local");
|
||||
expect(status.fields.Endpoint).toBe(fixture.settings.providers.local.base_url);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/compact");
|
||||
const until = Date.now() + TIMEOUT;
|
||||
|
||||
@@ -67,7 +67,7 @@ It drives one tmux shell through this shape:
|
||||
1. Start a shell with temp `HOME`, `ZDOTDIR`, history, and workdir.
|
||||
2. Print shell markers and wrapped lines before Fx starts.
|
||||
3. Launch the freshly built `zig-out/bin/fx`.
|
||||
4. Run no-key slash commands such as `/status`.
|
||||
4. Run no-key slash commands that write to the transcript, such as `/permissions`.
|
||||
5. Quit Fx.
|
||||
6. Print more shell markers in the same shell.
|
||||
7. Relaunch Fx.
|
||||
|
||||
@@ -315,7 +315,7 @@ async function runSameShellRelaunch(outRoot: string, runNumber: number): Promise
|
||||
);
|
||||
|
||||
await launchFx(context, session, "first");
|
||||
await submitSlashCommand(context, session, "/status", "permission_mode", "first-status-visible");
|
||||
await submitSlashCommand(context, session, "/permissions", "* permissions: mode=", "first-notice-visible");
|
||||
await quitFx(context, session, "first");
|
||||
|
||||
await runShellCommand(
|
||||
|
||||
@@ -288,7 +288,7 @@ async function runNativeRelaunchScenario(
|
||||
);
|
||||
|
||||
await launchFx(context, controller, "native-first");
|
||||
await submitSlashCommand(context, controller, "/status", "permission_mode", "native-first-status-visible");
|
||||
await submitSlashCommand(context, controller, "/permissions", "* permissions: mode=", "native-first-notice-visible");
|
||||
await quitFx(context, controller, "native-first");
|
||||
|
||||
await runShellCommand(
|
||||
@@ -333,9 +333,9 @@ async function runCommandKScenario(context: NativeContext, controller: NativeCon
|
||||
await submitSlashCommand(
|
||||
context,
|
||||
controller,
|
||||
"/status",
|
||||
"permission_mode",
|
||||
"native-command-k-status-visible",
|
||||
"/permissions",
|
||||
"* permissions: mode=",
|
||||
"native-command-k-notice-visible",
|
||||
);
|
||||
await quitFx(context, controller, "native-command-k-before-clear");
|
||||
|
||||
|
||||
@@ -8,7 +8,10 @@ export type TapeFrame = {
|
||||
payload: Buffer;
|
||||
};
|
||||
|
||||
export function readTapeFrames(path: string): TapeFrame[] {
|
||||
/// Every frame of the tape at `path`. A final frame cut short, as a killed
|
||||
/// process can leave it, throws unless `ignoreTruncatedTail` is set; then it
|
||||
/// is left out, as `fx replay` leaves it out.
|
||||
export function readTapeFrames(path: string, options: { ignoreTruncatedTail?: boolean } = {}): TapeFrame[] {
|
||||
const bytes = readFileSync(path);
|
||||
const magic = Buffer.from("FXTP\x01", "binary");
|
||||
if (!bytes.subarray(0, magic.length).equals(magic)) {
|
||||
@@ -24,13 +27,19 @@ export function readTapeFrames(path: string): TapeFrame[] {
|
||||
const frames: TapeFrame[] = [];
|
||||
let index = 0;
|
||||
while (offset < bytes.length) {
|
||||
if (offset + 9 > bytes.length) throw new Error(`truncated tape frame header at ${offset}`);
|
||||
if (offset + 9 > bytes.length) {
|
||||
if (options.ignoreTruncatedTail) break;
|
||||
throw new Error(`truncated tape frame header at ${offset}`);
|
||||
}
|
||||
const deltaMs = bytes.readInt32LE(offset);
|
||||
const kind = bytes.readUInt8(offset + 4);
|
||||
const len = bytes.readUInt32LE(offset + 5);
|
||||
const start = offset + 9;
|
||||
const end = start + len;
|
||||
if (end > bytes.length) throw new Error(`truncated tape frame payload at ${offset}`);
|
||||
if (end > bytes.length) {
|
||||
if (options.ignoreTruncatedTail) break;
|
||||
throw new Error(`truncated tape frame payload at ${offset}`);
|
||||
}
|
||||
frames.push({ index: ++index, deltaMs, kind, payload: bytes.subarray(start, end) });
|
||||
offset = end;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,369 @@
|
||||
// The interactive shell's sessions, driven through tmux, and what fx showed
|
||||
// and saved about them: the panels `/status` painted, read back through
|
||||
// fx's virtual terminal (`status-panel.ts`), the id each launch, `/new`,
|
||||
// `/clear`, `/resume` and `/fork` opened, from fx's wiring trace, the exit
|
||||
// line, and the session folders on disk. `tui-status-session.test.ts` and
|
||||
// `tests/tla/drivers/session/` use it.
|
||||
import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { cleanupIsolatedTestHome, createIsolatedTestHome, FX_BIN } from "../evals/eval-helpers";
|
||||
import { showsStatusPanel, statusReports, type StatusReport } from "./status-panel";
|
||||
import { FAKE_GATEWAY_MODEL, fakeGatewayFinalText, heldFakeGatewayFinalText, startDynamicFakeGateway, TmuxSession } from "./tmux-helpers";
|
||||
|
||||
const TIMEOUT = 30_000;
|
||||
|
||||
export type WiringLine = { action: string; session: string; pid: number };
|
||||
|
||||
type Held = ReturnType<typeof heldFakeGatewayFinalText>;
|
||||
|
||||
/// One profile, its workspace, and a fake gateway that answers each prompt
|
||||
/// it was given, by the newest scripted prompt the request holds.
|
||||
export class Profile {
|
||||
readonly root: string;
|
||||
readonly home: string;
|
||||
readonly workspace: string;
|
||||
readonly wiring: string;
|
||||
readonly gateway: ReturnType<typeof startDynamicFakeGateway>;
|
||||
private readonly answers = new Map<string, string>();
|
||||
private readonly held = new Map<string, Held>();
|
||||
private readonly shells: Shell[] = [];
|
||||
private blocked = false;
|
||||
|
||||
constructor(label: string, readonly bin: string = FX_BIN) {
|
||||
this.root = realpathSync(createIsolatedTestHome());
|
||||
this.home = join(this.root, "home");
|
||||
this.workspace = join(this.root, `workspace-${label}`);
|
||||
this.wiring = join(this.root, "wiring.log");
|
||||
mkdirSync(join(this.home, ".fx"), { recursive: true, mode: 0o700 });
|
||||
mkdirSync(this.workspace);
|
||||
this.gateway = startDynamicFakeGateway((body) => {
|
||||
const prompt = [...this.answers.keys(), ...this.held.keys()]
|
||||
.map((text) => [text, body.lastIndexOf(text)] as const)
|
||||
.filter(([, at]) => at >= 0)
|
||||
.sort((a, b) => b[1] - a[1])[0]?.[0];
|
||||
if (prompt === undefined) return fakeGatewayFinalText("UNSCRIPTED PROMPT");
|
||||
return this.held.get(prompt)?.response ?? fakeGatewayFinalText(this.answers.get(prompt)!);
|
||||
});
|
||||
}
|
||||
|
||||
/// Puts a file where `~/.fx/sessions` belongs, so no process started until
|
||||
/// `unblockStore` can create or open a session. Saved sessions wait aside
|
||||
/// and come back unchanged.
|
||||
blockStore() {
|
||||
if (this.blocked) return;
|
||||
if (existsSync(this.sessions)) renameSync(this.sessions, this.aside);
|
||||
writeFileSync(this.sessions, "not a folder\n");
|
||||
this.blocked = true;
|
||||
}
|
||||
|
||||
unblockStore() {
|
||||
if (!this.blocked) return;
|
||||
rmSync(this.sessions);
|
||||
if (existsSync(this.aside)) renameSync(this.aside, this.sessions);
|
||||
this.blocked = false;
|
||||
}
|
||||
|
||||
private get sessions(): string {
|
||||
return join(this.home, ".fx", "sessions");
|
||||
}
|
||||
|
||||
private get aside(): string {
|
||||
return join(this.root, "sessions-set-aside");
|
||||
}
|
||||
|
||||
answer(prompt: string, text: string) {
|
||||
this.answers.set(prompt, text);
|
||||
}
|
||||
|
||||
/// `prompt`'s reply waits until `release` sends `text`.
|
||||
hold(prompt: string): { release(text: string): void } {
|
||||
const held = heldFakeGatewayFinalText();
|
||||
this.held.set(prompt, held);
|
||||
return { release: (text: string) => held.release(text) };
|
||||
}
|
||||
|
||||
async launch(options: { args?: string[]; width?: number; height?: number } = {}): Promise<Shell> {
|
||||
const n = this.shells.length + 1;
|
||||
const shell = new Shell(this, n, await TmuxSession.create({
|
||||
cmd: [this.bin, ...(options.args ?? [])].map(shellQuote).join(" "),
|
||||
cwd: this.workspace,
|
||||
env: this.env(join(this.root, `shell-${n}.fxtape`)),
|
||||
stderrPath: join(this.root, `shell-${n}.stderr`),
|
||||
width: options.width ?? 120,
|
||||
height: options.height ?? 48,
|
||||
remainOnExit: true,
|
||||
}));
|
||||
this.shells.push(shell);
|
||||
await shell.session.waitForStableComposer(TIMEOUT);
|
||||
shell.pid = shell.session.processPid();
|
||||
return shell;
|
||||
}
|
||||
|
||||
env(tape: string): Record<string, string | undefined> {
|
||||
return {
|
||||
HOME: this.home,
|
||||
AI_GATEWAY_API_KEY: "status-session-fixture",
|
||||
VERCEL_OIDC_TOKEN: undefined,
|
||||
FX_GATEWAY_BASE_URL: this.gateway.baseUrl,
|
||||
FX_GATEWAY_CHAT_URL: this.gateway.chatUrl,
|
||||
FX_E2E_GATEWAY_CHAT_URL: this.gateway.chatUrl,
|
||||
FX_E2E_GATEWAY_MODELS_URL: `${this.gateway.baseUrl}/coding-agent/v1/models`,
|
||||
FX_MODEL: FAKE_GATEWAY_MODEL,
|
||||
FX_PERMISSION_MODE: "full-access",
|
||||
FX_AUTO_UPGRADE: "0",
|
||||
FX_SOUND: "0",
|
||||
FX_RECORD: tape,
|
||||
FX_RECORD_INPUT: "1",
|
||||
FX_TRACE_LOG: this.wiring,
|
||||
FX_TRACE_SCOPES: "wiring",
|
||||
};
|
||||
}
|
||||
|
||||
/// Every `[wiring]` line fx wrote, in order.
|
||||
wiringLines(): WiringLine[] {
|
||||
if (!existsSync(this.wiring)) return [];
|
||||
return readFileSync(this.wiring, "utf8").split("\n").flatMap((line) => {
|
||||
const match = /\[wiring\] action=(\S+) session=(\S+)(?: .*)? pid=(\d+)$/.exec(line);
|
||||
return match ? [{ action: match[1]!, session: match[2]!, pid: Number(match[3]) }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
/// Each saved session's id and the turns its log holds, as the store left
|
||||
/// them on disk.
|
||||
disk(): Record<string, number> {
|
||||
const store = join(this.blocked ? this.aside : this.sessions, "v2");
|
||||
if (!existsSync(store) || !statSync(store).isDirectory()) return {};
|
||||
const saved: Record<string, number> = {};
|
||||
for (const id of readdirSync(store).sort()) {
|
||||
const log = join(store, id, "log.jsonl");
|
||||
if (id.startsWith(".") || !existsSync(log)) continue;
|
||||
const text = readFileSync(log, "utf8");
|
||||
// Only whole lines: fx may still be appending.
|
||||
const lines = text.slice(0, text.lastIndexOf("\n") + 1).split("\n").filter(Boolean);
|
||||
saved[id] = lines.filter((line) => JSON.parse(line).kind === "turn_started").length;
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/// Whether a later fx opens session `id`, as `fx --resume <id>` would.
|
||||
async resumable(id: string): Promise<boolean> {
|
||||
const result = Bun.spawnSync([this.bin, "session", "--id", id, "--json"], {
|
||||
cwd: this.workspace,
|
||||
env: { PATH: process.env.PATH ?? "", HOME: this.home, FX_SOUND: "0", FX_AUTO_UPGRADE: "0", FX_DISABLE_KEYCHAIN: "1", FX_E2E_DISABLE_DOTENV: "1" },
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
timeout: TIMEOUT,
|
||||
});
|
||||
return result.exitCode === 0 && JSON.parse(result.stdout.toString()).id === id;
|
||||
}
|
||||
|
||||
/// Ends every shell and the gateway. Keeps the files when `keep` is set.
|
||||
async close(keep: boolean) {
|
||||
for (const shell of this.shells) await shell.session.kill().catch(() => {});
|
||||
this.gateway.stop();
|
||||
if (keep) console.error(`retained status session artifacts at ${this.root}`);
|
||||
else cleanupIsolatedTestHome(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
/// One fx process of a profile.
|
||||
export class Shell {
|
||||
pid = 0;
|
||||
readonly tape: string;
|
||||
readonly stderr: string;
|
||||
private statuses = 0;
|
||||
|
||||
constructor(readonly profile: Profile, readonly n: number, readonly session: TmuxSession) {
|
||||
this.tape = join(profile.root, `shell-${n}.fxtape`);
|
||||
this.stderr = join(profile.root, `shell-${n}.stderr`);
|
||||
}
|
||||
|
||||
/// The sessions this process opened, in order: each `Open` and `Fork`.
|
||||
opened(): string[] {
|
||||
return this.profile.wiringLines()
|
||||
.filter((line) => line.pid === this.pid && (line.action === "Open" || line.action === "Fork"))
|
||||
.map((line) => line.session);
|
||||
}
|
||||
|
||||
/// The session this process opened last, or null when it opened none.
|
||||
current(): string | null {
|
||||
return this.opened().at(-1) ?? null;
|
||||
}
|
||||
|
||||
/// Sends a prompt and waits for its answer.
|
||||
async prompt(text: string, answer: string) {
|
||||
this.profile.answer(text, answer);
|
||||
const before = this.profile.gateway.requests.length;
|
||||
await this.session.sendText(text);
|
||||
await this.waitUntil(() => this.profile.gateway.requests.length > before, `the request for ${text}`);
|
||||
await this.session.waitForText(answer, TIMEOUT);
|
||||
await this.session.waitForStableComposer(TIMEOUT);
|
||||
}
|
||||
|
||||
/// Sends a prompt whose reply is held, and waits until fx asked for it.
|
||||
async begin(text: string): Promise<{ release(answer: string): Promise<void> }> {
|
||||
const held = this.profile.hold(text);
|
||||
const before = this.profile.gateway.requests.length;
|
||||
await this.session.sendText(text);
|
||||
await this.waitUntil(() => this.profile.gateway.requests.length > before, `the request for ${text}`);
|
||||
return {
|
||||
release: async (answer: string) => {
|
||||
held.release(answer);
|
||||
await this.session.waitForText(answer, TIMEOUT);
|
||||
await this.session.waitForStableComposer(TIMEOUT);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Opens the `/status` panel, waits until it has settled, presses ↓
|
||||
/// `scroll` times, and closes it with Esc. A Ctrl-U, which changes nothing
|
||||
/// in an empty composer, then marks on the tape where the closed shell was
|
||||
/// painted. `reports` reads each panel back. Fails when the panel's text
|
||||
/// stayed in the terminal.
|
||||
async status(options: { scroll?: number } = {}) {
|
||||
await this.session.sendKeys("C-u");
|
||||
await settled(this.session);
|
||||
await this.session.sendText("/status");
|
||||
await this.waitUntilAsync(async () => showsStatusPanel(lines(await this.session.capturePane())), "the /status panel");
|
||||
await settled(this.session);
|
||||
for (let n = 0; n < (options.scroll ?? 0); n++) {
|
||||
await this.session.sendKeys("Down");
|
||||
await settled(this.session);
|
||||
}
|
||||
await this.session.sendKeys("Escape");
|
||||
await this.waitUntilAsync(async () => !showsStatusPanel(lines(await this.session.capturePane())), "the /status panel to close");
|
||||
await settled(this.session);
|
||||
await this.session.sendKeys("C-u");
|
||||
this.statuses++;
|
||||
const left = lines(await this.session.captureFullScrollback()).filter((line) => /^ {2}Session grants {2,}\S/.test(line));
|
||||
if (left.length > 0) throw new Error(`the /status panel left its text in the terminal: ${left.join(" | ")}`);
|
||||
}
|
||||
|
||||
/// How many `/status` reports this process was asked for.
|
||||
statusCount(): number {
|
||||
return this.statuses;
|
||||
}
|
||||
|
||||
/// Sends a slash command and waits for the composer, for a command that
|
||||
/// opens no session, such as `/new` in a shell without a store.
|
||||
async command(text: string) {
|
||||
await this.session.sendKeys("C-u");
|
||||
await this.session.sendText(text);
|
||||
await this.session.waitForStableComposer(TIMEOUT);
|
||||
await settled(this.session);
|
||||
}
|
||||
|
||||
/// `/new` or `/clear`, and the session it opened.
|
||||
async newSession(command: "/new" | "/clear"): Promise<string> {
|
||||
return this.switching(async () => {
|
||||
await this.session.sendKeys("C-u");
|
||||
await this.session.sendText(command);
|
||||
}, "Open");
|
||||
}
|
||||
|
||||
/// `/resume`, filtered to the one session whose title holds `title`.
|
||||
async resume(title: string): Promise<string> {
|
||||
return this.switching(async () => {
|
||||
await this.session.sendKeys("C-u");
|
||||
await this.session.sendText("/resume");
|
||||
await this.session.waitForPane((pane) => pane.includes("Sessions") && /\[(Current|All) workspaces?\]/.test(pane), TIMEOUT);
|
||||
await this.session.sendLiteralText(title);
|
||||
await this.session.waitForPane((pane) => pane.includes("Sessions 1"), TIMEOUT);
|
||||
await this.session.sendKeys("Enter");
|
||||
await this.session.waitForText("session resumed", TIMEOUT);
|
||||
}, "Open");
|
||||
}
|
||||
|
||||
/// `/fork` with the prompt `up` rows above the newest selected, and the
|
||||
/// fork it opened. The fork's prompt is left out of the composer.
|
||||
async fork(up: number): Promise<string> {
|
||||
return this.switching(async () => {
|
||||
await this.session.sendKeys("C-u");
|
||||
await this.session.sendText("/fork");
|
||||
await this.session.waitForText("enter fork", TIMEOUT);
|
||||
for (let n = 0; n < up; n++) await this.session.sendKeys("Up");
|
||||
await this.session.sendKeys("Enter");
|
||||
await this.session.waitForText("session forked", TIMEOUT);
|
||||
await settled(this.session);
|
||||
await this.session.sendKeys("C-u");
|
||||
}, "Fork");
|
||||
}
|
||||
|
||||
/// `/quit`, and the id the exit line named, or null when it named none.
|
||||
/// A terminal too narrow for one row puts the command on the next one.
|
||||
async quit(): Promise<string | null> {
|
||||
await this.session.sendKeys("C-u");
|
||||
await this.session.sendText("/quit");
|
||||
await this.waitUntil(() => this.session.paneStatus().dead, "fx to exit");
|
||||
const text = (await this.session.captureFullScrollback()).split("\n").map((line) => line.trim()).join("\n");
|
||||
const named = [...text.matchAll(/^Continue session with:[ \n]fx --resume (\S+)$/gm)].map((match) => match[1]!);
|
||||
if (named.length > 1) throw new Error(`the exit line named ${named.length} sessions: ${named}`);
|
||||
if (named.length === 0 && text.includes("Continue session with:")) throw new Error(`an exit line fx printed did not parse:\n${text}`);
|
||||
return named[0] ?? null;
|
||||
}
|
||||
|
||||
/// Kills the process without a chance to write anything.
|
||||
async crash() {
|
||||
process.kill(this.pid, "SIGKILL");
|
||||
await this.waitUntil(() => this.session.paneStatus().dead, "fx to die");
|
||||
}
|
||||
|
||||
/// Every panel `/status` painted in this process, read through fx's
|
||||
/// virtual terminal. Call it once the process has ended.
|
||||
reports(): StatusReport[] {
|
||||
const reports = statusReports(this.profile.bin, this.tape, join(this.profile.root, `shell-${this.n}.frames`));
|
||||
if (reports.length !== this.statuses) {
|
||||
throw new Error(`shell ${this.n} ran /status ${this.statuses} times, but its tape holds ${reports.length} panels`);
|
||||
}
|
||||
return reports;
|
||||
}
|
||||
|
||||
stderrText(): string {
|
||||
return readFileSync(this.stderr, "utf8");
|
||||
}
|
||||
|
||||
private async switching(act: () => Promise<void>, action: "Open" | "Fork"): Promise<string> {
|
||||
const before = this.profile.wiringLines().filter((line) => line.pid === this.pid && line.action === action).length;
|
||||
await act();
|
||||
await this.waitUntil(
|
||||
() => this.profile.wiringLines().filter((line) => line.pid === this.pid && line.action === action).length > before,
|
||||
`a wiring ${action}`,
|
||||
);
|
||||
await this.session.waitForStableComposer(TIMEOUT);
|
||||
return this.current()!;
|
||||
}
|
||||
|
||||
private async waitUntil(condition: () => boolean, what: string) {
|
||||
await this.waitUntilAsync(async () => condition(), what);
|
||||
}
|
||||
|
||||
private async waitUntilAsync(condition: () => Promise<boolean>, what: string) {
|
||||
const deadline = Date.now() + TIMEOUT;
|
||||
while (Date.now() < deadline) {
|
||||
if (await condition()) return;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
throw new Error(`timed out waiting for ${what}:\n${(await this.session.capturePane()).trimEnd()}`);
|
||||
}
|
||||
}
|
||||
|
||||
function lines(text: string): string[] {
|
||||
return text.split("\n");
|
||||
}
|
||||
|
||||
/// The pane once it has stopped changing for 300 ms.
|
||||
async function settled(session: TmuxSession): Promise<string> {
|
||||
const deadline = Date.now() + TIMEOUT;
|
||||
let previous = "", since = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const pane = await session.capturePane();
|
||||
if (pane !== previous) [previous, since] = [pane, Date.now()];
|
||||
else if (Date.now() - since >= 300) return pane;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
throw new Error(`pane never settled:\n${previous}`);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", "'\\''")}'`;
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
// What the `/status` panel showed, read back through fx's own virtual
|
||||
// terminal. A test records the terminal with `FX_RECORD` and
|
||||
// `FX_RECORD_INPUT=1`; `fx replay --frames-dir` feeds that tape to the
|
||||
// vt_emulator engine (`src/core/terminal/engine.zig`) and writes the grid
|
||||
// after every frame. Each `/status` the tape's input submitted is matched to
|
||||
// the panel fx painted before the next keystroke, the Esc that closes it, and
|
||||
// to the shell fx painted before the command was typed and after the panel
|
||||
// closed, so a test can prove the panel left the transcript as it was.
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { readTapeFrames } from "./render-lab/tape";
|
||||
|
||||
const STDOUT = 1;
|
||||
const STDIN = 2;
|
||||
|
||||
/// Every label the panel can show. A label that is the start of another,
|
||||
/// such as `Session` and `Session grants`, is matched by the two spaces at
|
||||
/// least that separate it from its value.
|
||||
export const STATUS_PANEL_LABELS = [
|
||||
"Session",
|
||||
"Model",
|
||||
"Provider",
|
||||
"Endpoint",
|
||||
"Connected",
|
||||
"Auth",
|
||||
"Auth help",
|
||||
"Team",
|
||||
"Settings",
|
||||
"Permissions",
|
||||
"Workspace",
|
||||
"Turns",
|
||||
"Session grants",
|
||||
"Step limit",
|
||||
"Ultrafast",
|
||||
"Updates",
|
||||
"Build",
|
||||
] as const;
|
||||
|
||||
export type StatusPanelLabel = (typeof STATUS_PANEL_LABELS)[number];
|
||||
|
||||
export type StatusPanel = {
|
||||
/// The panel's lines under its title, as painted.
|
||||
rows: string[];
|
||||
/// The labels shown, in order.
|
||||
labels: StatusPanelLabel[];
|
||||
/// Each label's value, its wrapped lines joined back together.
|
||||
fields: Partial<Record<StatusPanelLabel, string>>;
|
||||
};
|
||||
|
||||
/// The panel as one grid showed it.
|
||||
export type StatusPanelView = StatusPanel & {
|
||||
/// The tape frame whose grid holds the panel.
|
||||
frame: number;
|
||||
/// That frame's grid, one string per terminal row.
|
||||
grid: string[];
|
||||
};
|
||||
|
||||
/// One `/status`: the panel it opened with, every view of it while keys such
|
||||
/// as ↓ moved it, and the shell before and after.
|
||||
export type StatusReport = StatusPanelView & {
|
||||
/// The panel as it opened, then after each keystroke that repainted it.
|
||||
views: StatusPanelView[];
|
||||
/// The grid fx painted before `/status` was typed.
|
||||
before: string[];
|
||||
/// The grid fx painted once the panel closed, before the next keystroke.
|
||||
closed: string[];
|
||||
};
|
||||
|
||||
/// A tape's frames. A process killed mid-write leaves its last frame cut
|
||||
/// short; it is left out, as `fx replay` leaves it out.
|
||||
function frames(tapePath: string) {
|
||||
return readTapeFrames(tapePath, { ignoreTruncatedTail: true });
|
||||
}
|
||||
|
||||
/// Whether recorded input holds a keystroke, not only replies the terminal
|
||||
/// sent by itself: focus events, cursor and device reports, and OSC answers.
|
||||
export function holdsKeystroke(input: string): boolean {
|
||||
for (let at = 0; at < input.length;) {
|
||||
if (input[at] !== "\x1b") return true;
|
||||
const osc = /^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/.exec(input.slice(at));
|
||||
if (osc) {
|
||||
at += osc[0].length;
|
||||
continue;
|
||||
}
|
||||
const csi = /^\x1b\[([0-?]*)([ -/]*)([@-~])/.exec(input.slice(at));
|
||||
if (!csi) return true;
|
||||
const reply = /^[?>]/.test(csi[1]!) || "RIOcnty".includes(csi[3]!) || csi[2] === "$";
|
||||
if (!reply) return true;
|
||||
at += csi[0].length;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Every grid `fx replay --frames-dir` wrote for `tapePath`, by tape frame.
|
||||
export function replayGrids(fxBin: string, tapePath: string, framesRoot: string): Map<number, string[]> {
|
||||
execFileSync(fxBin, ["replay", tapePath, "--frames-dir", framesRoot], {
|
||||
stdio: "pipe",
|
||||
env: { ...process.env, FX_SOUND: "0" },
|
||||
});
|
||||
const grids = new Map<number, string[]>();
|
||||
for (const frame of frames(tapePath)) {
|
||||
if (frame.kind !== STDOUT) continue;
|
||||
const path = join(framesRoot, "frames", `${String(frame.index).padStart(4, "0")}.grid.txt`);
|
||||
const text = readFileSync(path, "utf8");
|
||||
// Each row is framed as |row|.
|
||||
grids.set(frame.index, text.replace(/\n$/, "").split("\n").map((line) => line.replace(/^\|(.*)\|$/, "$1")));
|
||||
}
|
||||
return grids;
|
||||
}
|
||||
|
||||
/// Each time the tape's input submitted `command`: the frame where its first
|
||||
/// character was typed, and the frame where Enter arrived with the composer's
|
||||
/// line, as the keystrokes typed it, equal to `command`.
|
||||
export function submittedFrames(tapePath: string, command: string): { typed: number; submitted: number }[] {
|
||||
const submits: { typed: number; submitted: number }[] = [];
|
||||
let line = "";
|
||||
let typed = -1;
|
||||
const enter = (index: number) => {
|
||||
if (line.trim() === command) submits.push({ typed, submitted: index });
|
||||
line = "";
|
||||
};
|
||||
for (const frame of frames(tapePath)) {
|
||||
if (frame.kind !== STDIN) continue;
|
||||
const bytes = frame.payload.toString("latin1");
|
||||
for (let at = 0; at < bytes.length; at++) {
|
||||
const byte = bytes[at]!;
|
||||
if (byte === "\x1b") {
|
||||
// CSI and SS3 sequences: arrows, focus reports, and Enter as CSI u.
|
||||
const csi = /^\x1b(?:\[[0-?]*[ -/]*[@-~]|O.)/.exec(bytes.slice(at));
|
||||
const sequence = csi?.[0] ?? "\x1b";
|
||||
if (/^\x1b\[13(?:;1)?u$/.test(sequence)) enter(frame.index);
|
||||
at += sequence.length - 1;
|
||||
continue;
|
||||
}
|
||||
if (byte === "\r" || byte === "\n") {
|
||||
enter(frame.index);
|
||||
} else if (byte === "\x15") {
|
||||
line = "";
|
||||
} else if (byte === "\x7f" || byte === "\b") {
|
||||
line = line.slice(0, -1);
|
||||
} else if (byte >= " ") {
|
||||
if (line === "") typed = frame.index;
|
||||
line += byte;
|
||||
}
|
||||
}
|
||||
}
|
||||
return submits;
|
||||
}
|
||||
|
||||
/// The `/status` panel `grid` shows, or null: its `Status` title, a blank
|
||||
/// row, and then one line per label, each wrapped value continuing on the
|
||||
/// next lines under it, up to the footer's divider. A value wraps at a
|
||||
/// space, which the join puts back; after a `/`; or, in a word too long for
|
||||
/// its column, where the column ends, the only break that fills the line. A
|
||||
/// word that exactly fills its column before a space reads back joined to the
|
||||
/// next word; no value the tests read holds one.
|
||||
/// `width` is the terminal's; by default the widest row's, since `fx replay`
|
||||
/// writes every row at the terminal's full width.
|
||||
export function statusPanel(grid: string[], width = Math.max(...grid.map((row) => [...row].length))): StatusPanel | null {
|
||||
const top = grid.findIndex((row, at) => row.trimEnd() === "Status" && (grid[at + 1] ?? "").trim() === "");
|
||||
if (top < 0) return null;
|
||||
const rows: string[] = [];
|
||||
const labels: StatusPanelLabel[] = [];
|
||||
const fields: Partial<Record<StatusPanelLabel, string>> = {};
|
||||
let label: StatusPanelLabel | null = null;
|
||||
let valueColumn = 0;
|
||||
let previousFilled = false;
|
||||
for (let row = top + 2; row < grid.length; row++) {
|
||||
const text = grid[row]!.trimEnd();
|
||||
const named = [...STATUS_PANEL_LABELS]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.find((candidate) => new RegExp(`^ {2}${candidate} {2,}\\S`).test(text));
|
||||
let value: string;
|
||||
if (!label && !named && /^ {3,}\S/.test(text)) {
|
||||
// Scrolled: the line continues a value whose label is above the panel.
|
||||
rows.push(text);
|
||||
continue;
|
||||
}
|
||||
if (named) {
|
||||
label = named;
|
||||
labels.push(named);
|
||||
value = text.slice(2 + named.length).trimStart();
|
||||
valueColumn = text.length - value.length;
|
||||
fields[named] = value;
|
||||
} else if (label && text.length > valueColumn && /^ +$/.test(text.slice(0, valueColumn)) && text[valueColumn] !== " ") {
|
||||
value = text.slice(valueColumn);
|
||||
const joined = fields[label]!;
|
||||
fields[label] = joined + (previousFilled || joined.endsWith("/") ? "" : " ") + value;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
previousFilled = valueColumn + [...value].length >= width;
|
||||
rows.push(text);
|
||||
}
|
||||
return labels.length > 0 ? { rows, labels, fields } : null;
|
||||
}
|
||||
|
||||
/// Whether `grid` shows any piece of the panel: its title or a labeled line.
|
||||
export function showsStatusPanel(grid: string[]): boolean {
|
||||
return statusPanel(grid) !== null || grid.some((row) => /^ {2}Session grants {2,}\S/.test(row));
|
||||
}
|
||||
|
||||
/// The credential the panel's `Auth` line names, without the ` · expired`
|
||||
/// and ` · refreshable` that may follow it; undefined when it shows none.
|
||||
export function panelAuth(panel: StatusPanel): string | undefined {
|
||||
return panel.fields.Auth?.split(" · ")[0];
|
||||
}
|
||||
|
||||
/// What a test drives a tmux pane with, as `TmuxSession` does.
|
||||
type Pane = {
|
||||
sendText(text: string): Promise<void>;
|
||||
sendKeys(keys: string): Promise<void>;
|
||||
capturePane(): Promise<string>;
|
||||
paneSize(): { cols: number; rows: number };
|
||||
};
|
||||
|
||||
/// Waits until `pane` shows a `/status` panel that has what `ready` asks
|
||||
/// for, the same in two captures in a row so a frame caught half painted
|
||||
/// is not taken for it, and returns it. The panel shows one snapshot, so a
|
||||
/// `ready` that never holds fails with the pane.
|
||||
export async function waitForStatusPanel(
|
||||
pane: Pane,
|
||||
timeout: number,
|
||||
ready: (panel: StatusPanel) => boolean = () => true,
|
||||
): Promise<StatusPanel> {
|
||||
const deadline = Date.now() + timeout;
|
||||
let previous = "";
|
||||
while (true) {
|
||||
const text = await pane.capturePane();
|
||||
const panel = statusPanel(text.split("\n"), pane.paneSize().cols);
|
||||
if (panel && text === previous && ready(panel)) return panel;
|
||||
if (Date.now() >= deadline) throw new Error(`the /status panel never showed what the test waited for:\n${text.trimEnd()}`);
|
||||
previous = text;
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
/// Closes the `/status` panel with Esc and waits until it is gone, so the
|
||||
/// composer takes input again.
|
||||
export async function closeStatusPanel(pane: Pane, timeout: number): Promise<void> {
|
||||
await pane.sendKeys("Escape");
|
||||
const deadline = Date.now() + timeout;
|
||||
let text: string;
|
||||
while (showsStatusPanel((text = await pane.capturePane()).split("\n"))) {
|
||||
if (Date.now() >= deadline) throw new Error(`the /status panel did not close on Esc:\n${text.trimEnd()}`);
|
||||
await Bun.sleep(25);
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `/status` in `pane`, waits until its panel shows what `ready` asks
|
||||
/// for, closes it, and returns what it showed.
|
||||
export async function readStatusPanel(
|
||||
pane: Pane,
|
||||
timeout: number,
|
||||
ready: (panel: StatusPanel) => boolean = () => true,
|
||||
): Promise<StatusPanel> {
|
||||
await pane.sendText("/status");
|
||||
const panel = await waitForStatusPanel(pane, timeout, ready);
|
||||
await closeStatusPanel(pane, timeout);
|
||||
return panel;
|
||||
}
|
||||
|
||||
/// The session the panel's `Session` line names: its id and whether a
|
||||
/// resume opens it, or no id when the shell has no session.
|
||||
export function panelSession(panel: StatusPanel): { id: string | null; saved: boolean } {
|
||||
const value = panel.fields.Session;
|
||||
if (value === undefined) throw new Error(`the /status panel shows no Session line:\n${panel.rows.join("\n")}`);
|
||||
if (value === "none · this shell can't save sessions") return { id: null, saved: false };
|
||||
const match = /^(\S+) · (saved|not saved yet)$/.exec(value);
|
||||
if (!match) throw new Error(`the /status panel's Session line did not parse: ${value}`);
|
||||
return { id: match[1]!, saved: match[2] === "saved" };
|
||||
}
|
||||
|
||||
/// The transcript `grid` shows: the rows above the composer, its bottom-most
|
||||
/// `┃` row, without trailing blank rows, and with a running turn's live
|
||||
/// activity row, such as `• Thinking (1s) (↑2 ↓0)`, as one placeholder: its
|
||||
/// glyph blinks and its counters tick. The footer is left out, since its
|
||||
/// status line may change on its own, as a timed warning ends.
|
||||
function transcriptRows(grid: string[]): string[] {
|
||||
const all = grid.map((row) => row.trimEnd());
|
||||
const composer = all.findLastIndex((row) => row.startsWith("┃"));
|
||||
if (composer < 0) throw new Error(`no composer row in the grid:\n${all.join("\n")}`);
|
||||
const rows = all.slice(0, composer).map((row) => (/^[• ] [A-Z][^()]* \(\d+s\)/.test(row) ? "<activity>" : row));
|
||||
while (rows.length > 0 && rows.at(-1) === "") rows.pop();
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// Whether the transcript in `report.closed` is the one `report.before`
|
||||
/// showed, or the same with rows that scrolled away above it: the panel
|
||||
/// opened and closed without adding a row to the transcript.
|
||||
export function closedAsBefore(report: StatusReport): boolean {
|
||||
const before = transcriptRows(report.before);
|
||||
const closed = transcriptRows(report.closed);
|
||||
if (closed.length > before.length) return false;
|
||||
return closed.every((row, at) => row === before[before.length - closed.length + at]);
|
||||
}
|
||||
|
||||
/// One report for each `/status` the tape submitted, in order. Each
|
||||
/// keystroke after the submit bounds the grid painted after the one before
|
||||
/// it; the report follows those grids until one shows no panel, the panel
|
||||
/// closed. Throws when a `/status` painted no panel before the next
|
||||
/// keystroke, when a panel was already showing when it was typed, or when no
|
||||
/// keystroke after the one that closed it bounds the closed grid.
|
||||
export function statusReports(fxBin: string, tapePath: string, framesRoot: string): StatusReport[] {
|
||||
const grids = replayGrids(fxBin, tapePath, framesRoot);
|
||||
const keystrokes = frames(tapePath)
|
||||
.filter((frame) => frame.kind === STDIN && holdsKeystroke(frame.payload.toString("latin1")))
|
||||
.map((frame) => frame.index);
|
||||
const paintedBetween = (after: number, before: number) =>
|
||||
[...grids.keys()].filter((index) => index > after && index < before).sort((a, b) => b - a);
|
||||
return submittedFrames(tapePath, "/status").map(({ typed, submitted }) => {
|
||||
const beforeFrame = paintedBetween(-1, typed)[0];
|
||||
if (beforeFrame === undefined) throw new Error(`/status typed at tape frame ${typed} before fx painted anything`);
|
||||
const before = grids.get(beforeFrame)!;
|
||||
if (showsStatusPanel(before)) throw new Error(`a /status panel was already showing when /status was typed at tape frame ${typed}`);
|
||||
|
||||
const views: StatusPanelView[] = [];
|
||||
let start = submitted;
|
||||
for (const end of keystrokes.filter((index) => index > submitted)) {
|
||||
const painted = paintedBetween(start, end);
|
||||
const view = painted.flatMap((frame) => {
|
||||
const panel = statusPanel(grids.get(frame)!);
|
||||
return panel ? [{ frame, grid: grids.get(frame)!, ...panel }] : [];
|
||||
})[0];
|
||||
if (views.length === 0 && !view) {
|
||||
throw new Error(`/status submitted at tape frame ${submitted} painted no panel before the next keystroke`);
|
||||
}
|
||||
if (view && painted[0] === view.frame) {
|
||||
views.push(view);
|
||||
} else if (painted.length > 0) {
|
||||
// The newest grid shows no panel: the keystroke before it closed it.
|
||||
return { ...views[0]!, views, before, closed: grids.get(painted[0]!)! };
|
||||
}
|
||||
start = end;
|
||||
}
|
||||
throw new Error(`/status submitted at tape frame ${submitted} has no keystroke after the one that closed its panel`);
|
||||
});
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { FX_BIN, REPO_ROOT, runFx, providerVersionTestEnv } from "../evals/eval-helpers";
|
||||
import { closeStatusPanel, panelAuth, readStatusPanel, waitForStatusPanel } from "./status-panel";
|
||||
import { fakeResponsesTitleDefault, TITLE_GENERATION_MARKER } from "./tmux-helpers";
|
||||
import { readTapeFrames } from "./render-lab/tape";
|
||||
import { equivalentPngEncodings, solidPng } from "./fixtures/image-encoding";
|
||||
@@ -1470,8 +1471,7 @@ for (const otherProvider of ["codex", "grok"] as const) {
|
||||
AI_GATEWAY_API_KEY: undefined,
|
||||
});
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
const authPath = join(home, ".fx", "auth.json");
|
||||
linkSync(authPath, join(home, "login.alias"));
|
||||
await session.sendText("/logout");
|
||||
@@ -1892,20 +1892,13 @@ tmuxTest(
|
||||
expect(response.error.message).toContain("Saved credential storage is unavailable");
|
||||
session = await startFx(home, stderrPath, gateway, oauth.issuerUrl, undefined, env);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth_help=", TIMEOUT);
|
||||
expect(await session.captureFullScrollback()).toContain(
|
||||
"auth_help=Saved credential storage is unavailable",
|
||||
);
|
||||
const unavailable = await readStatusPanel(session, TIMEOUT, (panel) => panel.fields["Auth help"] !== undefined);
|
||||
expect(unavailable.fields["Auth help"]).toContain("Saved credential storage is unavailable");
|
||||
await selectEnvKeyCredential(session);
|
||||
await session.sendText("Use the working Gateway account.");
|
||||
await session.waitForText(`GATEWAY_RECOVERED_${provider}`, TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
const recoveredStatus = scrollback.slice(scrollback.lastIndexOf("* status:"));
|
||||
expect(recoveredStatus).toContain("auth=AI_GATEWAY_API_KEY");
|
||||
expect(recoveredStatus).not.toContain("auth_help=");
|
||||
const recovered = await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
expect(recovered.labels).not.toContain("Auth help");
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
await session.kill();
|
||||
session = null;
|
||||
@@ -2432,11 +2425,7 @@ tmuxTest(
|
||||
expect(existsSync(authPath)).toBe(true);
|
||||
expect(statSync(authPath).mode & 0o077).toBe(0);
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText(
|
||||
"model_source=Codex subscription",
|
||||
TIMEOUT,
|
||||
);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panel.fields.Provider === "Codex subscription");
|
||||
await session.sendLiteralText("/model");
|
||||
await session.sendKeys("Tab");
|
||||
const picker = await session.waitForPane(
|
||||
@@ -2523,8 +2512,7 @@ tmuxTest(
|
||||
},
|
||||
);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("model=gpt-5.6-sol", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panel.fields.Model === "gpt-5.6-sol");
|
||||
await selectEnvKeyCredential(session);
|
||||
await session.waitForText("Switched to Vercel AI Gateway", TIMEOUT);
|
||||
const savedGateway = JSON.parse(readFileSync(settingsPath, "utf8"));
|
||||
@@ -2544,8 +2532,7 @@ tmuxTest(
|
||||
await session.sendText("/logout codex");
|
||||
await session.waitForText("Signed out of Codex.", TIMEOUT);
|
||||
expect(existsSync(authPath)).toBe(false);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText(`model=${gatewayModelBefore}`, TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panel.fields.Model === gatewayModelBefore);
|
||||
expect(JSON.parse(readFileSync(settingsPath, "utf8")).provider).toBe("gateway");
|
||||
chatgptOauth.setModels([
|
||||
{ slug: "gpt-5.4-mini", visibility: "list", supported_in_api: true, supported_reasoning_levels: [{ effort: "low" }], additional_speed_tiers: [], input_modalities: ["text"], context_window: 128000 },
|
||||
@@ -2601,8 +2588,7 @@ tmuxTest(
|
||||
const selected = JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8"));
|
||||
expect(selected.provider).toBe("codex");
|
||||
expect(selected.models.codex).toBe("gpt-5.6-sol");
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("model_source=Codex subscription", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panel.fields.Provider === "Codex subscription");
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
},
|
||||
60_000,
|
||||
@@ -2943,16 +2929,16 @@ for (const [provider, help] of [
|
||||
});
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth_help=", TIMEOUT);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
expect(scrollback).toContain(`auth_help=${help}`);
|
||||
if (provider !== "gateway") {
|
||||
expect(scrollback).toContain(`model_source=${provider === "codex" ? "Codex" : "Grok"} subscription`);
|
||||
expect(scrollback).not.toContain("auth_help=fx needs access to Vercel AI Gateway");
|
||||
}
|
||||
expect(scrollback).toContain("auth=missing");
|
||||
expect(scrollback).toContain("auth_refreshable=false");
|
||||
const panel = await waitForStatusPanel(session, TIMEOUT, (shown) => shown.fields["Auth help"] !== undefined);
|
||||
expect(await session.captureFullScrollbackEscapes()).toContain(help);
|
||||
await closeStatusPanel(session, TIMEOUT);
|
||||
expect(panel.fields["Auth help"]).toStartWith(help);
|
||||
if (provider !== "gateway") {
|
||||
expect(panel.fields.Provider).toBe(`${provider === "codex" ? "Codex" : "Grok"} subscription`);
|
||||
expect(panel.fields["Auth help"]).not.toContain("fx needs access to Vercel AI Gateway");
|
||||
}
|
||||
// Missing, and not refreshable.
|
||||
expect(panel.fields.Auth).toBe("missing");
|
||||
expect(gateway.requests).toHaveLength(0);
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(settings);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
@@ -2974,21 +2960,17 @@ for (const [source, help] of [
|
||||
gateway = startFakeGateway([fakeGatewayFinalText("EXPLICIT_KEY_RECOVERED")]);
|
||||
session = await startFx(home, stderrPath, gateway);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth_help=", TIMEOUT);
|
||||
const missing = await session.captureFullScrollback();
|
||||
expect(missing).toContain(`auth_help=${help}`);
|
||||
expect(missing).not.toContain("set AI_GATEWAY_API_KEY");
|
||||
const missing = await readStatusPanel(session, TIMEOUT, (panel) => panel.fields["Auth help"] !== undefined);
|
||||
expect(missing.fields["Auth help"]).toStartWith(help);
|
||||
expect(missing.fields["Auth help"]).not.toContain("set AI_GATEWAY_API_KEY");
|
||||
expect(gateway.requests).toHaveLength(0);
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(settings);
|
||||
|
||||
await selectEnvKeyCredential(session);
|
||||
await session.sendText("Verify the account with a greeting.");
|
||||
await session.waitForText("EXPLICIT_KEY_RECOVERED", TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
expect(scrollback.slice(scrollback.lastIndexOf("* status:"))).not.toContain("auth_help=");
|
||||
const recovered = await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
expect(recovered.labels).not.toContain("Auth help");
|
||||
expect(JSON.parse(readFileSync(settingsPath, "utf8")).credential_source).toBe("ai_gateway_api_key");
|
||||
expect(gateway.requests).toHaveLength(1);
|
||||
expect(gateway.requests[0].headers.get("authorization")).toBe(`Bearer ${ENV_TOKEN}`);
|
||||
@@ -3012,18 +2994,22 @@ tmuxTest("/status preserves a missing selected login through explicit key recove
|
||||
session = await startFx(home, stderrPath, gateway);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth_help=", TIMEOUT);
|
||||
const missing = await session.captureFullScrollback();
|
||||
expect(missing).toContain("auth_help=fx login is selected but unavailable.");
|
||||
expect(missing).toContain("Run /login to reconnect");
|
||||
expect(missing).not.toContain("or set AI_GATEWAY_API_KEY");
|
||||
const missing = await waitForStatusPanel(session, TIMEOUT, (panel) => panel.fields["Auth help"] !== undefined);
|
||||
expect(await session.captureFullScrollbackEscapes()).toContain("fx login is selected but unavailable.");
|
||||
await closeStatusPanel(session, TIMEOUT);
|
||||
expect(missing.fields["Auth help"]).toStartWith("fx login is selected but unavailable.");
|
||||
expect(missing.fields["Auth help"]).toContain("Run /login to reconnect");
|
||||
expect(missing.fields["Auth help"]).not.toContain("or set AI_GATEWAY_API_KEY");
|
||||
expect(gateway.requests).toHaveLength(0);
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(settings);
|
||||
|
||||
writeFileSync(settingsPath, "{broken");
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("Failed to load settings:", TIMEOUT);
|
||||
// Unreadable settings leave the selected credential unknown, so the panel
|
||||
// says so and shows no auth.
|
||||
const unreadable = await readStatusPanel(session, TIMEOUT, (panel) => panel.fields.Settings !== undefined);
|
||||
expect(unreadable.fields.Settings).toStartWith("couldn't be read: ");
|
||||
expect(unreadable.labels).not.toContain("Auth");
|
||||
expect(unreadable.labels).not.toContain("Auth help");
|
||||
expect(gateway.requests).toHaveLength(0);
|
||||
writeFileSync(settingsPath, settings);
|
||||
|
||||
@@ -3032,11 +3018,9 @@ tmuxTest("/status preserves a missing selected login through explicit key recove
|
||||
await session.waitForText("SELECTED_KEY_RECOVERED", TIMEOUT);
|
||||
expect(JSON.parse(readFileSync(settingsPath, "utf8")).credential_source).toBe("ai_gateway_api_key");
|
||||
writeFileSync(settingsPath, "{broken");
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
const recovered = scrollback.slice(scrollback.lastIndexOf("* status:"));
|
||||
expect(recovered).not.toContain("auth_help=");
|
||||
const recovered = await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
expect(recovered.labels).not.toContain("Auth help");
|
||||
expect(recovered.labels).not.toContain("Settings");
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe("{broken");
|
||||
expect(gateway.requests).toHaveLength(1);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
@@ -3062,9 +3046,8 @@ for (const provider of ["codex", "grok"] as const) {
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/logout vercel");
|
||||
await session.waitForText("No fx login session found.", TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth_help=", TIMEOUT);
|
||||
expect(await session.captureFullScrollback()).toContain("auth_help=Saved credential storage is unavailable");
|
||||
const unavailable = await readStatusPanel(session, TIMEOUT, (panel) => panel.fields["Auth help"] !== undefined);
|
||||
expect(unavailable.fields["Auth help"]).toContain("Saved credential storage is unavailable");
|
||||
expect(existsSync(credentialPath)).toBe(true);
|
||||
expect(readFileSync(settingsPath, "utf8")).toBe(settings);
|
||||
expect(gateway.requests).toHaveLength(0);
|
||||
@@ -3219,8 +3202,7 @@ profileStoredKeyTmuxTest(
|
||||
FX_DISABLE_KEYCHAIN: undefined,
|
||||
});
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
|
||||
// Typing the full path exercises both space-advance columns: the space
|
||||
// after "vercel" opens the methods, the space after "api-key" opens the
|
||||
@@ -3238,8 +3220,7 @@ profileStoredKeyTmuxTest(
|
||||
expect(keyColumn).toContain("env · AI_GATEWAY_API_KEY");
|
||||
await session.sendKeys("Escape");
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=stored API key (profile file)", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "stored API key (profile file)");
|
||||
expect(savedCredentialSource(home)).toBe("stored_key");
|
||||
|
||||
const keyPath = join(home, ".fx", "api-key");
|
||||
@@ -3251,8 +3232,7 @@ profileStoredKeyTmuxTest(
|
||||
FX_DISABLE_KEYCHAIN: undefined,
|
||||
});
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=stored API key (profile file)", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "stored API key (profile file)");
|
||||
await session.sendText("use the stored key after restart");
|
||||
await session.waitForText(STORED_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests[0].headers.get("authorization")).toBe(`Bearer ${STORED_TOKEN}`);
|
||||
@@ -3285,8 +3265,7 @@ tmuxTest(
|
||||
|
||||
session = await startFx(home, stderrPath, gateway, oauth.issuerUrl);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
|
||||
await session.sendText("/login");
|
||||
await session.waitForPane(
|
||||
@@ -3303,8 +3282,7 @@ tmuxTest(
|
||||
await session.waitForText("Vercel team · Search:", TIMEOUT);
|
||||
await session.sendKeys("Enter");
|
||||
await session.waitForText("Changed Vercel team to Vercel Labs", TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
expect(savedCredentialSource(home)).toBe("fx_login");
|
||||
await session.sendText("use the direct login credential");
|
||||
await session.waitForText(DIRECT_LOGIN_RESPONSE, TIMEOUT);
|
||||
@@ -3315,8 +3293,7 @@ tmuxTest(
|
||||
await session.kill();
|
||||
session = await startFx(home, stderrPath, gateway, oauth.issuerUrl);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
await session.sendText("use the remembered direct login credential");
|
||||
await session.waitForText(RESTART_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests[1].headers.get("authorization")).toBe(
|
||||
@@ -3325,14 +3302,12 @@ tmuxTest(
|
||||
|
||||
await selectEnvKeyCredential(session);
|
||||
expect(savedCredentialSource(home)).toBe("ai_gateway_api_key");
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
|
||||
await session.kill();
|
||||
session = await startFx(home, stderrPath, gateway, oauth.issuerUrl);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
await session.sendText("use automatic precedence after restart");
|
||||
await session.waitForText(ENV_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests[2].headers.get("authorization")).toBe(`Bearer ${ENV_TOKEN}`);
|
||||
@@ -3362,8 +3337,7 @@ tmuxTest(
|
||||
|
||||
session = await startFx(home, stderrPath, gateway, oauth.issuerUrl);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
|
||||
await openProviderPicker(session);
|
||||
await session.sendKeys("Enter");
|
||||
@@ -3375,8 +3349,7 @@ tmuxTest(
|
||||
await session.waitForText("vercel-labs", TIMEOUT);
|
||||
await session.sendKeys("Enter");
|
||||
await session.waitForText("Changed Vercel team to Vercel Labs", TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
expect(savedCredentialSource(home)).toBe("fx_login");
|
||||
|
||||
const savedAuth = JSON.parse(readFileSync(join(home, ".fx", "auth.json"), "utf8")) as {
|
||||
@@ -3389,8 +3362,7 @@ tmuxTest(
|
||||
await session.kill();
|
||||
session = await startFx(home, stderrPath, gateway, oauth.issuerUrl);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
await session.sendText("use the selected team after restart");
|
||||
await session.waitForText(LOGIN_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests[0].headers.get("authorization")).toBe(`Bearer ${LOGIN_TOKEN}`);
|
||||
@@ -3429,8 +3401,7 @@ tmuxTest(
|
||||
expect(initial).not.toContain("Sign in with Vercel");
|
||||
expect(initial).not.toContain("Switch credential");
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
await session.sendText("use normal startup precedence");
|
||||
await session.waitForText(ENV_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests).toHaveLength(1);
|
||||
@@ -3438,8 +3409,7 @@ tmuxTest(
|
||||
expect(readFileSync(authPath, "utf8")).toBe(seededAuthFile);
|
||||
|
||||
await selectFxLoginCredential(session);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
const selectedAuth = JSON.parse(readFileSync(authPath, "utf8")) as {
|
||||
team_id?: string;
|
||||
team_slug?: string;
|
||||
@@ -3457,10 +3427,9 @@ tmuxTest(
|
||||
await session.kill();
|
||||
session = await startFx(home, stderrPath, gateway, oauth.issuerUrl);
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
// The switch above is remembered, so the restart keeps fx login rather than
|
||||
// letting AI_GATEWAY_API_KEY reclaim it through precedence.
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
expect(JSON.parse(readFileSync(authPath, "utf8")).team_slug).toBe("vercel-labs");
|
||||
await session.sendText("use the remembered credential after restart");
|
||||
await session.waitForText(RESTART_RESPONSE, TIMEOUT);
|
||||
@@ -3515,8 +3484,7 @@ tmuxTest(
|
||||
(await session.captureFullScrollback()).match(/Signed in to Vercel\./g) ?? [],
|
||||
).toHaveLength(1);
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=fx login", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "fx login");
|
||||
await session.sendText("direct login prompt");
|
||||
await session.waitForText(DIRECT_LOGIN_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests).toHaveLength(4);
|
||||
@@ -3535,8 +3503,7 @@ tmuxTest(
|
||||
{ tokenTypeHint: "refresh_token", validForm: true },
|
||||
{ tokenTypeHint: "access_token", validForm: true },
|
||||
]);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
await session.sendText("use the API key after logout");
|
||||
await session.waitForText(LOGOUT_FALLBACK_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests).toHaveLength(5);
|
||||
@@ -6375,8 +6342,7 @@ tmuxTest(
|
||||
).toHaveLength(1);
|
||||
expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false);
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
|
||||
await session.sendText("prove environment auth remains active");
|
||||
await session.waitForText(ENV_RESPONSE, TIMEOUT);
|
||||
@@ -6426,8 +6392,7 @@ tmuxTest(
|
||||
await session.waitForText("Signed out of fx.", TIMEOUT);
|
||||
expect(existsSync(join(home, ".fx", "auth.json"))).toBe(false);
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
await session.sendText("prove the active API key was unchanged");
|
||||
await session.waitForText(ENV_RESPONSE, TIMEOUT);
|
||||
expect(gateway.requests).toHaveLength(1);
|
||||
@@ -6455,8 +6420,7 @@ tmuxTest(
|
||||
const loggedOut = await session.waitForText("Signed out of fx.", TIMEOUT);
|
||||
expect(existsSync(authPath)).toBe(false);
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=AI_GATEWAY_API_KEY", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "AI_GATEWAY_API_KEY");
|
||||
expect(oauth.requests).toEqual([]);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
for (const secret of [
|
||||
@@ -6500,8 +6464,7 @@ tmuxTest(
|
||||
);
|
||||
expect(existsSync(authPath)).toBe(true);
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=missing", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "missing");
|
||||
expect(gateway.requests).toHaveLength(0);
|
||||
expect(oauth.requests).toEqual([]);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
@@ -6526,8 +6489,7 @@ tmuxTest(
|
||||
await session.waitForComposer(TIMEOUT);
|
||||
await session.sendText("/logout");
|
||||
await session.waitForText("Signed out of fx.", TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth=missing", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panelAuth(panel) === "missing");
|
||||
|
||||
await session.sendText("/setup");
|
||||
const picker = await session.waitForPane(
|
||||
@@ -6625,8 +6587,7 @@ tmuxTest(
|
||||
expect(gateway.requests).toHaveLength(3);
|
||||
expect(oauth.requests.filter((request) => request.path === "/oauth/token")).toHaveLength(0);
|
||||
await Bun.sleep(Math.max(0, expiresAt - 60_000 + 100 - Date.now()));
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("auth_expired=true", TIMEOUT);
|
||||
await readStatusPanel(session, TIMEOUT, (panel) => panel.fields.Auth?.split(" · ").includes("expired") === true);
|
||||
const sessionIds = rootSessionIds(home);
|
||||
expect(sessionIds).toHaveLength(1);
|
||||
const historyPath = join(sessionDir(home, sessionIds[0]!), "log.jsonl");
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { FX_BIN, REPO_ROOT, runFx } from "../evals/eval-helpers";
|
||||
import { closeStatusPanel, waitForStatusPanel } from "./status-panel";
|
||||
import {
|
||||
FAKE_GATEWAY_MODEL,
|
||||
fakeGatewayFinalText,
|
||||
@@ -1326,10 +1327,9 @@ tmuxTest(
|
||||
|
||||
await typeLiteral(active, "/status");
|
||||
await active.sendKeys("Enter");
|
||||
await active.waitForPane(
|
||||
(pane) => pane.includes("model=") && pane.includes("[Image 1]"),
|
||||
READY_TIMEOUT,
|
||||
);
|
||||
await waitForStatusPanel(active, READY_TIMEOUT);
|
||||
await closeStatusPanel(active, READY_TIMEOUT);
|
||||
await active.waitForPane((pane) => pane.includes("[Image 1]"), READY_TIMEOUT);
|
||||
|
||||
expect(gateway?.requests).toHaveLength(0);
|
||||
const composer = (await active.capturePaneGrid())
|
||||
|
||||
@@ -373,8 +373,8 @@ describe("tui: render record/replay", () => {
|
||||
await session.sendText(marker);
|
||||
await session.waitForText("fx needs access to Vercel AI Gateway", 5_000);
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("permission_mode", 5_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForText("* permissions: mode=", 5_000);
|
||||
await session.sendKeys("-l 'replay_input_tail_visible'");
|
||||
await session.waitForText(inputTail, 5_000);
|
||||
await session.resizeWindow(64, 24);
|
||||
@@ -515,8 +515,8 @@ describe("tui: render record/replay", () => {
|
||||
await session.waitForText("fx needs access to Vercel AI Gateway", 5_000);
|
||||
await session.resizeWindow(72, 24);
|
||||
await session.sendKeys("C-u");
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("permission_mode", 5_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForText("* permissions: mode=", 5_000);
|
||||
const trace = await waitForTrace(
|
||||
launched.tracePath,
|
||||
(text) =>
|
||||
|
||||
@@ -117,8 +117,8 @@ describe.skipIf(SKIP)("tui: render stress", () => {
|
||||
await session.waitForText("Commands 37", 5_000);
|
||||
await session.sendKeys("Escape");
|
||||
await session.waitForPane((pane) => !pane.includes("enter open"), 5_000);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("permission_mode", 5_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForText("* permissions: mode=", 5_000);
|
||||
const unknownCommand = `/unknown-render-stress-${run} local transcript notice`;
|
||||
await session.sendLiteralText(unknownCommand);
|
||||
await session.waitForPane(
|
||||
|
||||
@@ -1993,7 +1993,8 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
finish(1);
|
||||
await session.waitForStableComposer(15_000);
|
||||
await assertRows(tailCount);
|
||||
await session.sendText("/status");
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForText("* permissions: mode=", 10_000);
|
||||
await session.waitForStableComposer(10_000);
|
||||
await assertRows(tailCount);
|
||||
await session.sendText("/quit");
|
||||
@@ -2234,11 +2235,11 @@ describe.skipIf(SKIP)("tui: resize", () => {
|
||||
expect(gateway.requests).toHaveLength(4);
|
||||
});
|
||||
check("native history after output", () => expectHistory(afterOutput, true));
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("agent_step_limit=", 10_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForText("* permissions: mode=", 10_000);
|
||||
await session.waitForStableComposer(10_000);
|
||||
const afterStatus = await capture("after-status");
|
||||
check("native history after status", () => expectHistory(afterStatus, true));
|
||||
const afterNotice = await capture("after-notice");
|
||||
check("native history after a local notice", () => expectHistory(afterNotice, true));
|
||||
if (!pressure) check("control never prunes", () => {
|
||||
expect(readFileSync(tracePath, "utf8")).not.toMatch(/pruned (?:command output|entry)/);
|
||||
});
|
||||
|
||||
@@ -1783,9 +1783,8 @@ test.skipIf(!tmuxAvailable())(
|
||||
expect(compact).not.toContain(stdoutTail);
|
||||
expect(compact).not.toContain(stderrTail);
|
||||
|
||||
await active.sendText("/status");
|
||||
const status = await active.waitForText(/model|permission_mode/i, TIMEOUT);
|
||||
expect(status.toLowerCase()).toMatch(/model|permission_mode/);
|
||||
await active.sendText("/permissions");
|
||||
await active.waitForText("* permissions: mode=", TIMEOUT);
|
||||
expect(active.paneStatus()).toEqual({ dead: false, status: null });
|
||||
const sessionId = sessionIdFromHome(home);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { FX_BIN, HAS_API_KEY } from "../evals/eval-helpers";
|
||||
import { panelSession, readStatusPanel } from "./status-panel";
|
||||
import {
|
||||
FAKE_GATEWAY_MODEL,
|
||||
fakeGatewayFinalText,
|
||||
@@ -154,7 +155,7 @@ describe.skipIf(TMUX_SKIP)("tui: no-key slash commands", () => {
|
||||
);
|
||||
|
||||
test(
|
||||
"compact status notice preserves native scrollback",
|
||||
"compact status panel leaves native scrollback whole",
|
||||
async () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "fx-status-compact-"));
|
||||
const home = join(root, "home");
|
||||
@@ -193,17 +194,15 @@ describe.skipIf(TMUX_SKIP)("tui: no-key slash commands", () => {
|
||||
await session.waitForComposer(10_000);
|
||||
expect((await session.captureFullScrollback()).split("Run /help for commands")).toHaveLength(2);
|
||||
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("agent_step_limit=", 5_000);
|
||||
// Twelve rows leave the panel too few to show every line.
|
||||
const panel = await readStatusPanel(session, 5_000, (shown) => shown.fields.Permissions === "auto");
|
||||
expect(panel.labels[0]).toBe("Session");
|
||||
expect(panel.labels).not.toContain("Build");
|
||||
await session.waitForComposer(5_000);
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
|
||||
for (const field of [
|
||||
"Run /help for commands",
|
||||
"auth_refreshable=",
|
||||
"permission_mode=auto",
|
||||
]) {
|
||||
expect(scrollback.split(field)).toHaveLength(2);
|
||||
expect(scrollback.split("Run /help for commands")).toHaveLength(2);
|
||||
for (const label of ["Session", "Permissions", "Session grants"]) {
|
||||
expect(scrollback).not.toMatch(new RegExp(`^ {2}${label} {2,}\\S`, "m"));
|
||||
}
|
||||
expect(session.paneStatus()).toEqual({ dead: false, status: null });
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
@@ -224,12 +223,14 @@ describe.skipIf(TMUX_SKIP)("tui: no-key slash commands", () => {
|
||||
|
||||
describe.skipIf(SKIP)("tui: slash commands", () => {
|
||||
test(
|
||||
"/status shows session info",
|
||||
"/status shows the session, model, and permissions in a panel and Esc closes it",
|
||||
async () => {
|
||||
session = await launchAndWait();
|
||||
await session.sendText("/status");
|
||||
const pane = await session.waitForText(/model|permission/i, 5_000);
|
||||
expect(pane.toLowerCase()).toMatch(/model|permission/);
|
||||
const panel = await readStatusPanel(session, 5_000);
|
||||
expect(panelSession(panel).id).toMatch(/^[A-Za-z0-9_-]{12}$/);
|
||||
expect(panel.fields.Model).toBeTruthy();
|
||||
expect(panel.fields.Permissions).toBeTruthy();
|
||||
await session.waitForComposer(5_000);
|
||||
},
|
||||
TIMEOUT,
|
||||
);
|
||||
|
||||
@@ -274,13 +274,13 @@ describe.skipIf(SKIP_TMUX)("tui: fresh-session commands", () => {
|
||||
expect(initial.split(banner)).toHaveLength(2);
|
||||
|
||||
for (const command of ["/clear", "/reset", "/new"]) {
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("model=", 5_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForText("* permissions: mode=", 5_000);
|
||||
await session.sendText(command);
|
||||
const pane = await session.waitForPane(
|
||||
(value) =>
|
||||
value.includes(banner) &&
|
||||
!value.includes("model=") &&
|
||||
!value.includes("* permissions: mode=") &&
|
||||
hasEmptyComposer(value),
|
||||
5_000,
|
||||
);
|
||||
@@ -334,24 +334,24 @@ describe.skipIf(SKIP_TMUX)("tui: fresh-session commands", () => {
|
||||
});
|
||||
await session.waitForComposer(10_000);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
await session.sendText("/status");
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForComposer(5_000);
|
||||
}
|
||||
const before = await session.captureFullScrollback();
|
||||
const lastStatus = before.slice(before.lastIndexOf("* status:"));
|
||||
expect(lastStatus).toContain("agent_step_limit=0");
|
||||
const lastNotice = before.slice(before.lastIndexOf("* permissions: mode="));
|
||||
expect(lastNotice).toContain("* permissions: saved-session permission rules: none");
|
||||
|
||||
await session.sendText("/new");
|
||||
await session.waitForPane(
|
||||
(pane) => pane.includes(banner) && !pane.includes("model=") && hasEmptyComposer(pane),
|
||||
(pane) => pane.includes(banner) && !pane.includes("* permissions: mode=") && hasEmptyComposer(pane),
|
||||
5_000,
|
||||
);
|
||||
const after = await session.captureFullScrollback();
|
||||
const priorStatus = after.slice(after.lastIndexOf("* status:"), after.lastIndexOf(banner)).trimEnd();
|
||||
expect(priorStatus).toBe(lastStatus.split("\n┃")[0]?.trimEnd());
|
||||
expect(priorStatus).not.toContain("Commands 1");
|
||||
expect(priorStatus).not.toContain("run /login ·");
|
||||
expect(await session.capturePane()).not.toContain("model=");
|
||||
const priorNotice = after.slice(after.lastIndexOf("* permissions: mode="), after.lastIndexOf(banner)).trimEnd();
|
||||
expect(priorNotice).toBe(lastNotice.split("\n┃")[0]?.trimEnd());
|
||||
expect(priorNotice).not.toContain("Commands 1");
|
||||
expect(priorNotice).not.toContain("run /login ·");
|
||||
expect(await session.capturePane()).not.toContain("* permissions: mode=");
|
||||
const replay = JSON.parse(execFileSync(FX_BIN, ["replay", tapePath, "--json"], { encoding: "utf8" }));
|
||||
expect(replay.frame_count).toBeGreaterThan(0);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
@@ -449,34 +449,40 @@ describe.skipIf(SKIP_TMUX)("tui: fresh-session commands", () => {
|
||||
height: 18,
|
||||
});
|
||||
await session.waitForComposer(10_000);
|
||||
await session.sendText("/status");
|
||||
await session.waitForPane((pane) => pane.includes("agent_step_limit=0"), 5_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForPane((pane) => pane.includes("saved-session permission rules: none"), 5_000);
|
||||
const before = await session.captureFullScrollback();
|
||||
const expectedStatus = before.slice(before.lastIndexOf("* status:")).split("\n┃")[0]?.trimEnd();
|
||||
expect(expectedStatus).toContain("agent_step_limit=0");
|
||||
const expectedNotice = before.slice(before.lastIndexOf("* permissions: mode=")).split("\n┃")[0]?.trimEnd();
|
||||
expect(expectedNotice).toContain("saved-session permission rules: none");
|
||||
session.sendLiteralImmediate("/new");
|
||||
await session.resizeWindow(78, 18, 0);
|
||||
await Bun.sleep(40);
|
||||
session.sendKeysImmediate(["Enter"]);
|
||||
|
||||
await session.waitForPane(
|
||||
(pane) => pane.includes(banner) && !pane.includes("model=") && hasEmptyComposer(pane),
|
||||
(pane) => pane.includes(banner) && !pane.includes("* permissions: mode=") && hasEmptyComposer(pane),
|
||||
10_000,
|
||||
);
|
||||
const history = await session.captureFullScrollback();
|
||||
const oldStatus = history.slice(history.lastIndexOf("* status:"), history.lastIndexOf(banner));
|
||||
const oldNotice = history.slice(history.lastIndexOf("* permissions: mode="), history.lastIndexOf(banner));
|
||||
let lastIndex = -1;
|
||||
for (const field of ["* status:", "permission_mode=auto", "workspace=", "history_turns=0", "session_permission_grants=0", "agent_step_limit=0"]) {
|
||||
const index = oldStatus.indexOf(field);
|
||||
for (const field of [
|
||||
"* permissions: mode=auto",
|
||||
"configured rules: (none)",
|
||||
"session grants: (none)",
|
||||
"usage: /permissions",
|
||||
"* permissions: saved-session permission rules: none",
|
||||
]) {
|
||||
const index = oldNotice.indexOf(field);
|
||||
expect(index).toBeGreaterThan(lastIndex);
|
||||
lastIndex = index;
|
||||
}
|
||||
expect(oldStatus.replace(/\s+/g, "")).toBe(expectedStatus?.replace(/\s+/g, ""));
|
||||
expect(oldStatus).not.toContain("Commands 1");
|
||||
expect(oldStatus).not.toContain("run /login ·");
|
||||
expect(oldNotice.replace(/\s+/g, "")).toBe(expectedNotice?.replace(/\s+/g, ""));
|
||||
expect(oldNotice).not.toContain("Commands 1");
|
||||
expect(oldNotice).not.toContain("run /login ·");
|
||||
expect(session.isAlive()).toBe(true);
|
||||
await session.sendText("/status");
|
||||
await session.waitForPane((pane) => pane.includes("agent_step_limit=0") && hasEmptyComposer(pane), 5_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForPane((pane) => pane.includes("saved-session permission rules: none") && hasEmptyComposer(pane), 5_000);
|
||||
expect(readFileSync(stderrPath, "utf8")).toBe("");
|
||||
const replay = JSON.parse(execFileSync(FX_BIN, ["replay", tapePath, "--json"], { encoding: "utf8" }));
|
||||
expect(replay.frame_count).toBeGreaterThan(0);
|
||||
@@ -510,8 +516,8 @@ describe.skipIf(SKIP_TMUX)("tui: fresh-session commands", () => {
|
||||
height: 18,
|
||||
});
|
||||
await session.waitForComposer(10_000);
|
||||
await session.sendText("/status");
|
||||
await session.waitForText("agent_step_limit=0", 5_000);
|
||||
await session.sendText("/permissions");
|
||||
await session.waitForText("saved-session permission rules: none", 5_000);
|
||||
session.sendLiteralImmediate("/new");
|
||||
await session.resizeWindow(78, 3, 0);
|
||||
session.sendKeysImmediate(["Enter"]);
|
||||
|
||||
@@ -0,0 +1,283 @@
|
||||
// `/status` opens a panel in the footer that shows the open session's id and
|
||||
// whether it is saved (D90 in tests/tla/session_manager/system-design.md),
|
||||
// and writes nothing to the transcript. Each scenario drives the real binary
|
||||
// in a terminal and reads every panel back from the recorded tape through
|
||||
// fx's own virtual terminal (vt_emulator), then checks what the panel claimed
|
||||
// against what fx did: the session folders on disk, the exit line, a later
|
||||
// resume, and the wiring trace's record of each session opened.
|
||||
// tests/tla/drivers/session/ runs the same shell along the ShellSession model.
|
||||
import { expect, test } from "bun:test";
|
||||
import { runFx } from "../evals/eval-helpers";
|
||||
import { Profile } from "./shell-sessions";
|
||||
import { closedAsBefore, panelSession, showsStatusPanel, type StatusReport } from "./status-panel";
|
||||
import { tmuxAvailable } from "./tmux-helpers";
|
||||
import { assertSingleFooter } from "./tui-render-assertions";
|
||||
|
||||
const TIMEOUT = 120_000;
|
||||
const SESSION_ID = /^[A-Za-z0-9_-]{12}$/;
|
||||
|
||||
function scenario(name: string, body: (profile: Profile) => Promise<void>) {
|
||||
test.skipIf(!tmuxAvailable())(name, async () => {
|
||||
const profile = new Profile(name.replace(/[^A-Za-z0-9]+/g, "-").slice(0, 32));
|
||||
let passed = false;
|
||||
try {
|
||||
await body(profile);
|
||||
passed = true;
|
||||
} finally {
|
||||
await profile.close(!passed);
|
||||
}
|
||||
}, TIMEOUT);
|
||||
}
|
||||
|
||||
/// `report`'s panel names session `id` (null: no session), saved or not, on
|
||||
/// its first line, and closing it left the shell as it was before `/status`
|
||||
/// was typed, with the composer's footer drawn once: the transcript holds no
|
||||
/// row of it.
|
||||
function expectReport(report: StatusReport, id: string | null, saved: boolean) {
|
||||
expect(panelSession(report)).toEqual({ id, saved });
|
||||
expect(report.labels[0]).toBe("Session");
|
||||
expect(report.labels).toEqual(expect.arrayContaining(["Model", "Auth", "Permissions", "Workspace", "Turns"]));
|
||||
expect(showsStatusPanel(report.closed)).toBe(false);
|
||||
if (!closedAsBefore(report)) {
|
||||
throw new Error(`the /status panel at tape frame ${report.frame} changed the shell:\n--- before\n${report.before.join("\n")}\n--- closed\n${report.closed.join("\n")}`);
|
||||
}
|
||||
const failures: string[] = [];
|
||||
assertSingleFooter(report.closed, failures, `the grid after the /status panel at tape frame ${report.frame} closed`);
|
||||
expect(failures).toEqual([]);
|
||||
}
|
||||
|
||||
scenario("a new session is unsaved until its first prompt, and its id is the one to resume", async (profile) => {
|
||||
const shell = await profile.launch();
|
||||
const id = shell.current()!;
|
||||
expect(id).toMatch(SESSION_ID);
|
||||
await shell.status();
|
||||
expect(profile.disk()).toEqual({});
|
||||
expect(await profile.resumable(id)).toBe(false);
|
||||
|
||||
// The first turn saves the session when it starts, before its answer.
|
||||
const turn = await shell.begin("Save this conversation.");
|
||||
await shell.status();
|
||||
expect(profile.disk()).toEqual({ [id]: 1 });
|
||||
await turn.release("FIRST_ANSWER");
|
||||
await shell.status();
|
||||
expect(await shell.quit()).toBe(id);
|
||||
expect(shell.stderrText()).toBe("");
|
||||
|
||||
const [pristine, running, answered] = shell.reports();
|
||||
expectReport(pristine!, id, false);
|
||||
expect(pristine!.fields.Turns).toBe("0");
|
||||
expectReport(running!, id, true);
|
||||
expectReport(answered!, id, true);
|
||||
expect(answered!.fields.Turns).toBe("1");
|
||||
expect(answered!.fields.Workspace).toBe(profile.workspace);
|
||||
|
||||
// The id /status showed opens the session again.
|
||||
const again = await profile.launch({ args: ["--resume", panelSession(pristine!).id!] });
|
||||
await again.session.waitForText("FIRST_ANSWER", TIMEOUT);
|
||||
expect(again.current()).toBe(id);
|
||||
await again.status();
|
||||
expect(await again.quit()).toBe(id);
|
||||
expectReport(again.reports()[0]!, id, true);
|
||||
expect(again.stderrText()).toBe("");
|
||||
});
|
||||
|
||||
scenario("/new and /clear open fresh sessions, and one never prompted leaves nothing to resume", async (profile) => {
|
||||
const shell = await profile.launch();
|
||||
const first = shell.current()!;
|
||||
await shell.status();
|
||||
const second = await shell.newSession("/new");
|
||||
await shell.status();
|
||||
await shell.prompt("Keep the second session.", "SECOND_ANSWER");
|
||||
await shell.status();
|
||||
const third = await shell.newSession("/clear");
|
||||
await shell.status();
|
||||
expect(new Set([first, second, third]).size).toBe(3);
|
||||
expect(await shell.quit()).toBeNull();
|
||||
expect(shell.stderrText()).toBe("");
|
||||
|
||||
const reports = shell.reports();
|
||||
expectReport(reports[0]!, first, false);
|
||||
expectReport(reports[1]!, second, false);
|
||||
expectReport(reports[2]!, second, true);
|
||||
expectReport(reports[3]!, third, false);
|
||||
expect(profile.disk()).toEqual({ [second]: 1 });
|
||||
expect(await profile.resumable(first)).toBe(false);
|
||||
expect(await profile.resumable(second)).toBe(true);
|
||||
expect(await profile.resumable(third)).toBe(false);
|
||||
});
|
||||
|
||||
scenario("/resume switches /status to the session picked", async (profile) => {
|
||||
const shell = await profile.launch();
|
||||
const alpha = shell.current()!;
|
||||
await shell.prompt("Alpha session prompt.", "ALPHA_ANSWER");
|
||||
const bravo = await shell.newSession("/new");
|
||||
await shell.prompt("Bravo session prompt.", "BRAVO_ANSWER");
|
||||
const unprompted = await shell.newSession("/new");
|
||||
await shell.status();
|
||||
expect(await shell.resume("Alpha session")).toBe(alpha);
|
||||
await shell.status();
|
||||
expect(await shell.resume("Bravo session")).toBe(bravo);
|
||||
await shell.status();
|
||||
expect(await shell.quit()).toBe(bravo);
|
||||
expect(shell.stderrText()).toBe("");
|
||||
|
||||
const reports = shell.reports();
|
||||
expectReport(reports[0]!, unprompted, false);
|
||||
expectReport(reports[1]!, alpha, true);
|
||||
expectReport(reports[2]!, bravo, true);
|
||||
expect(profile.disk()).toEqual({ [alpha]: 1, [bravo]: 1 });
|
||||
});
|
||||
|
||||
scenario("a fork before the first prompt is saved from the start, and the exit line names it", async (profile) => {
|
||||
const shell = await profile.launch();
|
||||
const source = shell.current()!;
|
||||
await shell.prompt("Fork source one.", "SOURCE_ONE");
|
||||
await shell.prompt("Fork source two.", "SOURCE_TWO");
|
||||
const fork = await shell.fork(1);
|
||||
expect(fork).not.toBe(source);
|
||||
await shell.status();
|
||||
expect(profile.disk()).toEqual({ [source]: 2, [fork]: 0 });
|
||||
expect(await shell.quit()).toBe(fork);
|
||||
expect(shell.stderrText()).toBe("");
|
||||
const [report] = shell.reports();
|
||||
expectReport(report!, fork, true);
|
||||
expect(report!.fields.Turns).toBe("0");
|
||||
|
||||
const again = await profile.launch({ args: ["--resume", fork] });
|
||||
expect(again.current()).toBe(fork);
|
||||
await again.status();
|
||||
expect(await again.quit()).toBe(fork);
|
||||
expectReport(again.reports()[0]!, fork, true);
|
||||
expect(again.stderrText()).toBe("");
|
||||
});
|
||||
|
||||
scenario("a fork at a later prompt holds the turns before it", async (profile) => {
|
||||
const shell = await profile.launch();
|
||||
const source = shell.current()!;
|
||||
for (const n of ["one", "two", "three"]) await shell.prompt(`Later fork ${n}.`, `LATER_${n.toUpperCase()}`);
|
||||
const fork = await shell.fork(1);
|
||||
await shell.status();
|
||||
expect(profile.disk()).toEqual({ [source]: 3, [fork]: 1 });
|
||||
await shell.prompt("A prompt in the fork.", "IN_FORK");
|
||||
await shell.status();
|
||||
expect(profile.disk()).toEqual({ [source]: 3, [fork]: 2 });
|
||||
expect(await shell.quit()).toBe(fork);
|
||||
const [forked, prompted] = shell.reports();
|
||||
expectReport(forked!, fork, true);
|
||||
expect(forked!.fields.Turns).toBe("1");
|
||||
expectReport(prompted!, fork, true);
|
||||
expect(prompted!.fields.Turns).toBe("2");
|
||||
});
|
||||
|
||||
scenario("a crash keeps every session /status called saved and loses the one it called unsaved", async (profile) => {
|
||||
const answered = await profile.launch();
|
||||
const kept = answered.current()!;
|
||||
await answered.prompt("Survive the crash.", "CRASH_ANSWER");
|
||||
await answered.status();
|
||||
await answered.crash();
|
||||
expectReport(answered.reports()[0]!, kept, true);
|
||||
|
||||
// Saved while its first turn still runs.
|
||||
const running = await profile.launch();
|
||||
const started = running.current()!;
|
||||
await running.begin("Crash while this runs.");
|
||||
await running.status();
|
||||
await running.crash();
|
||||
expectReport(running.reports()[0]!, started, true);
|
||||
|
||||
const unprompted = await profile.launch();
|
||||
const lost = unprompted.current()!;
|
||||
await unprompted.status();
|
||||
await unprompted.crash();
|
||||
expectReport(unprompted.reports()[0]!, lost, false);
|
||||
|
||||
expect(profile.disk()).toEqual({ [kept]: 1, [started]: 1 });
|
||||
expect(await profile.resumable(kept)).toBe(true);
|
||||
expect(await profile.resumable(started)).toBe(true);
|
||||
expect(await profile.resumable(lost)).toBe(false);
|
||||
});
|
||||
|
||||
scenario("a shell that cannot create a session shows none, saves nothing, and prints no exit line", async (profile) => {
|
||||
profile.blockStore();
|
||||
const shell = await profile.launch();
|
||||
expect(shell.current()).toBeNull();
|
||||
await shell.status();
|
||||
await shell.prompt("A prompt with no session.", "NO_STORE_ANSWER");
|
||||
await shell.status();
|
||||
await shell.command("/new");
|
||||
await shell.status();
|
||||
expect(await shell.quit()).toBeNull();
|
||||
expect(shell.current()).toBeNull();
|
||||
expect(shell.stderrText()).toBe("");
|
||||
for (const report of shell.reports()) expectReport(report, null, false);
|
||||
expect(profile.disk()).toEqual({});
|
||||
});
|
||||
|
||||
scenario("the panel wraps long values in a narrow terminal and keeps the session id whole", async (profile) => {
|
||||
const shell = await profile.launch({ width: 40, height: 40 });
|
||||
const id = shell.current()!;
|
||||
await shell.status();
|
||||
await shell.prompt("Narrow terminal prompt.", "NARROW_ANSWER");
|
||||
await shell.status();
|
||||
// The exit line is wider than 40 columns, so its command takes a row of its own.
|
||||
expect(await shell.quit()).toBe(id);
|
||||
expect(shell.stderrText()).toBe("");
|
||||
const [unsaved, saved] = shell.reports();
|
||||
expectReport(unsaved!, id, false);
|
||||
expectReport(saved!, id, true);
|
||||
for (const report of [unsaved!, saved!]) {
|
||||
expect(report.grid.every((row) => [...row].length <= 40)).toBe(true);
|
||||
// The workspace wraps between its folders and still reads back whole.
|
||||
const at = report.rows.findIndex((row) => row.startsWith(" Workspace "));
|
||||
expect(report.rows[at]!.trimEnd().endsWith("/")).toBe(true);
|
||||
expect(report.rows[at + 1]!).toMatch(/^ {3,}\S/);
|
||||
expect(report.fields.Workspace).toBe(profile.workspace);
|
||||
// The id sits whole on the session line, however its state wraps.
|
||||
expect(report.rows[0]!).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
scenario("a short terminal shows the panel's first lines, and ↓ scrolls it a line at a time to the last", async (profile) => {
|
||||
const shell = await profile.launch({ width: 80, height: 14 });
|
||||
const id = shell.current()!;
|
||||
await shell.status({ scroll: 24 });
|
||||
expect(await shell.quit()).toBeNull();
|
||||
expect(shell.stderrText()).toBe("");
|
||||
const [report] = shell.reports();
|
||||
expectReport(report!, id, false);
|
||||
|
||||
const [first, ...scrolled] = report!.views;
|
||||
expect(first!.labels[0]).toBe("Session");
|
||||
expect(first!.labels).not.toContain("Build");
|
||||
expect(first!.grid.some((row) => row.trimEnd() === "↑↓ scroll esc close")).toBe(true);
|
||||
expect(scrolled.length).toBeGreaterThan(0);
|
||||
let previous = first!;
|
||||
for (const view of scrolled) {
|
||||
// One line further down, as many lines showing.
|
||||
expect(view.rows.slice(0, -1)).toEqual(previous.rows.slice(1));
|
||||
expect(view.rows.length).toBe(first!.rows.length);
|
||||
previous = view;
|
||||
}
|
||||
// The last line is shown, and ↓ past it paints nothing.
|
||||
expect(previous.labels.at(-1)).toBe("Build");
|
||||
expect(previous.labels).not.toContain("Session");
|
||||
expect(scrolled.length).toBeLessThan(24);
|
||||
});
|
||||
|
||||
test("fx status runs outside any session and shows none", async () => {
|
||||
const profile = new Profile("cli-status");
|
||||
try {
|
||||
const env = { HOME: profile.home, FX_SOUND: "0", FX_AUTO_UPGRADE: "0", AI_GATEWAY_API_KEY: "status-session-fixture" };
|
||||
const text = await runFx(["status"], { cwd: profile.workspace, env });
|
||||
expect(text.code).toBe(0);
|
||||
expect(text.stdout).toContain("[status] history_turns=0\n");
|
||||
expect(text.stdout).not.toMatch(/^\[status\] session(_saved)?=/m);
|
||||
const json = await runFx(["status", "--json"], { cwd: profile.workspace, env });
|
||||
expect(json.code).toBe(0);
|
||||
const status = JSON.parse(json.stdout);
|
||||
expect(status.kind).toBe("status");
|
||||
expect(Object.keys(status).filter((key) => key.startsWith("session") && key !== "session_permission_grants")).toEqual([]);
|
||||
} finally {
|
||||
await profile.close(false);
|
||||
}
|
||||
});
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { runFx } from "../evals/eval-helpers";
|
||||
import { readStatusPanel } from "./status-panel";
|
||||
import {
|
||||
FAKE_GATEWAY_MODEL,
|
||||
fakeGatewayFinalText,
|
||||
@@ -349,10 +350,9 @@ describe.skipIf(!tmuxAvailable())("yolo interactive mode", () => {
|
||||
|
||||
await session.sendKeys("BTab");
|
||||
await session.waitForText("ask ·", TIMEOUT);
|
||||
await session.sendText("/status");
|
||||
const statusPane = await session.waitForText("agent_step_limit=", TIMEOUT);
|
||||
expect(statusPane).toContain("permission_mode=ask");
|
||||
expect(statusPane).not.toContain("sandbox=");
|
||||
const panel = await readStatusPanel(session, TIMEOUT);
|
||||
expect(panel.fields.Permissions).toBe("ask");
|
||||
expect(panel.rows.join("\n").toLowerCase()).not.toContain("sandbox");
|
||||
const scrollback = await session.captureFullScrollback();
|
||||
expect(scrollback).not.toContain("YOLO ·");
|
||||
expect(scrollback).not.toContain("YOLO enabled:");
|
||||
|
||||
+4
-1
@@ -12,7 +12,8 @@ TLA+ specifications for fx, checked with TLC, together with the scripts and Bun
|
||||
| `mcp/` | The MCP engine (`src/mcp/`): wire format, requests, stdio, version detection, the tool catalog, both HTTP transports, input-required rounds, authorization, subscriptions, elicitation, and the engine. `rules.md` lists the MCP specification rules the models cite by ID. | `mcp/check.sh`, then the drivers in `drivers/mcp/` |
|
||||
| `mcp_host/` | fx's MCP host (`src/core/mcp_host/`): host lifecycle, saved credentials, logins shared between processes, logins beside processes that run an older build (`MixedBuilds.tla`), sign-in, elicitation routing, and calls held for the URLs a server asks the user to open. | `mcp_host/check.sh`, `bun test drivers/mcp_host/` |
|
||||
| `sdk/` | libfx durable sessions (`DurableSession.tla`), how a session object takes prompts in (`PromptIntake.tla`), images saved once by name (`ImageStore.tla`), agent names and routing (`AgentRouting.tla`), tenants whose agents share one process (`TenantIsolation.tla`), steering (`DurableSteer.tla`), and the provider credential edge (`ProviderAuth.tla`). `check.sh` also writes the TLC state graphs the drivers walk. | `sdk/check.sh`, then the drivers |
|
||||
| `session_manager/` | The session log, turn lifecycle, writer lock, snapshots, fork, subagents, catalog, host wiring, the side-file move, v1 conversion, and prompt images saved as blobs (`PromptImages.tla`). `rollout/` holds the models of the changes that moved fx onto the session manager. `system-design.md` is the design record the specs check and defines the `D1` to `D89` decision numbers that code comments cite. | `session_manager/check.sh`, `session_manager/mutants.py`, `session_manager/rollout/run.py`, `bun test drivers/session_manager/` |
|
||||
| `session/` | The session the interactive app has open across launches, prompts, `/new`, `/clear`, `/resume`, `/fork`, quitting and crashes, and what `/status` and the exit line say about it (`src/core/session/`, D90). | `session/check.sh`, then `bun test ./tests/tla/drivers/session/`, which runs the real binary along it and has TLC check every run |
|
||||
| `session_manager/` | The session log, turn lifecycle, writer lock, snapshots, fork, subagents, catalog, host wiring, the side-file move, v1 conversion, and prompt images saved as blobs (`PromptImages.tla`). `rollout/` holds the models of the changes that moved fx onto the session manager. `system-design.md` is the design record the specs check and defines the `D1` to `D90` decision numbers that code comments cite. | `session_manager/check.sh`, `session_manager/mutants.py`, `session_manager/rollout/run.py`, `bun test drivers/session_manager/` |
|
||||
| `shell_command/` | How an agent git command is admitted and run (`src/core/shell_command/safe_git.zig`), and one model turn through the shell tool, for the current design and a redesign. | `shell_command/check.sh`, `bun test drivers/shell_command/` |
|
||||
| `terminal/` | The in-memory shell snapshot owner (`src/core/terminal/shell_snapshot.zig`) and the lifecycle of hosted terminals. | `terminal/check.sh` |
|
||||
| `usage/` | `/usage` refreshes and the shared usage cache (`src/usage/`): `LeanUsage.tla` and `UsageModel.tla`. | `usage/check.sh`, then the drivers in `drivers/usage/` |
|
||||
@@ -81,6 +82,8 @@ bun tests/tla/drivers/drive-agents.mjs --walks 60 # agent routing
|
||||
bun tests/tla/drivers/drive-tenants.mjs --walks 60 # tenants in one process
|
||||
bun tests/tla/drivers/drive-steer.mjs ideal --walks 20 # steering
|
||||
bun test ./tests/tla/drivers/shell_command/ # a driver written as bun tests; see each check.sh for its own
|
||||
tests/tla/session/check.sh # the shell's session, /status and the exit line
|
||||
bun test ./tests/tla/drivers/session/ # fx along it; FX_TLA_WALKS, FX_TLA_STEPS, FX_TLA_SEED set the random walks
|
||||
```
|
||||
|
||||
A `check.sh` that uses `lib/tlc.sh` takes config names to run only those, for example `tests/tla/usage/check.sh LeanUsage`. `Fork`, `V1Conversion`, and the full `LeanUsage` config can run for many minutes. `session_manager/README.md` describes each session spec, the planted bugs, and the trace checkers.
|
||||
@@ -19,7 +19,12 @@ export const repetitions = Number(process.env.FX_TLA_REPEAT ?? "1");
|
||||
|
||||
/// The checkout's tests/e2e/tmux-helpers.ts: tmux sessions and fake gateways.
|
||||
export async function targetHelpers() {
|
||||
const path = join(worktree, "tests", "e2e", "tmux-helpers.ts");
|
||||
return await targetModule("tmux-helpers.ts");
|
||||
}
|
||||
|
||||
/// A helper module from the checkout's tests/e2e/, such as shell-sessions.ts.
|
||||
export async function targetModule(name: string) {
|
||||
const path = join(worktree, "tests", "e2e", name);
|
||||
if (!existsSync(path)) throw new Error(`missing ${path}`);
|
||||
return await import(pathToFileURL(path).href);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
// Connects runs of the real fx binary to TLC. The observations of one
|
||||
// profile's run become ShellSessionObserved.tla, and
|
||||
// tests/tla/session/ShellSessionTrace.tla replays them through ShellSession's
|
||||
// own actions. ACCEPTED needs TLC's exit 12 on the NotAllMatched sentinel
|
||||
// with every observation matched. A finished search is REJECTED; then the
|
||||
// longest prefix the model can produce names the first observation it
|
||||
// cannot. Strict verdicts: missing or unreadable evidence is never accepted.
|
||||
import { mkdirSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { runTlc, sha256File, sha256Text, TLA_ROOT, writeJson } from "../lib/tlc";
|
||||
|
||||
export type Op =
|
||||
| "launch" | "launch_resume" | "launch_no_store" | "begin" | "end" | "status"
|
||||
| "new" | "clear" | "resume" | "fork" | "quit" | "crash";
|
||||
|
||||
/// One step of a run, with what fx showed and saved after it. Every field
|
||||
/// comes from fx's own output and files; one the step does not show is
|
||||
/// "none", 0 or false, as ShellSession's `Obs` leaves it.
|
||||
export interface Observation {
|
||||
op: Op;
|
||||
/// The session the step opened (wiring trace) or `/status` named.
|
||||
id: string;
|
||||
/// The prompt a fork was made before, counted from 1.
|
||||
point: number;
|
||||
/// What `/status` said.
|
||||
saved: boolean;
|
||||
/// The session the exit line named.
|
||||
handoff: string;
|
||||
/// Each saved session's id and the turns its log holds.
|
||||
disk: Record<string, number>;
|
||||
}
|
||||
|
||||
export type Verdict = "ACCEPTED" | "REJECTED" | "TOOL_ERROR";
|
||||
|
||||
export interface TraceResult {
|
||||
verdict: Verdict;
|
||||
length: number;
|
||||
matched: number;
|
||||
detail: string;
|
||||
firstMismatch?: { step: number; observation: Observation };
|
||||
hashes: Record<string, string>;
|
||||
}
|
||||
|
||||
const SPEC_DIR = join(TLA_ROOT, "session");
|
||||
const SPECS = ["ShellSession.tla", "ShellSessionTrace.tla"];
|
||||
|
||||
const str = (value: string) => JSON.stringify(value);
|
||||
const bool = (value: boolean) => (value ? "TRUE" : "FALSE");
|
||||
|
||||
function diskValue(disk: Record<string, number>): { ids: string; turns: string } {
|
||||
const ids = Object.keys(disk).sort();
|
||||
return {
|
||||
ids: `{${ids.map(str).join(", ")}}`,
|
||||
turns: ids.length === 0 ? "[i \\in {} |-> 0]" : `(${ids.map((id) => `${str(id)} :> ${disk[id]}`).join(" @@ ")})`,
|
||||
};
|
||||
}
|
||||
|
||||
function observationValue(o: Observation): string {
|
||||
const disk = diskValue(o.disk);
|
||||
return `[op |-> ${str(o.op)}, id |-> ${str(o.id)}, point |-> ${o.point}, saved |-> ${bool(o.saved)}, ` +
|
||||
`handoff |-> ${str(o.handoff)}, disk |-> ${disk.ids}, turns |-> ${disk.turns}]`;
|
||||
}
|
||||
|
||||
/// The ids a run named, and the most turns any log held.
|
||||
function constants(observations: Observation[]): { ids: string[]; maxTurns: number } {
|
||||
const ids = new Set<string>();
|
||||
let maxTurns = 1;
|
||||
for (const o of observations) {
|
||||
for (const id of [o.id, o.handoff, ...Object.keys(o.disk)]) if (id !== "none") ids.add(id);
|
||||
for (const turns of Object.values(o.disk)) maxTurns = Math.max(maxTurns, turns);
|
||||
}
|
||||
return { ids: [...ids].sort(), maxTurns };
|
||||
}
|
||||
|
||||
export function observedModule(observations: Observation[]): string {
|
||||
const { ids, maxTurns } = constants(observations);
|
||||
return [
|
||||
"---------------------------- MODULE ShellSessionObserved ----------------------------",
|
||||
"(* Generated by tests/tla/drivers/session/bridge.ts from one run. Do not edit. *)",
|
||||
"EXTENDS TLC",
|
||||
`TIds == {${ids.map(str).join(", ")}}`,
|
||||
`TMaxTurns == ${maxTurns}`,
|
||||
`Observed == << ${observations.map(observationValue).join(",\n ")} >>`,
|
||||
"=============================================================================",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
const TRACE_CFG = [
|
||||
"CONSTANTS",
|
||||
" Ids <- TIds",
|
||||
" MaxTurns <- TMaxTurns",
|
||||
' Mutant = "none"',
|
||||
"INIT TraceInit",
|
||||
"NEXT TraceNext",
|
||||
"INVARIANT NotAllMatched",
|
||||
"CHECK_DEADLOCK FALSE",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
async function replay(observations: Observation[], dir: string): Promise<Verdict> {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(join(dir, "ShellSessionObserved.tla"), observedModule(observations));
|
||||
writeFileSync(join(dir, "trace.cfg"), TRACE_CFG);
|
||||
const run = await runTlc(dir, "ShellSessionTrace.tla", "trace.cfg", SPECS, SPEC_DIR);
|
||||
const matched = [...run.output.matchAll(/\/\\ k = (\d+)/g)].map((match) => Number(match[1])).at(-1);
|
||||
if (run.exitCode === 12 && run.violated === "NotAllMatched" && matched === observations.length) return "ACCEPTED";
|
||||
if (run.exitCode === 0 && run.output.includes("Model checking completed. No error has been found.")) return "REJECTED";
|
||||
return "TOOL_ERROR";
|
||||
}
|
||||
|
||||
/// Replays `observations` through the model. `dir` receives every TLC run.
|
||||
export async function checkTrace(observations: Observation[], dir: string): Promise<TraceResult> {
|
||||
const hashes = {
|
||||
...Object.fromEntries(SPECS.map((name) => [name, sha256File(join(SPEC_DIR, name))])),
|
||||
observations: sha256Text(JSON.stringify(observations)),
|
||||
};
|
||||
const result = await (async (): Promise<TraceResult> => {
|
||||
const length = observations.length;
|
||||
if (length === 0) return { verdict: "TOOL_ERROR", length, matched: 0, detail: "no observations", hashes };
|
||||
const whole = await replay(observations, join(dir, "whole"));
|
||||
if (whole !== "REJECTED") {
|
||||
return { verdict: whole, length, matched: whole === "ACCEPTED" ? length : 0, detail: `TLC work in ${dir}`, hashes };
|
||||
}
|
||||
// A prefix of a behavior is a behavior, so the longest one accepted is
|
||||
// found by bisection.
|
||||
let good = 0, bad = length;
|
||||
while (bad - good > 1) {
|
||||
const mid = Math.floor((good + bad) / 2);
|
||||
const verdict = await replay(observations.slice(0, mid), join(dir, `prefix-${mid}`));
|
||||
if (verdict === "TOOL_ERROR") return { verdict, length, matched: 0, detail: `TLC failed on a prefix of ${mid}; see ${dir}`, hashes };
|
||||
if (verdict === "ACCEPTED") good = mid;
|
||||
else bad = mid;
|
||||
}
|
||||
return {
|
||||
verdict: "REJECTED", length, matched: good, hashes,
|
||||
firstMismatch: { step: good + 1, observation: observations[good]! },
|
||||
detail: `the model cannot produce step ${good + 1}: ${JSON.stringify(observations[good])}`,
|
||||
};
|
||||
})();
|
||||
writeJson(join(dir, "verdict.json"), result);
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
// Runs the real fx binary along tests/tla/session/ShellSession.tla: a profile
|
||||
// outlives several processes, each of which launches, prompts, runs
|
||||
// `/status`, `/new`, `/clear`, `/resume` and `/fork`, then quits or crashes.
|
||||
// After every step the driver records what fx showed and saved, from fx's
|
||||
// own output only: the session each step opened from the wiring trace, each
|
||||
// `/status` panel as fx's virtual terminal replays it from the recorded
|
||||
// tape, the exit line, and the session folders on disk. TLC then replays the
|
||||
// whole run through the model (ShellSessionTrace.tla). Every panel must also
|
||||
// close without adding a row to the transcript.
|
||||
//
|
||||
// The scripted walk reaches every action and every case D90 names. The
|
||||
// random walks (FX_TLA_WALKS, default 2, of FX_TLA_STEPS steps, default 16,
|
||||
// from FX_TLA_SEED, default 1) choose among the steps a user could take
|
||||
// next; the driver tracks only what it did, never what fx should show.
|
||||
// Planted changes to a kept run must be rejected, and an empty run is no
|
||||
// evidence at all.
|
||||
import { expect, test } from "bun:test";
|
||||
import { join } from "node:path";
|
||||
import { artifacts, binary, targetModule } from "../lib/context";
|
||||
import { writeJson } from "../lib/tlc";
|
||||
import { checkTrace, type Observation, type Op } from "./bridge";
|
||||
|
||||
const { Profile } = await targetModule("shell-sessions.ts");
|
||||
const { closedAsBefore, panelSession, showsStatusPanel } = await targetModule("status-panel.ts");
|
||||
const { tmuxAvailable } = await targetModule("tmux-helpers.ts");
|
||||
|
||||
const TIMEOUT = 900_000;
|
||||
const MAX_TURNS = 3;
|
||||
|
||||
type Step =
|
||||
| { op: "launch" | "launch_no_store" | "begin" | "end" | "status" | "new" | "clear" | "quit" | "crash" }
|
||||
| { op: "launch_resume"; target: string }
|
||||
| { op: "resume"; marker: string }
|
||||
| { op: "fork"; up: number };
|
||||
|
||||
/// What the driver did to one session: the prompts it holds, copied ones
|
||||
/// included, the marker its own prompts carry, the session it was forked
|
||||
/// from, and whether its first prompt was answered, which titles it (D52).
|
||||
type Tracked = { prompts: string[]; marker: string | null; source: string | null; titled: boolean };
|
||||
|
||||
/// What the driver did, and nothing it expects fx to show: which process
|
||||
/// runs, which session it last opened, what each session was given, and
|
||||
/// whether a reply is held.
|
||||
class Walk {
|
||||
readonly profile: any;
|
||||
readonly observations: Observation[] = [];
|
||||
readonly sessions = new Map<string, Tracked>();
|
||||
shell: any = null;
|
||||
blocked = false;
|
||||
held: { release(answer: string): Promise<void> } | null = null;
|
||||
/// Each `/status` observation's index, per shell, filled once it ends.
|
||||
private pending: Array<{ shell: any; index: number }> = [];
|
||||
private prompts = 0;
|
||||
private markers = 0;
|
||||
|
||||
constructor(readonly name: string) {
|
||||
this.profile = new Profile(name, binary);
|
||||
}
|
||||
|
||||
current(): string | null {
|
||||
return this.shell?.current() ?? null;
|
||||
}
|
||||
|
||||
tracked(id: string): Tracked {
|
||||
if (!this.sessions.has(id)) this.sessions.set(id, { prompts: [], marker: null, source: null, titled: false });
|
||||
return this.sessions.get(id)!;
|
||||
}
|
||||
|
||||
/// The saved sessions, as the last step left the disk.
|
||||
saved(): string[] {
|
||||
return Object.keys(this.observations.at(-1)?.disk ?? {});
|
||||
}
|
||||
|
||||
/// Saved sessions the `/resume` picker finds alone by their marker: not
|
||||
/// open, not a fork, titled, and with no fork on disk, whose title and
|
||||
/// copied prompts carry the same marker.
|
||||
pickable(): Array<{ id: string; marker: string }> {
|
||||
const saved = this.saved();
|
||||
const descends = (id: string, from: string): boolean => {
|
||||
for (let at = this.sessions.get(id)?.source ?? null; at; at = this.sessions.get(at)?.source ?? null) {
|
||||
if (at === from) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
return saved.flatMap((id) => {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session?.marker || !session.titled || session.source || id === this.current()) return [];
|
||||
if (saved.some((other) => descends(other, id))) return [];
|
||||
return [{ id, marker: session.marker }];
|
||||
});
|
||||
}
|
||||
|
||||
async run(step: Step): Promise<void> {
|
||||
const observed: Observation = { op: step.op as Op, id: "none", point: 0, saved: false, handoff: "none", disk: {} };
|
||||
switch (step.op) {
|
||||
case "launch":
|
||||
this.shell = await this.profile.launch();
|
||||
observed.id = this.current() ?? "none";
|
||||
break;
|
||||
case "launch_resume":
|
||||
this.shell = await this.profile.launch({ args: ["--resume", step.target] });
|
||||
observed.id = this.current() ?? "none";
|
||||
break;
|
||||
case "launch_no_store":
|
||||
this.profile.blockStore();
|
||||
this.blocked = true;
|
||||
this.shell = await this.profile.launch();
|
||||
observed.id = this.current() ?? "none";
|
||||
break;
|
||||
case "begin": {
|
||||
const id = this.current();
|
||||
const session = id ? this.tracked(id) : null;
|
||||
if (session && !session.marker) session.marker = `M${String(++this.markers).padStart(3, "0")}X`;
|
||||
const text = `Walk ${this.name} ${session?.marker ?? "unsaved"} prompt ${++this.prompts}.`;
|
||||
this.held = await this.shell.begin(text);
|
||||
session?.prompts.push(text);
|
||||
break;
|
||||
}
|
||||
case "end": {
|
||||
await this.held!.release(`ANSWER_${this.prompts}`);
|
||||
this.held = null;
|
||||
const id = this.current();
|
||||
if (id) this.tracked(id).titled = true;
|
||||
break;
|
||||
}
|
||||
case "status":
|
||||
await this.shell.status();
|
||||
this.pending.push({ shell: this.shell, index: this.observations.length });
|
||||
break;
|
||||
case "new":
|
||||
case "clear":
|
||||
if (this.blocked) await this.shell.command(`/${step.op}`);
|
||||
else await this.shell.newSession(`/${step.op}`);
|
||||
observed.id = this.blocked ? "none" : this.current()!;
|
||||
break;
|
||||
case "resume":
|
||||
observed.id = await this.shell.resume(step.marker);
|
||||
break;
|
||||
case "fork": {
|
||||
const source = this.current()!;
|
||||
const prompts = this.tracked(source).prompts;
|
||||
observed.point = prompts.length - step.up;
|
||||
observed.id = await this.shell.fork(step.up);
|
||||
Object.assign(this.tracked(observed.id), { prompts: prompts.slice(0, observed.point - 1), source });
|
||||
break;
|
||||
}
|
||||
case "quit":
|
||||
observed.handoff = (await this.shell.quit()) ?? "none";
|
||||
this.ended();
|
||||
break;
|
||||
case "crash":
|
||||
await this.shell.crash();
|
||||
this.ended();
|
||||
break;
|
||||
}
|
||||
observed.disk = this.profile.disk();
|
||||
this.observations.push(observed);
|
||||
if (step.op === "quit" || step.op === "crash") this.fillReports();
|
||||
}
|
||||
|
||||
/// The id the newest `/status` of an ended process showed.
|
||||
lastStatusId(): string {
|
||||
return this.observations.findLast((o) => o.op === "status" && o.id !== "none")!.id;
|
||||
}
|
||||
|
||||
/// The process is gone, and with it any reply it was waiting for.
|
||||
private ended() {
|
||||
this.shell.ended = true;
|
||||
this.held = null;
|
||||
if (this.blocked) {
|
||||
this.profile.unblockStore();
|
||||
this.blocked = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Each `/status` the ended shell ran gets the panel its tape replays.
|
||||
private fillReports() {
|
||||
const shell = this.shell;
|
||||
const mine = this.pending.filter((entry) => entry.shell === shell);
|
||||
const reports = shell.reports();
|
||||
expect(reports.length).toBe(mine.length);
|
||||
mine.forEach((entry, n) => {
|
||||
const report = reports[n];
|
||||
const session = panelSession(report);
|
||||
this.observations[entry.index]!.id = session.id ?? "none";
|
||||
this.observations[entry.index]!.saved = session.saved;
|
||||
expect(showsStatusPanel(report.closed)).toBe(false);
|
||||
expect(closedAsBefore(report)).toBe(true);
|
||||
});
|
||||
this.pending = this.pending.filter((entry) => entry.shell !== shell);
|
||||
expect(shell.stderrText()).toBe("");
|
||||
this.shell = null;
|
||||
}
|
||||
|
||||
async finish(keep: boolean) {
|
||||
if (this.shell && !this.shell.ended) await this.run({ op: "quit" });
|
||||
await this.profile.close(keep);
|
||||
}
|
||||
}
|
||||
|
||||
/// Every action and every D90 case: a session unsaved until its first turn,
|
||||
/// saved while it runs, `/new` and `/clear` from saved and unsaved sessions,
|
||||
/// `/resume`, a fork before the first prompt and a later one, a resume of the
|
||||
/// id the exit line printed and of the id `/status` showed, crashes with and
|
||||
/// without a saved session, and a launch without a store.
|
||||
function scriptedSteps(): Array<Step | ((w: Walk) => Step)> {
|
||||
const pick = (w: Walk): Step => ({ op: "resume", marker: w.pickable()[0]!.marker });
|
||||
return [
|
||||
{ op: "launch" }, { op: "status" },
|
||||
{ op: "begin" }, { op: "status" }, { op: "end" }, { op: "status" },
|
||||
{ op: "new" }, { op: "status" }, { op: "clear" }, { op: "status" },
|
||||
{ op: "begin" }, { op: "end" }, { op: "status" },
|
||||
pick, { op: "status" },
|
||||
{ op: "begin" }, { op: "end" },
|
||||
{ op: "fork", up: 1 }, { op: "status" },
|
||||
{ op: "quit" },
|
||||
(w) => ({ op: "launch_resume", target: w.observations.at(-1)!.handoff }), { op: "status" },
|
||||
{ op: "begin" }, { op: "end" }, { op: "begin" }, { op: "end" },
|
||||
{ op: "fork", up: 0 }, { op: "status" },
|
||||
{ op: "crash" },
|
||||
{ op: "launch" }, { op: "status" }, { op: "begin" }, { op: "status" }, { op: "crash" },
|
||||
(w) => ({ op: "launch_resume", target: w.lastStatusId() }), { op: "status" }, { op: "quit" },
|
||||
{ op: "launch" }, { op: "status" }, { op: "crash" },
|
||||
{ op: "launch_no_store" }, { op: "status" }, { op: "begin" }, { op: "end" }, { op: "new" }, { op: "status" }, { op: "quit" },
|
||||
(w) => ({ op: "launch_resume", target: w.saved()[0]! }), { op: "status" }, { op: "quit" },
|
||||
];
|
||||
}
|
||||
|
||||
/// A small seeded generator, so a failing walk can be run again.
|
||||
function prng(seed: number) {
|
||||
let state = seed >>> 0 || 1;
|
||||
return (n: number) => {
|
||||
state ^= state << 13; state >>>= 0;
|
||||
state ^= state >>> 17;
|
||||
state ^= state << 5; state >>>= 0;
|
||||
return state % n;
|
||||
};
|
||||
}
|
||||
|
||||
/// The steps a user could take next, from what the driver did, each listed
|
||||
/// as often as it should be taken: prompts, forks and resumes more often
|
||||
/// than ending a process, so a walk reaches sessions worth checking.
|
||||
function choices(w: Walk): Step[] {
|
||||
const times = (count: number, step: Step): Step[] => Array.from({ length: count }, () => step);
|
||||
if (!w.shell) {
|
||||
return [
|
||||
...times(3, { op: "launch" }), { op: "launch_no_store" },
|
||||
...w.saved().flatMap((target) => times(2, { op: "launch_resume", target })),
|
||||
];
|
||||
}
|
||||
if (w.held) return [...times(3, { op: "end" }), ...times(2, { op: "status" }), { op: "quit" }, { op: "crash" }];
|
||||
const cur = w.current();
|
||||
const prompts = cur ? w.tracked(cur).prompts.length : 0;
|
||||
const steps: Step[] = [...times(3, { op: "status" }), { op: "new" }, { op: "clear" }, { op: "quit" }, { op: "crash" }];
|
||||
if (prompts < MAX_TURNS) steps.push(...times(4, { op: "begin" }));
|
||||
if (cur && !w.blocked) {
|
||||
for (let up = 0; up < prompts; up++) steps.push(...times(2, { op: "fork", up }));
|
||||
for (const { marker } of w.pickable()) steps.push(...times(2, { op: "resume", marker }));
|
||||
}
|
||||
return steps;
|
||||
}
|
||||
|
||||
async function walk(name: string, plan: (w: Walk) => Promise<void>) {
|
||||
const w = new Walk(name);
|
||||
let passed = false;
|
||||
try {
|
||||
await plan(w);
|
||||
await w.finish(false);
|
||||
passed = true;
|
||||
} finally {
|
||||
if (!passed) await w.finish(true).catch(() => {});
|
||||
writeJson(join(artifacts, name, "observations.json"), w.observations);
|
||||
}
|
||||
const result = await checkTrace(w.observations, join(artifacts, name, "tlc"));
|
||||
console.log(`${name}: ${result.verdict} ${result.matched}/${result.length} ${result.detail}`);
|
||||
return { observations: w.observations, result };
|
||||
}
|
||||
|
||||
let kept: Observation[] | null = null;
|
||||
|
||||
test.skipIf(!tmuxAvailable())("the scripted walk is a behavior of ShellSession", async () => {
|
||||
const { observations, result } = await walk("scripted", async (w) => {
|
||||
for (const step of scriptedSteps()) await w.run(typeof step === "function" ? step(w) : step);
|
||||
});
|
||||
expect(result.verdict).toBe("ACCEPTED");
|
||||
for (const op of ["launch", "launch_resume", "launch_no_store", "begin", "end", "status", "new", "clear", "resume", "fork", "quit", "crash"]) {
|
||||
expect(observations.some((o) => o.op === op)).toBe(true);
|
||||
}
|
||||
// A fork before the first prompt: saved with no turn, and named at exit.
|
||||
const empty = observations.find((o) => o.op === "fork" && o.point === 1)!;
|
||||
expect(empty.disk[empty.id]).toBe(0);
|
||||
kept = observations;
|
||||
}, TIMEOUT);
|
||||
|
||||
const walks = Number(process.env.FX_TLA_WALKS ?? "2");
|
||||
const steps = Number(process.env.FX_TLA_STEPS ?? "16");
|
||||
const seed = Number(process.env.FX_TLA_SEED ?? "1");
|
||||
for (let n = 0; n < walks; n++) {
|
||||
test.skipIf(!tmuxAvailable())(`random walk ${n + 1} from seed ${seed} is a behavior of ShellSession`, async () => {
|
||||
const pick = prng(seed * 7919 + n);
|
||||
const { result } = await walk(`random-${seed}-${n + 1}`, async (w) => {
|
||||
for (let taken = 0; taken < steps; taken++) {
|
||||
const next = choices(w);
|
||||
await w.run(next[pick(next.length)]!);
|
||||
}
|
||||
});
|
||||
expect(result.verdict).toBe("ACCEPTED");
|
||||
}, TIMEOUT);
|
||||
}
|
||||
|
||||
/// The kept scripted run with one planted change. TLC must reject it, and
|
||||
/// the first step it cannot produce must be the one `plant` changed, whose
|
||||
/// index `plant` returns.
|
||||
function planted(name: string, plant: (observations: Observation[]) => number) {
|
||||
test.skipIf(!tmuxAvailable())(`planted: ${name} is rejected at the planted step`, async () => {
|
||||
if (!kept) throw new Error("the planted runs are made from the scripted walk's run; run this whole file");
|
||||
const copy: Observation[] = structuredClone(kept!);
|
||||
const at = plant(copy);
|
||||
const result = await checkTrace(copy, join(artifacts, "planted", name.replace(/[^A-Za-z0-9]+/g, "-")));
|
||||
expect(result.verdict).toBe("REJECTED");
|
||||
expect(result.firstMismatch?.step).toBe(at + 1);
|
||||
}, TIMEOUT);
|
||||
}
|
||||
|
||||
planted("/status calling a saved session unsaved", (o) => {
|
||||
const at = o.findIndex((step) => step.op === "status" && step.saved);
|
||||
o[at]!.saved = false;
|
||||
return at;
|
||||
});
|
||||
|
||||
planted("/status calling a session saved before its first turn", (o) => {
|
||||
const at = o.findIndex((step) => step.op === "status" && !step.saved && step.id !== "none");
|
||||
o[at]!.saved = true;
|
||||
return at;
|
||||
});
|
||||
|
||||
planted("/status naming the session open before /new", (o) => {
|
||||
const at = o.findIndex((step) => step.op === "new");
|
||||
expect([o[at - 1]!.op, o[at + 1]!.op]).toEqual(["status", "status"]);
|
||||
o[at + 1]!.id = o[at - 1]!.id;
|
||||
return at + 1;
|
||||
});
|
||||
|
||||
planted("the exit line before D90, silent after a fork before the first prompt", (o) => {
|
||||
const fork = o.findIndex((step) => step.op === "fork" && step.point === 1);
|
||||
const at = o.findIndex((step, index) => index > fork && step.op === "quit");
|
||||
expect(o[at]!.handoff).toBe(o[fork]!.id);
|
||||
o[at]!.handoff = "none";
|
||||
return at;
|
||||
});
|
||||
|
||||
planted("a session never prompted left on disk", (o) => {
|
||||
// The scripted walk runs /clear in a session /new opened and never prompted.
|
||||
const at = o.findIndex((step) => step.op === "clear");
|
||||
const left = o[at - 1]!;
|
||||
expect(left.op === "status" && !left.saved).toBe(true);
|
||||
for (const step of o.slice(at)) step.disk[left.id] = 0;
|
||||
return at;
|
||||
});
|
||||
|
||||
planted("a fork holding the turn it was made before", (o) => {
|
||||
const at = o.findIndex((step) => step.op === "fork" && step.point > 1);
|
||||
for (const step of o.slice(at)) if (o[at]!.id in step.disk) step.disk[o[at]!.id]! += 1;
|
||||
return at;
|
||||
});
|
||||
|
||||
test("an empty run is no evidence", async () => {
|
||||
expect((await checkTrace([], join(artifacts, "planted", "empty"))).verdict).toBe("TOOL_ERROR");
|
||||
});
|
||||
@@ -0,0 +1,335 @@
|
||||
---------------------------- MODULE ShellSession ----------------------------
|
||||
(***************************************************************************)
|
||||
(* The session the interactive app has open, and what fx tells the user *)
|
||||
(* about it (D90): `/status` shows its id and whether it is saved, and *)
|
||||
(* quitting prints `fx --resume <id>` for a saved session. Saved means its *)
|
||||
(* log is on disk, so a resume opens it. *)
|
||||
(* *)
|
||||
(* One profile outlives many processes. A launch opens a new session, *)
|
||||
(* resumes a saved one, or finds no usable store and opens none. In a *)
|
||||
(* process, a prompt's turn begins and ends, `/new` and `/clear` open a *)
|
||||
(* new session, `/resume` opens a saved one, and `/fork` opens a new *)
|
||||
(* session holding the turns before a chosen prompt. A process ends when *)
|
||||
(* the user quits or it crashes. *)
|
||||
(* *)
|
||||
(* `last` is what the latest action showed the user. ShellSessionTrace *)
|
||||
(* matches it, the saved ids, and their turns against what the real binary *)
|
||||
(* did. Report and Handoff are what fx shows; the properties say what is *)
|
||||
(* true. Mutant plants one bug so that each property is shown to catch it. *)
|
||||
(***************************************************************************)
|
||||
EXTENDS Naturals, FiniteSets, TLC
|
||||
|
||||
CONSTANTS
|
||||
Ids, \* the session ids a run may open
|
||||
MaxTurns, \* the most turns one session may hold
|
||||
Mutant \* "none", or the planted bug in Mutants
|
||||
|
||||
None == "none"
|
||||
|
||||
Mutants == {
|
||||
"none",
|
||||
"saved_by_turns", \* /status counts turns, as `saved` did before D90
|
||||
"saved_on_open", \* /status calls every open session saved
|
||||
"exit_by_turns", \* the exit line counts turns, as it did before D90
|
||||
"status_launch_id", \* /status keeps showing the id the process launched with
|
||||
"keep_pristine", \* leaving a session never prompted saves it
|
||||
"reuse_id", \* a new session may take an id already used
|
||||
"fork_held", \* a fork before the first prompt waits for its first turn
|
||||
"drop_on_switch" \* leaving a saved session deletes it
|
||||
}
|
||||
|
||||
ASSUME None \notin Ids
|
||||
ASSUME MaxTurns \in Nat \ {0}
|
||||
ASSUME Mutant \in Mutants
|
||||
|
||||
VARIABLES
|
||||
running, \* a process is running
|
||||
durable, \* this process can create sessions
|
||||
cur, \* the open session's id, or None
|
||||
turnOpen, \* a turn is running
|
||||
onDisk, \* the ids whose log is on disk
|
||||
turns, \* the turns each session holds
|
||||
used, \* every id opened so far
|
||||
discarded, \* the ids left before their log reached the disk
|
||||
first, \* the id this process opened when it started
|
||||
ended, \* how the last process ended: "none", "quit" or "crash"
|
||||
last \* what the latest action showed the user
|
||||
|
||||
vars == <<running, durable, cur, turnOpen, onDisk, turns, used, discarded, first, ended, last>>
|
||||
|
||||
Ops == {"start", "launch", "launch_resume", "launch_no_store", "begin", "end",
|
||||
"status", "new", "clear", "resume", "fork", "quit", "crash"}
|
||||
|
||||
Obs(op, id, point, saved, handoff) ==
|
||||
[op |-> op, id |-> id, point |-> point, saved |-> saved, handoff |-> handoff]
|
||||
|
||||
Quiet(op) == Obs(op, None, 0, FALSE, None)
|
||||
|
||||
Saved(i) == i # None /\ i \in onDisk
|
||||
|
||||
(* ------------------------- what fx shows -------------------------------- *)
|
||||
|
||||
(* The `/status` panel's Session line: the id, and whether it says saved. *)
|
||||
Report ==
|
||||
[ id |-> IF Mutant = "status_launch_id" THEN first ELSE cur,
|
||||
saved |-> CASE Mutant = "saved_by_turns" -> cur # None /\ turns[cur] > 0
|
||||
[] Mutant = "saved_on_open" -> cur # None
|
||||
[] OTHER -> Saved(cur) ]
|
||||
|
||||
(* The id the exit line names, or None when it prints nothing. *)
|
||||
Handoff ==
|
||||
IF IF Mutant = "exit_by_turns" THEN cur # None /\ turns[cur] > 0 ELSE Saved(cur)
|
||||
THEN cur
|
||||
ELSE None
|
||||
|
||||
(* An id for a session that starts now. *)
|
||||
FreshIds == IF Mutant = "reuse_id" THEN Ids \ {cur} ELSE Ids \ used
|
||||
|
||||
(* Leaving the open session by a switch or a quit: a saved one stays, one *)
|
||||
(* never prompted is gone for good (D2, D30). A crash writes nothing. *)
|
||||
OnDiskAfterLeave ==
|
||||
CASE cur # None /\ Mutant = "keep_pristine" -> onDisk \cup {cur}
|
||||
[] cur # None /\ Mutant = "drop_on_switch" -> onDisk \ {cur}
|
||||
[] OTHER -> onDisk
|
||||
|
||||
DiscardedAfterLeave ==
|
||||
IF cur # None /\ cur \notin onDisk THEN discarded \cup {cur} ELSE discarded
|
||||
|
||||
(* -------------------------------- Init ---------------------------------- *)
|
||||
|
||||
Init ==
|
||||
/\ running = FALSE
|
||||
/\ durable = TRUE
|
||||
/\ cur = None
|
||||
/\ turnOpen = FALSE
|
||||
/\ onDisk = {}
|
||||
/\ turns = [i \in Ids |-> 0]
|
||||
/\ used = {}
|
||||
/\ discarded = {}
|
||||
/\ first = None
|
||||
/\ ended = "none"
|
||||
/\ last = Quiet("start")
|
||||
|
||||
(* ------------------------------ launches -------------------------------- *)
|
||||
|
||||
(* `fx`: a new session, held in memory until its first turn (D2). *)
|
||||
Launch(i) ==
|
||||
/\ ~running
|
||||
/\ i \in FreshIds
|
||||
/\ running' = TRUE
|
||||
/\ durable' = TRUE
|
||||
/\ cur' = i
|
||||
/\ used' = used \cup {i}
|
||||
/\ first' = i
|
||||
/\ last' = Obs("launch", i, 0, FALSE, None)
|
||||
/\ UNCHANGED <<turnOpen, onDisk, turns, discarded, ended>>
|
||||
|
||||
(* `fx --resume <id>`, as the exit line or `/status` gives it. *)
|
||||
LaunchResume(i) ==
|
||||
/\ ~running
|
||||
/\ i \in onDisk
|
||||
/\ running' = TRUE
|
||||
/\ durable' = TRUE
|
||||
/\ cur' = i
|
||||
/\ first' = i
|
||||
/\ last' = Obs("launch_resume", i, 0, FALSE, None)
|
||||
/\ UNCHANGED <<turnOpen, onDisk, turns, used, discarded, ended>>
|
||||
|
||||
(* `fx` where no session can be created: it runs, and saves nothing. *)
|
||||
LaunchNoStore ==
|
||||
/\ ~running
|
||||
/\ running' = TRUE
|
||||
/\ durable' = FALSE
|
||||
/\ cur' = None
|
||||
/\ first' = None
|
||||
/\ last' = Quiet("launch_no_store")
|
||||
/\ UNCHANGED <<turnOpen, onDisk, turns, used, discarded, ended>>
|
||||
|
||||
(* ------------------------------- turns ---------------------------------- *)
|
||||
|
||||
(* A prompt starts a turn. The first turn publishes the session (D2). *)
|
||||
Begin ==
|
||||
/\ running
|
||||
/\ ~turnOpen
|
||||
/\ cur # None => turns[cur] < MaxTurns
|
||||
/\ turnOpen' = TRUE
|
||||
/\ IF cur = None
|
||||
THEN UNCHANGED <<onDisk, turns>>
|
||||
ELSE /\ onDisk' = onDisk \cup {cur}
|
||||
/\ turns' = [turns EXCEPT ![cur] = @ + 1]
|
||||
/\ last' = Quiet("begin")
|
||||
/\ UNCHANGED <<running, durable, cur, used, discarded, first, ended>>
|
||||
|
||||
End ==
|
||||
/\ running
|
||||
/\ turnOpen
|
||||
/\ turnOpen' = FALSE
|
||||
/\ last' = Quiet("end")
|
||||
/\ UNCHANGED <<running, durable, cur, onDisk, turns, used, discarded, first, ended>>
|
||||
|
||||
(* `/status`, between turns or while one runs. *)
|
||||
Status ==
|
||||
/\ running
|
||||
/\ last' = Obs("status", Report.id, 0, Report.saved, None)
|
||||
/\ UNCHANGED <<running, durable, cur, turnOpen, onDisk, turns, used, discarded, first, ended>>
|
||||
|
||||
(* ----------------------------- switching -------------------------------- *)
|
||||
|
||||
(* `/new` or `/clear`. Without a store the shell stays without a session. *)
|
||||
NewSession(op, i) ==
|
||||
/\ running
|
||||
/\ ~turnOpen
|
||||
/\ IF durable
|
||||
THEN /\ i \in FreshIds
|
||||
/\ cur' = i
|
||||
/\ used' = used \cup {i}
|
||||
/\ onDisk' = OnDiskAfterLeave
|
||||
/\ discarded' = DiscardedAfterLeave
|
||||
ELSE /\ i = None
|
||||
/\ UNCHANGED <<cur, used, onDisk, discarded>>
|
||||
/\ last' = Obs(op, i, 0, FALSE, None)
|
||||
/\ UNCHANGED <<running, durable, turnOpen, turns, first, ended>>
|
||||
|
||||
(* `/resume`: the picker opens another saved session. *)
|
||||
Resume(i) ==
|
||||
/\ running
|
||||
/\ ~turnOpen
|
||||
/\ durable
|
||||
/\ cur # None
|
||||
/\ i \in onDisk
|
||||
/\ i # cur
|
||||
/\ cur' = i
|
||||
/\ onDisk' = OnDiskAfterLeave
|
||||
/\ discarded' = DiscardedAfterLeave
|
||||
/\ last' = Obs("resume", i, 0, FALSE, None)
|
||||
/\ UNCHANGED <<running, durable, turnOpen, turns, used, first, ended>>
|
||||
|
||||
(* `/fork` before prompt p: a new session holding turns 1..p-1, on disk *)
|
||||
(* from its open, which the app switches to (D58). *)
|
||||
Fork(p, i) ==
|
||||
/\ running
|
||||
/\ ~turnOpen
|
||||
/\ durable
|
||||
/\ cur \in onDisk
|
||||
/\ p \in 1..turns[cur]
|
||||
/\ i \in FreshIds
|
||||
/\ cur' = i
|
||||
/\ used' = used \cup {i}
|
||||
/\ turns' = [turns EXCEPT ![i] = p - 1]
|
||||
/\ onDisk' = IF Mutant = "fork_held" /\ p = 1 THEN OnDiskAfterLeave ELSE OnDiskAfterLeave \cup {i}
|
||||
/\ discarded' = DiscardedAfterLeave
|
||||
/\ last' = Obs("fork", i, p, FALSE, None)
|
||||
/\ UNCHANGED <<running, durable, turnOpen, first, ended>>
|
||||
|
||||
(* ------------------------------- ending --------------------------------- *)
|
||||
|
||||
(* `/quit`, between turns or while one runs, which ends it, and the exit *)
|
||||
(* line. *)
|
||||
Quit ==
|
||||
/\ running
|
||||
/\ running' = FALSE
|
||||
/\ turnOpen' = FALSE
|
||||
/\ onDisk' = OnDiskAfterLeave
|
||||
/\ discarded' = DiscardedAfterLeave
|
||||
/\ ended' = "quit"
|
||||
/\ last' = Obs("quit", None, 0, FALSE, Handoff)
|
||||
/\ UNCHANGED <<durable, cur, turns, used, first>>
|
||||
|
||||
(* The process dies: what was published stays (D2 syncs the publish), a *)
|
||||
(* session still in memory is gone, and nothing is printed. *)
|
||||
Crash ==
|
||||
/\ running
|
||||
/\ running' = FALSE
|
||||
/\ turnOpen' = FALSE
|
||||
/\ discarded' = DiscardedAfterLeave
|
||||
/\ ended' = "crash"
|
||||
/\ last' = Quiet("crash")
|
||||
/\ UNCHANGED <<durable, cur, onDisk, turns, used, first>>
|
||||
|
||||
Next ==
|
||||
\/ \E i \in Ids : Launch(i) \/ LaunchResume(i)
|
||||
\/ LaunchNoStore
|
||||
\/ Begin
|
||||
\/ End
|
||||
\/ Status
|
||||
\/ \E i \in Ids \cup {None} : NewSession("new", i) \/ NewSession("clear", i)
|
||||
\/ \E i \in Ids : Resume(i)
|
||||
\/ \E p \in 1..MaxTurns, i \in Ids : Fork(p, i)
|
||||
\/ Quit
|
||||
\/ Crash
|
||||
|
||||
Spec == Init /\ [][Next]_vars
|
||||
|
||||
(* ----------------------------- properties ------------------------------- *)
|
||||
|
||||
TypeOK ==
|
||||
/\ running \in BOOLEAN
|
||||
/\ durable \in BOOLEAN
|
||||
/\ cur \in Ids \cup {None}
|
||||
/\ turnOpen \in BOOLEAN
|
||||
/\ onDisk \subseteq Ids
|
||||
/\ turns \in [Ids -> 0..MaxTurns]
|
||||
/\ used \subseteq Ids
|
||||
/\ discarded \subseteq Ids
|
||||
/\ first \in Ids \cup {None}
|
||||
/\ ended \in {"none", "quit", "crash"}
|
||||
/\ last \in [op: Ops, id: Ids \cup {None}, point: 0..MaxTurns, saved: BOOLEAN, handoff: Ids \cup {None}]
|
||||
|
||||
(* `/status` names the session that is open, or none. *)
|
||||
StatusNamesOpenSession == last.op = "status" => last.id = cur
|
||||
|
||||
(* A session `/status` calls saved is on disk, so `fx --resume` opens it. *)
|
||||
StatusSavedIsOnDisk == last.op = "status" /\ last.saved => last.id \in onDisk
|
||||
|
||||
(* A session `/status` calls unsaved is not on disk, so it is not called *)
|
||||
(* unsaved while a resume would open it. *)
|
||||
StatusUnsavedIsAbsent == last.op = "status" /\ ~last.saved => last.id \notin onDisk
|
||||
|
||||
(* A session left before it was saved never reaches the disk (D2, D30). *)
|
||||
DiscardedNeverSaved == discarded \cap onDisk = {}
|
||||
|
||||
(* A launch, `/new` or `/clear` opens a session that is not saved yet. *)
|
||||
NewSessionUnsaved ==
|
||||
last.op \in {"launch", "new", "clear"} /\ last.id # None => last.id \notin onDisk
|
||||
|
||||
(* A resume opens a saved session. *)
|
||||
ResumeOpensSaved ==
|
||||
last.op \in {"launch_resume", "resume"} => last.id = cur /\ cur \in onDisk
|
||||
|
||||
(* A fork is on disk from its open and holds the turns before its prompt. *)
|
||||
ForkOnDisk == last.op = "fork" => last.id = cur /\ cur \in onDisk /\ turns[cur] = last.point - 1
|
||||
|
||||
(* The exit line names only a saved session. *)
|
||||
ExitNamesSaved == last.op = "quit" /\ last.handoff # None => last.handoff \in onDisk
|
||||
|
||||
(* The exit line names the session exactly when `/status` calls it saved. *)
|
||||
ExitAgreesWithStatus ==
|
||||
[][last'.op = "quit" => last'.handoff = IF Saved(cur) THEN cur ELSE None]_vars
|
||||
|
||||
(* Every session a launch, `/new`, `/clear` or `/fork` opens is new. *)
|
||||
NewIdsAreFresh ==
|
||||
[][last'.op \in {"launch", "new", "clear", "fork"} /\ last'.id # None => last'.id \notin used]_vars
|
||||
|
||||
(* Nothing in these flows removes a saved session. *)
|
||||
SavedStaysSaved == [][onDisk \subseteq onDisk']_vars
|
||||
|
||||
(* ------------------------------ witnesses ------------------------------- *)
|
||||
(* Each must be violated: the state it rules out is reachable, so the *)
|
||||
(* properties above are not vacuous there. *)
|
||||
|
||||
(* `/status` on a saved session that holds no turn: a fork before the *)
|
||||
(* first prompt. *)
|
||||
NoSavedEmptyStatus == ~(last.op = "status" /\ last.saved /\ turns[cur] = 0)
|
||||
|
||||
(* `/status` on a session not saved yet. *)
|
||||
NoUnsavedStatus == ~(last.op = "status" /\ ~last.saved /\ last.id # None)
|
||||
|
||||
(* `/status` in a shell without a session. *)
|
||||
NoStorelessStatus == ~(last.op = "status" /\ last.id = None)
|
||||
|
||||
(* An exit line for a session that holds no turn. *)
|
||||
NoEmptyExitLine == ~(last.op = "quit" /\ last.handoff # None /\ turns[last.handoff] = 0)
|
||||
|
||||
(* A resume after the process before it crashed. *)
|
||||
NoResumeAfterCrash == ~(last.op = "launch_resume" /\ ended = "crash")
|
||||
=============================================================================
|
||||
@@ -0,0 +1,50 @@
|
||||
---------------------------- MODULE ShellSessionTrace ----------------------------
|
||||
(***************************************************************************)
|
||||
(* Trace validation: replays one profile's run of the real fx binary *)
|
||||
(* through ShellSession's own actions. *)
|
||||
(* *)
|
||||
(* ShellSessionObserved is generated by `tests/tla/drivers/session/ *)
|
||||
(* bridge.ts`. It defines TIds and TMaxTurns, and Observed, a sequence of *)
|
||||
(* records *)
|
||||
(* [op, id, point, saved, handoff, disk, turns] *)
|
||||
(* where op names the action the driver took, id the session it opened or *)
|
||||
(* `/status` named, point a fork's prompt, saved what `/status` said, *)
|
||||
(* handoff the id the exit line named, disk the ids whose log was on disk *)
|
||||
(* after the step, and turns the turns each of those logs held. The driver *)
|
||||
(* reads every field from fx's own output and files: the wiring trace, the *)
|
||||
(* terminal replayed through fx's virtual terminal, the exit line, and the *)
|
||||
(* session folders. Fields an action does not show are "none", 0 or FALSE. *)
|
||||
(* *)
|
||||
(* NotAllMatched is a sentinel: TLC reports it violated (exit 12) only *)
|
||||
(* after every observation matched a step of the model. A search that ends *)
|
||||
(* without that violation means the run is not a behavior of the model. *)
|
||||
(***************************************************************************)
|
||||
EXTENDS ShellSession, ShellSessionObserved, Sequences
|
||||
|
||||
VARIABLE k
|
||||
|
||||
traceVars == <<vars, k>>
|
||||
|
||||
DiskTurns == [i \in onDisk |-> turns[i]]
|
||||
|
||||
Match(o) ==
|
||||
/\ last = Obs(o.op, o.id, o.point, o.saved, o.handoff)
|
||||
/\ onDisk = o.disk
|
||||
/\ DiskTurns = o.turns
|
||||
|
||||
ASSUME Len(Observed) >= 1
|
||||
|
||||
TraceInit == Init /\ k = 0
|
||||
|
||||
(* The observation is bound before the step, so only the state is primed. *)
|
||||
TraceNext ==
|
||||
/\ k < Len(Observed)
|
||||
/\ \E o \in {Observed[k + 1]} :
|
||||
/\ Next
|
||||
/\ Match(o)'
|
||||
/\ k' = k + 1
|
||||
|
||||
TraceSpec == TraceInit /\ [][TraceNext]_traceVars
|
||||
|
||||
NotAllMatched == k < Len(Observed)
|
||||
=============================================================================
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/sh
|
||||
# Model-check ShellSession, the session the interactive app has open and what
|
||||
# `/status` and the exit line say about it (D90), and require each config's
|
||||
# expected verdict.
|
||||
#
|
||||
# holds.cfg must pass. Each witness-*.cfg must be violated, which shows the
|
||||
# state it names is reachable, so the properties are not vacuous there. Each
|
||||
# mutant-*.cfg plants one bug that its property must catch. simulate.cfg runs
|
||||
# random behaviors with more ids and turns; SIM_RUNS sets how many (default
|
||||
# 20000).
|
||||
#
|
||||
# Run the real binary along the model and have TLC check every run:
|
||||
# bun test ../drivers/session/
|
||||
#
|
||||
# Needs Java 11+ and tla2tools.jar, as ../README.md describes.
|
||||
set -eu
|
||||
|
||||
cd "$(dirname "$0")"
|
||||
# shellcheck source=../lib/tlc.sh
|
||||
. ../lib/tlc.sh
|
||||
|
||||
sim="-simulate num=${SIM_RUNS:-20000} -depth 40 -seed 1"
|
||||
tlc_verdicts "$@" <<EOF
|
||||
ShellSession holds -
|
||||
ShellSession witness-saved-empty-status NoSavedEmptyStatus
|
||||
ShellSession witness-unsaved-status NoUnsavedStatus
|
||||
ShellSession witness-storeless-status NoStorelessStatus
|
||||
ShellSession witness-empty-exit-line NoEmptyExitLine
|
||||
ShellSession witness-resume-after-crash NoResumeAfterCrash
|
||||
ShellSession mutant-saved-by-turns StatusUnsavedIsAbsent
|
||||
ShellSession mutant-saved-on-open StatusSavedIsOnDisk
|
||||
ShellSession mutant-exit-by-turns ExitAgreesWithStatus
|
||||
ShellSession mutant-status-launch-id StatusNamesOpenSession
|
||||
ShellSession mutant-keep-pristine DiscardedNeverSaved
|
||||
ShellSession mutant-reuse-id NewIdsAreFresh
|
||||
ShellSession mutant-fork-held ForkOnDisk
|
||||
ShellSession mutant-drop-on-switch SavedStaysSaved
|
||||
ShellSession simulate simulate $sim
|
||||
EOF
|
||||
@@ -0,0 +1,19 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "none"
|
||||
INVARIANTS
|
||||
TypeOK
|
||||
StatusNamesOpenSession
|
||||
StatusSavedIsOnDisk
|
||||
StatusUnsavedIsAbsent
|
||||
DiscardedNeverSaved
|
||||
NewSessionUnsaved
|
||||
ResumeOpensSaved
|
||||
ForkOnDisk
|
||||
ExitNamesSaved
|
||||
PROPERTIES
|
||||
ExitAgreesWithStatus
|
||||
NewIdsAreFresh
|
||||
SavedStaysSaved
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "drop_on_switch"
|
||||
PROPERTY
|
||||
SavedStaysSaved
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "exit_by_turns"
|
||||
PROPERTY
|
||||
ExitAgreesWithStatus
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "fork_held"
|
||||
INVARIANT
|
||||
ForkOnDisk
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "keep_pristine"
|
||||
INVARIANT
|
||||
DiscardedNeverSaved
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "reuse_id"
|
||||
PROPERTY
|
||||
NewIdsAreFresh
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "saved_by_turns"
|
||||
INVARIANT
|
||||
StatusUnsavedIsAbsent
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "saved_on_open"
|
||||
INVARIANT
|
||||
StatusSavedIsOnDisk
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "status_launch_id"
|
||||
INVARIANT
|
||||
StatusNamesOpenSession
|
||||
@@ -0,0 +1,19 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d", "e", "f"}
|
||||
MaxTurns = 3
|
||||
Mutant = "none"
|
||||
INVARIANTS
|
||||
TypeOK
|
||||
StatusNamesOpenSession
|
||||
StatusSavedIsOnDisk
|
||||
StatusUnsavedIsAbsent
|
||||
DiscardedNeverSaved
|
||||
NewSessionUnsaved
|
||||
ResumeOpensSaved
|
||||
ForkOnDisk
|
||||
ExitNamesSaved
|
||||
PROPERTIES
|
||||
ExitAgreesWithStatus
|
||||
NewIdsAreFresh
|
||||
SavedStaysSaved
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "none"
|
||||
INVARIANT
|
||||
NoEmptyExitLine
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "none"
|
||||
INVARIANT
|
||||
NoResumeAfterCrash
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "none"
|
||||
INVARIANT
|
||||
NoSavedEmptyStatus
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "none"
|
||||
INVARIANT
|
||||
NoStorelessStatus
|
||||
@@ -0,0 +1,7 @@
|
||||
SPECIFICATION Spec
|
||||
CONSTANTS
|
||||
Ids = {"a", "b", "c", "d"}
|
||||
MaxTurns = 2
|
||||
Mutant = "none"
|
||||
INVARIANT
|
||||
NoUnsavedStatus
|
||||
@@ -1529,6 +1529,39 @@ for an image pasted into its prompt):
|
||||
keep D79's two copies; load every blob when the session opens; or name
|
||||
the temporary snapshot, which is gone when its process exits.
|
||||
|
||||
Settled for `/status` (2026-10-23, after a user could find a session's id
|
||||
only by quitting):
|
||||
- **D90 Decision (amends D2's exit line and D58):** `/status` in the
|
||||
interactive app opens a panel in the footer, and writes nothing to the
|
||||
transcript. Its first line names the open session as `<id> · saved`
|
||||
exactly when its log is on disk, so `fx --resume <id>` opens it, and as
|
||||
`<id> · not saved yet` before then. A shell with no session, because none
|
||||
could be created, shows `none · this shell can't save sessions`. While
|
||||
the panel is open, it follows a turn that saves the session or ends. The
|
||||
manager answers whether the log is on disk
|
||||
(`Session.onDisk`): from the publish of the first turn, or from the open
|
||||
of a resumed or forked session. A failed session keeps the log it had.
|
||||
One whose first publish failed counts as not on disk, though the publish
|
||||
may have landed before the failure, so `/status` errs toward unsaved.
|
||||
- The adapter's `saved` asks the manager instead of counting turns. A
|
||||
fork before the first prompt holds no turn, but its log is on disk
|
||||
from the fork, and resume opens it. Counting turns hid it: the exit
|
||||
line left it out, while `/status` would have to show it as unsaved.
|
||||
Now the exit line and `/status` give the same answer for every
|
||||
session.
|
||||
- `fx status` runs outside any session and shows none.
|
||||
- `tests/tla/session/ShellSession.tla` models the open session across
|
||||
launches, prompts, `/new`, `/clear`, `/resume`, `/fork`, quitting and
|
||||
crashes, with what `/status` and the exit line say. Its planted bugs
|
||||
include counting turns. `tests/tla/drivers/session/` runs the real
|
||||
binary along it and has TLC check every run.
|
||||
|
||||
**Why:** a user who wants to come back to a session needs its id while
|
||||
it is open, and an id is useful only when fx says truthfully whether it
|
||||
can be resumed yet. **Alternative:** show the id only once saved; print
|
||||
the full `fx --resume` command; or keep counting turns and show a fork
|
||||
before its first prompt as unsaved, though resume opens it.
|
||||
|
||||
## Design Risks And Failure Behavior
|
||||
|
||||
- **Stale index.** A crash between publish and the index append hides a
|
||||
|
||||
Reference in new issue
Block a user