diff --git a/scripts/pgso/corpus.json b/scripts/pgso/corpus.json index 5ff84b456..a906b37d9 100644 --- a/scripts/pgso/corpus.json +++ b/scripts/pgso/corpus.json @@ -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}, diff --git a/src/builtins/commands.zig b/src/builtins/commands.zig index d62437229..71e98dff6 100644 --- a/src/builtins/commands.zig +++ b/src/builtins/commands.zig @@ -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 (/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 ", .completion_description = "choose what model and reasoning effort to use", .presentation_category = .model, .has_args = true, .accepts_payload = true }, diff --git a/src/core/app/app_commands.zig b/src/core/app/app_commands.zig index c59464f2d..b4b32d6fc 100644 --- a/src/core/app/app_commands.zig +++ b/src/core/app/app_commands.zig @@ -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(); } diff --git a/src/core/app/app_input_runtime.zig b/src/core/app/app_input_runtime.zig index 784e85773..4b3141750 100644 --- a/src/core/app/app_input_runtime.zig +++ b/src/core/app/app_input_runtime.zig @@ -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()) { diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 683b39609..7c917737e 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -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, ), diff --git a/src/core/cli/cli_ask.zig b/src/core/cli/cli_ask.zig index a8bab5cd9..78e530a84 100644 --- a/src/core/cli/cli_ask.zig +++ b/src/core/cli/cli_ask.zig @@ -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, ""); diff --git a/src/core/input/runtime.zig b/src/core/input/runtime.zig index 464d32f54..20abe3b72 100644 --- a/src/core/input/runtime.zig +++ b/src/core/input/runtime.zig @@ -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); diff --git a/src/core/output/output_contracts.zig b/src/core/output/output_contracts.zig index 192c63bc6..81fab7380 100644 --- a/src/core/output/output_contracts.zig +++ b/src/core/output/output_contracts.zig @@ -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 ` 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" { diff --git a/src/core/session/session_adapter.zig b/src/core/session/session_adapter.zig index 8d3d58252..3d2df7889 100644 --- a/src/core/session/session_adapter.zig +++ b/src/core/session/session_adapter.zig @@ -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(); } diff --git a/src/core/session/session_commands.zig b/src/core/session/session_commands.zig index 723bed98a..a55534924 100644 --- a/src/core/session/session_commands.zig +++ b/src/core/session/session_commands.zig @@ -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" { diff --git a/src/core/session/status_menu.zig b/src/core/session/status_menu.zig new file mode 100644 index 000000000..4cfa33930 --- /dev/null +++ b/src/core/session/status_menu.zig @@ -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; + } +}; diff --git a/src/core/session_manager/api.zig b/src/core/session_manager/api.zig index 41162fc46..f93844f32 100644 --- a/src/core/session_manager/api.zig +++ b/src/core/session_manager/api.zig @@ -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); diff --git a/src/core/session_manager/session.zig b/src/core/session_manager/session.zig index 277f2e480..7836b8adc 100644 --- a/src/core/session_manager/session.zig +++ b/src/core/session_manager/session.zig @@ -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. diff --git a/src/main.zig b/src/main.zig index f49c09ba4..452c8504a 100644 --- a/src/main.zig +++ b/src/main.zig @@ -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) { diff --git a/src/ui/footer/compact_command_menu_presentation.zig b/src/ui/footer/compact_command_menu_presentation.zig index 8badca180..02762769c 100644 --- a/src/ui/footer/compact_command_menu_presentation.zig +++ b/src/ui/footer/compact_command_menu_presentation.zig @@ -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, }; } diff --git a/src/ui/footer/input_presentation.zig b/src/ui/footer/input_presentation.zig index 0347e6459..eada513ed 100644 --- a/src/ui/footer/input_presentation.zig +++ b/src/ui/footer/input_presentation.zig @@ -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", diff --git a/src/ui/footer/render_input.zig b/src/ui/footer/render_input.zig index 3d0d76b25..4ffee6c01 100644 --- a/src/ui/footer/render_input.zig +++ b/src/ui/footer/render_input.zig @@ -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; diff --git a/src/ui/footer/status_menu_presentation.zig b/src/ui/footer/status_menu_presentation.zig new file mode 100644 index 000000000..94af0de82 --- /dev/null +++ b/src/ui/footer/status_menu_presentation.zig @@ -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 }; +} diff --git a/tests/e2e/ci-shard-weights.json b/tests/e2e/ci-shard-weights.json index d1ac4c6ff..b2bcc17dc 100644 --- a/tests/e2e/ci-shard-weights.json +++ b/tests/e2e/ci-shard-weights.json @@ -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 }, diff --git a/tests/e2e/config-persistence.test.ts b/tests/e2e/config-persistence.test.ts index 3e0512884..8eaea85d5 100644 --- a/tests/e2e/config-persistence.test.ts +++ b/tests/e2e/config-persistence.test.ts @@ -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; diff --git a/tests/e2e/render-lab/README.md b/tests/e2e/render-lab/README.md index 01cba96e6..c17fe2577 100644 --- a/tests/e2e/render-lab/README.md +++ b/tests/e2e/render-lab/README.md @@ -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. diff --git a/tests/e2e/render-lab/index.ts b/tests/e2e/render-lab/index.ts index b6a5d25a8..363234e1f 100644 --- a/tests/e2e/render-lab/index.ts +++ b/tests/e2e/render-lab/index.ts @@ -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( diff --git a/tests/e2e/render-lab/native.ts b/tests/e2e/render-lab/native.ts index 0f962eaa0..8df4d35f8 100644 --- a/tests/e2e/render-lab/native.ts +++ b/tests/e2e/render-lab/native.ts @@ -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"); diff --git a/tests/e2e/render-lab/tape.ts b/tests/e2e/render-lab/tape.ts index 2668e64b9..6c030c16c 100644 --- a/tests/e2e/render-lab/tape.ts +++ b/tests/e2e/render-lab/tape.ts @@ -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; } diff --git a/tests/e2e/shell-sessions.ts b/tests/e2e/shell-sessions.ts new file mode 100644 index 000000000..d45ceae71 --- /dev/null +++ b/tests/e2e/shell-sessions.ts @@ -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; + +/// 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; + private readonly answers = new Map(); + private readonly held = new Map(); + 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 { + 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 { + 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 { + const store = join(this.blocked ? this.aside : this.sessions, "v2"); + if (!existsSync(store) || !statSync(store).isDirectory()) return {}; + const saved: Record = {}; + 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 ` would. + async resumable(id: string): Promise { + 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 }> { + 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 { + 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 { + 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 { + 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 { + 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, action: "Open" | "Fork"): Promise { + 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, 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 { + 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("'", "'\\''")}'`; +} diff --git a/tests/e2e/status-panel.ts b/tests/e2e/status-panel.ts new file mode 100644 index 000000000..2781d2da9 --- /dev/null +++ b/tests/e2e/status-panel.ts @@ -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>; +}; + +/// 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 { + execFileSync(fxBin, ["replay", tapePath, "--frames-dir", framesRoot], { + stdio: "pipe", + env: { ...process.env, FX_SOUND: "0" }, + }); + const grids = new Map(); + 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> = {}; + 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; + sendKeys(keys: string): Promise; + capturePane(): Promise; + 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 { + 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 { + 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 { + 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) ? "" : 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`); + }); +} diff --git a/tests/e2e/tui-auth-source-selection.test.ts b/tests/e2e/tui-auth-source-selection.test.ts index 84373cc68..cb0d2b973 100644 --- a/tests/e2e/tui-auth-source-selection.test.ts +++ b/tests/e2e/tui-auth-source-selection.test.ts @@ -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"); diff --git a/tests/e2e/tui-input-navigation.test.ts b/tests/e2e/tui-input-navigation.test.ts index 12d620913..06fe9787d 100644 --- a/tests/e2e/tui-input-navigation.test.ts +++ b/tests/e2e/tui-input-navigation.test.ts @@ -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()) diff --git a/tests/e2e/tui-render-replay.test.ts b/tests/e2e/tui-render-replay.test.ts index 1382329d9..327e3ee28 100644 --- a/tests/e2e/tui-render-replay.test.ts +++ b/tests/e2e/tui-render-replay.test.ts @@ -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) => diff --git a/tests/e2e/tui-render-stress.test.ts b/tests/e2e/tui-render-stress.test.ts index 008ebd5f8..5aca7975f 100644 --- a/tests/e2e/tui-render-stress.test.ts +++ b/tests/e2e/tui-render-stress.test.ts @@ -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( diff --git a/tests/e2e/tui-resize.test.ts b/tests/e2e/tui-resize.test.ts index 22f069b35..98e5353c7 100644 --- a/tests/e2e/tui-resize.test.ts +++ b/tests/e2e/tui-resize.test.ts @@ -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)/); }); diff --git a/tests/e2e/tui-resume.test.ts b/tests/e2e/tui-resume.test.ts index 4c55d65bc..8c8587585 100644 --- a/tests/e2e/tui-resume.test.ts +++ b/tests/e2e/tui-resume.test.ts @@ -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); diff --git a/tests/e2e/tui-slash-commands.test.ts b/tests/e2e/tui-slash-commands.test.ts index b47232ea5..47d5fa71e 100644 --- a/tests/e2e/tui-slash-commands.test.ts +++ b/tests/e2e/tui-slash-commands.test.ts @@ -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, ); diff --git a/tests/e2e/tui-startup.test.ts b/tests/e2e/tui-startup.test.ts index 72a30d654..1624b6f40 100644 --- a/tests/e2e/tui-startup.test.ts +++ b/tests/e2e/tui-startup.test.ts @@ -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"]); diff --git a/tests/e2e/tui-status-session.test.ts b/tests/e2e/tui-status-session.test.ts new file mode 100644 index 000000000..6e50364af --- /dev/null +++ b/tests/e2e/tui-status-session.test.ts @@ -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) { + 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); + } +}); diff --git a/tests/e2e/yolo-permission-mode.test.ts b/tests/e2e/yolo-permission-mode.test.ts index 2b3802560..612a1a7a8 100644 --- a/tests/e2e/yolo-permission-mode.test.ts +++ b/tests/e2e/yolo-permission-mode.test.ts @@ -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:"); diff --git a/tests/tla/README.md b/tests/tla/README.md index e26f52506..59f7aa8ea 100644 --- a/tests/tla/README.md +++ b/tests/tla/README.md @@ -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. diff --git a/tests/tla/drivers/lib/context.ts b/tests/tla/drivers/lib/context.ts index 026443ffb..71466dc8f 100644 --- a/tests/tla/drivers/lib/context.ts +++ b/tests/tla/drivers/lib/context.ts @@ -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); } diff --git a/tests/tla/drivers/session/bridge.ts b/tests/tla/drivers/session/bridge.ts new file mode 100644 index 000000000..77b7bd1fc --- /dev/null +++ b/tests/tla/drivers/session/bridge.ts @@ -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; +} + +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; +} + +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): { 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(); + 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 { + 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 { + const hashes = { + ...Object.fromEntries(SPECS.map((name) => [name, sha256File(join(SPEC_DIR, name))])), + observations: sha256Text(JSON.stringify(observations)), + }; + const result = await (async (): Promise => { + 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; +} diff --git a/tests/tla/drivers/session/status.test.ts b/tests/tla/drivers/session/status.test.ts new file mode 100644 index 000000000..dba22d9b6 --- /dev/null +++ b/tests/tla/drivers/session/status.test.ts @@ -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(); + shell: any = null; + blocked = false; + held: { release(answer: string): Promise } | 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 { + 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)> { + 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) { + 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"); +}); diff --git a/tests/tla/session/ShellSession.tla b/tests/tla/session/ShellSession.tla new file mode 100644 index 000000000..8fdf19c4b --- /dev/null +++ b/tests/tla/session/ShellSession.tla @@ -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 ` 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 == <> + +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 <> + +(* `fx --resume `, 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 <> + +(* `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 <> + +(* ------------------------------- 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 <> + ELSE /\ onDisk' = onDisk \cup {cur} + /\ turns' = [turns EXCEPT ![cur] = @ + 1] + /\ last' = Quiet("begin") + /\ UNCHANGED <> + +End == + /\ running + /\ turnOpen + /\ turnOpen' = FALSE + /\ last' = Quiet("end") + /\ UNCHANGED <> + +(* `/status`, between turns or while one runs. *) +Status == + /\ running + /\ last' = Obs("status", Report.id, 0, Report.saved, None) + /\ UNCHANGED <> + +(* ----------------------------- 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 <> + /\ last' = Obs(op, i, 0, FALSE, None) + /\ UNCHANGED <> + +(* `/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 <> + +(* `/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 <> + +(* ------------------------------- 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 <> + +(* 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 <> + +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") +============================================================================= diff --git a/tests/tla/session/ShellSessionTrace.tla b/tests/tla/session/ShellSessionTrace.tla new file mode 100644 index 000000000..fb7333fd4 --- /dev/null +++ b/tests/tla/session/ShellSessionTrace.tla @@ -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 == <> + +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) +============================================================================= diff --git a/tests/tla/session/check.sh b/tests/tla/session/check.sh new file mode 100755 index 000000000..46cf20c5a --- /dev/null +++ b/tests/tla/session/check.sh @@ -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 "$@" < · saved` + exactly when its log is on disk, so `fx --resume ` opens it, and as + ` · 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