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:
Pranit committed 2026-10-23 15:59:13 -04:00
1 parent 314c01479c
commit 8a964aca1d
59 files changed
+2811 -248

No files matched your search

+1
View File
@@ -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},
+1 -1
View File
@@ -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 },
+22 -1
View File
@@ -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();
}
+28 -2
View File
@@ -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()) {
+5
View File
@@ -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,
),
+1 -1
View File
@@ -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, "");
+3
View File
@@ -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);
+88 -35
View File
@@ -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" {
+7 -3
View File
@@ -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();
}
+83 -25
View File
@@ -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" {
+76
View File
@@ -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;
}
};
+5
View File
@@ -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);
+16
View File
@@ -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.
+3
View File
@@ -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,
};
}
+9
View File
@@ -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",
+20
View File
@@ -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;
+184
View File
@@ -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 };
}
+1
View File
@@ -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 },
+4 -2
View File
@@ -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;
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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(
+4 -4
View File
@@ -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");
+12 -3
View File
@@ -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;
}
+369
View File
@@ -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("'", "'\\''")}'`;
}
+339
View File
@@ -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`);
});
}
+62 -101
View File
@@ -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");
+4 -4
View File
@@ -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())
+4 -4
View File
@@ -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) =>
+2 -2
View File
@@ -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(
+6 -5
View File
@@ -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)/);
});
+2 -3
View File
@@ -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);
+15 -14
View File
@@ -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,
);
+33 -27
View File
@@ -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"]);
+283
View File
@@ -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);
}
});
+4 -4
View File
@@ -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
View File
@@ -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.
+6 -1
View File
@@ -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);
}
+143
View File
@@ -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;
}
+369
View File
@@ -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");
});
+335
View File
@@ -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")
=============================================================================
+50
View File
@@ -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)
=============================================================================
+39
View File
@@ -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
+19
View File
@@ -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
+7
View File
@@ -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
+7
View File
@@ -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
+19
View File
@@ -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