From d20639065213a140a23ca43c0243de9fbf3a786e Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 12:04:51 -0400 Subject: [PATCH 1/5] Keep the fast indicator stable at startup Derive the footer marker from the active fast selection and intrinsic model identity so catalog hydration cannot toggle it. --- src/core/app/app_render_runtime.zig | 67 ++++++++--- src/core/config/model_capabilities.zig | 4 +- src/gateway/vercel_model_policy.zig | 4 + src/ui/footer/input_presentation.zig | 9 +- src/ui/footer/render_input.zig | 3 +- src/ui/render.zig | 46 ++++---- tests/e2e/config-persistence.test.ts | 155 +++++++++++++++---------- 7 files changed, 180 insertions(+), 108 deletions(-) diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index a559cebcc..8ae2668ed 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -597,8 +597,7 @@ pub fn Runtime(comptime App: type) type { const visible_model = pending_model orelse provider_runtime.model(app); const visible_capabilities = model_capabilities.resolveForApp(App, app, visible_model); const active_capabilities_pending = pending_model == null and app.isModelCacheLoading(); - const model_supports_fast = visible_capabilities.supports_fast_mode or - (active_capabilities_pending and app.fast_mode); + const model_supports_fast = visible_capabilities.supports_fast_mode; const model_supports_effort = visible_capabilities.reasoning_efforts.len > 0 or (active_capabilities_pending and !app.effort.isDefault()); const visible_effort = if (pending_model != null and model_supports_effort) @@ -607,10 +606,11 @@ pub fn Runtime(comptime App: type) type { app.effort else .auto; - const visible_fast_mode = if (pending_model != null and model_supports_fast) - pendingPickerFastMode(model_query, app.input_runtime.picker.model_picker_fast_index) + const fast_indicator_active = if (pending_model != null) + visible_capabilities.intrinsic_fast or + (model_supports_fast and pendingPickerFastMode(model_query, app.input_runtime.picker.model_picker_fast_index)) else - app.fast_mode; + visible_capabilities.intrinsic_fast or app.fast_mode; const upgrade_label = app.upgrader.statusLabel(upgrade_status_buf); const yolo_warning_active = @@ -667,8 +667,7 @@ pub fn Runtime(comptime App: type) type { .selected_subagent_status = null, .selected_subagent_tool_calls = 0, .selected_subagent_activity = null, - .fast_mode = visible_fast_mode, - .model_supports_fast = model_supports_fast, + .fast_indicator_active = fast_indicator_active, .effort = visible_effort, .model_supports_effort = model_supports_effort, .ctrl_c_pending = app.input_runtime.gestures.ctrlCExitArmed(), @@ -1273,8 +1272,7 @@ pub fn Runtime(comptime App: type) type { ctx.subagent_view_active = false; ctx.selected_subagent_label = display_name; ctx.selected_subagent_status = chat.state; - ctx.fast_mode = false; - ctx.model_supports_fast = capabilities.supports_fast_mode; + ctx.fast_indicator_active = capabilities.intrinsic_fast; ctx.effort = chat.configuration.effort orelse .auto; ctx.model_supports_effort = capabilities.reasoning_efforts.len > 0; ctx.ctrl_c_pending = view.editor.gestures.ctrlCExitArmed(); @@ -4609,6 +4607,7 @@ const CoordinatorTestApp = struct { effort: types.ReasoningEffort = .auto, statusline_context: bool = false, total_input_tokens: u64 = 0, + intrinsic_fast_model: ?[]const u8 = null, gateway_metadata_model: ?[]const u8 = null, gateway_metadata: model_capabilities.GatewayMetadata = .{}, permission_state: app_permission_runtime.State = .{}, @@ -4658,10 +4657,13 @@ const CoordinatorTestApp = struct { } pub fn resolvedModelCapabilities(self: *CoordinatorTestApp, model: []const u8) model_capabilities.Capabilities { - const fallback = model_capabilities.Capabilities{ + var fallback = model_capabilities.Capabilities{ .prompt_caching = true, .context_window = 1_000_000, }; + if (self.intrinsic_fast_model) |intrinsic_model| { + fallback.intrinsic_fast = std.mem.eql(u8, intrinsic_model, model); + } if (self.gateway_metadata_model) |metadata_model| { if (std.mem.eql(u8, metadata_model, model)) { return model_capabilities.mergeCapabilities( @@ -4797,8 +4799,7 @@ test "core.app_render_runtime keeps configured controls visible while model capa ctx.permission_mode, ctx.queued_count, null, - ctx.fast_mode, - ctx.model_supports_fast, + ctx.fast_indicator_active, ctx.effort, ctx.model_supports_effort, ctx.statusline, @@ -4811,6 +4812,47 @@ test "core.app_render_runtime keeps configured controls visible while model capa ); } +test "core.app_render_runtime keeps Kimi fast indicator stable across catalog hydration" { + const cases = [_]struct { + model: []const u8, + fast_mode: bool, + intrinsic_fast: bool, + supports_fast_mode: bool, + expected_indicator: bool, + }{ + .{ .model = "moonshotai/kimi-k3", .fast_mode = false, .intrinsic_fast = false, .supports_fast_mode = true, .expected_indicator = false }, + .{ .model = "moonshotai/kimi-k3", .fast_mode = true, .intrinsic_fast = false, .supports_fast_mode = true, .expected_indicator = true }, + .{ .model = "moonshotai/kimi-k3-fast", .fast_mode = false, .intrinsic_fast = true, .supports_fast_mode = false, .expected_indicator = true }, + }; + + for (cases) |case| { + for ([_]bool{ true, false }) |catalog_loading| { + var app = CoordinatorTestApp{ + .alloc = std.testing.allocator, + .shell = .{}, + .model_cache_loading = catalog_loading, + .fast_mode = case.fast_mode, + .intrinsic_fast_model = if (case.intrinsic_fast) case.model else null, + .gateway_metadata_model = if (catalog_loading) null else case.model, + .gateway_metadata = .{ .supports_fast_mode = case.supports_fast_mode }, + }; + defer app.deinit(); + try app.selected_model.appendSlice(std.testing.allocator, case.model); + + var upgrade_status_buf: [64]u8 = undefined; + const queued_cards: QueuedCardProjection = .{}; + const ctx = Runtime(CoordinatorTestApp).footerContext( + &app, + &upgrade_status_buf, + 0, + &queued_cards, + ); + + try std.testing.expectEqual(case.expected_indicator, ctx.fast_indicator_active); + } + } +} + test "core.app_render_runtime projects only the visible inline completion suffix" { const alloc = std.testing.allocator; var app = CoordinatorTestApp{ @@ -4893,7 +4935,6 @@ test "core.app_render_runtime projects Opus 4.8 one million token context to foo 0, null, false, - true, .auto, true, statusline, diff --git a/src/core/config/model_capabilities.zig b/src/core/config/model_capabilities.zig index b5ff9aa17..50616a159 100644 --- a/src/core/config/model_capabilities.zig +++ b/src/core/config/model_capabilities.zig @@ -48,6 +48,7 @@ pub const Capabilities = struct { supports_reasoning: bool = false, reasoning_efforts: ReasoningEffortOptions = .{}, supports_fast_mode: bool = false, + intrinsic_fast: bool = false, supports_tool_use: bool = false, supports_vision: bool = false, supports_file_input: bool = false, @@ -178,7 +179,7 @@ test "mergeCapabilities preserves provider controls and supplied fallback policy types.ReasoningEffort.literal("future-tier"), types.ReasoningEffort.literal("high"), }; - const capabilities = mergeCapabilities(.{ .prompt_caching = true }, .{ + const capabilities = mergeCapabilities(.{ .intrinsic_fast = true, .prompt_caching = true }, .{ .reasoning_efforts = .fromSlice(&efforts), .supports_fast_mode = true, .supports_tool_use = true, @@ -192,6 +193,7 @@ test "mergeCapabilities preserves provider controls and supplied fallback policy }); try std.testing.expect(capabilities.supports_reasoning); + try std.testing.expect(capabilities.intrinsic_fast); try std.testing.expectEqual(@as(usize, 2), capabilities.reasoning_efforts.len); try std.testing.expectEqualStrings("future-tier", capabilities.reasoning_efforts.values[0].label()); try std.testing.expect(capabilities.supports_fast_mode); diff --git a/src/gateway/vercel_model_policy.zig b/src/gateway/vercel_model_policy.zig index dcb0001d1..15e502a70 100644 --- a/src/gateway/vercel_model_policy.zig +++ b/src/gateway/vercel_model_policy.zig @@ -3,6 +3,7 @@ const model_capabilities = @import("../core/config/model_capabilities.zig"); pub fn capabilitiesForModel(model: []const u8) model_capabilities.Capabilities { var capabilities: model_capabilities.Capabilities = .{}; + capabilities.intrinsic_fast = std.mem.eql(u8, model, "moonshotai/kimi-k3-fast"); if (std.mem.startsWith(u8, model, "anthropic/")) { capabilities.prompt_caching = true; } else if (std.mem.startsWith(u8, model, "xai/")) { @@ -55,6 +56,9 @@ fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { } test "Vercel fallback policy owns vendor model heuristics" { + try std.testing.expect(!capabilitiesForModel("moonshotai/kimi-k3").intrinsic_fast); + try std.testing.expect(capabilitiesForModel("moonshotai/kimi-k3-fast").intrinsic_fast); + try std.testing.expect(!capabilitiesForModel("moonshotai/kimi-k3-fast").supports_fast_mode); try std.testing.expectEqual(@as(?u32, 1_000_000), contextWindowSize("anthropic/claude-opus-4.8")); try std.testing.expect(capabilitiesForModel("anthropic/claude-opus-4.8").prompt_caching); try std.testing.expectEqual(@as(?bool, true), capabilitiesForModel("xai/grok-4").parallel_tool_calls); diff --git a/src/ui/footer/input_presentation.zig b/src/ui/footer/input_presentation.zig index 5ece9830d..65e366870 100644 --- a/src/ui/footer/input_presentation.zig +++ b/src/ui/footer/input_presentation.zig @@ -441,8 +441,7 @@ pub fn composeHintRow( ctx.permission_mode, ctx.queued_count, active_label, - ctx.fast_mode, - ctx.model_supports_fast, + ctx.fast_indicator_active, ctx.effort, ctx.model_supports_effort, ctx.statusline, @@ -1595,8 +1594,7 @@ test "compose hint row keeps model in left hint text" { .selected_subagent_id = null, .selected_subagent_label = null, .selected_subagent_status = null, - .fast_mode = true, - .model_supports_fast = true, + .fast_indicator_active = true, .input = &input, }; @@ -1733,8 +1731,7 @@ test "compose hint row omits the inactive subagent manager marker" { .selected_subagent_id = null, .selected_subagent_label = null, .selected_subagent_status = null, - .fast_mode = true, - .model_supports_fast = true, + .fast_indicator_active = true, .input = &input, }; diff --git a/src/ui/footer/render_input.zig b/src/ui/footer/render_input.zig index 5dc7c5604..92f3dc73b 100644 --- a/src/ui/footer/render_input.zig +++ b/src/ui/footer/render_input.zig @@ -403,8 +403,7 @@ pub const RenderContext = struct { selected_subagent_status: ?SubagentStatus, selected_subagent_tool_calls: usize = 0, selected_subagent_activity: ?[]const u8 = null, - fast_mode: bool = false, - model_supports_fast: bool = false, + fast_indicator_active: bool = false, effort: types.ReasoningEffort = .auto, model_supports_effort: bool = false, ctrl_c_pending: bool = false, diff --git a/src/ui/render.zig b/src/ui/render.zig index 863c6d7d7..293f390b3 100644 --- a/src/ui/render.zig +++ b/src/ui/render.zig @@ -396,8 +396,7 @@ pub fn buildHintLine( permission_mode: types.PermissionMode, queued_count: usize, active_label: ?[]const u8, - fast_mode: bool, - model_supports_fast: bool, + fast_indicator_active: bool, effort: types.ReasoningEffort, model_supports_effort: bool, statusline: StatuslineItems, @@ -424,7 +423,6 @@ pub fn buildHintLine( } const status_limit = @min(@as(usize, width), out.len); const show_effort = model_supports_effort and !effort.isDefault(); - const show_fast = model_supports_fast and fast_mode; if (leadingPermissionModeFits(status_limit, permission_label, model_label)) { appendStatusSegment(out, &end, permission_label); } @@ -432,7 +430,7 @@ pub fn buildHintLine( if (show_effort) { appendStatusSegment(out, &end, effort.displayLabel()); } - if (show_fast) { + if (fast_indicator_active) { appendStatusSegment(out, &end, "⚡︎"); } @@ -944,20 +942,20 @@ test "dev build label drops an unresolved revision" { test "buildHintLine advertises queue without persistent steering hint while streaming" { var buf: [128]u8 = undefined; - const line = buildHintLine(true, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{}, 120, &buf); + const line = buildHintLine(true, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{}, 120, &buf); try std.testing.expect(std.mem.find(u8, line, "enter queue") != null); try std.testing.expect(std.mem.find(u8, line, "ctrl+enter steer") == null); } test "buildHintLine hides effort when it is auto" { var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.7", .ask, 0, null, false, true, .auto, true, .{}, 80, &buf); + const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.7", .ask, 0, null, false, .auto, true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · opus 4.7", line); } test "buildHintLine hides effort for models without effort support" { var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-4o", .ask, 0, null, false, false, .auto, false, .{}, 80, &buf); + const line = buildHintLine(false, false, true, "openai/gpt-4o", .ask, 0, null, false, .auto, false, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · gpt-4o", line); } @@ -966,20 +964,20 @@ test "buildHintLine uses a monochrome lightning marker for fast mode" { defer initTheme(false, null); var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.8", .ask, 0, null, true, true, types.ReasoningEffort.literal("low"), true, .{}, 80, &buf); + const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.8", .ask, 0, null, true, types.ReasoningEffort.literal("low"), true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · opus 4.8 · low · ⚡︎", line); try std.testing.expectEqual(@as(usize, 25), display_width.visibleWidthIgnoringAnsi(line)); } test "buildHintLine shows effort when active" { var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, types.ReasoningEffort.literal("high"), true, .{}, 80, &buf); + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, types.ReasoningEffort.literal("high"), true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · gpt-5 · high", line); } test "buildHintLine shows full context usage" { var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.8", .ask, 0, null, false, true, .auto, true, .{ + const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.8", .ask, 0, null, false, .auto, true, .{ .context_used = 43_000, .context_total = 1_000_000, }, 80, &buf); @@ -988,7 +986,7 @@ test "buildHintLine shows full context usage" { test "buildHintLine shows the session title" { var buf: [256]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{ + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{ .session_title = "add a session name display", }, 200, &buf); try std.testing.expectEqualStrings( @@ -999,7 +997,7 @@ test "buildHintLine shows the session title" { test "buildHintLine clips an overlong session title on a character boundary" { var buf: [256]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{ + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{ .session_title = "ααααααααααααααααααααααααααααααααααααααααα", }, 200, &buf); try std.testing.expect(std.mem.startsWith(u8, line, "ask · gpt-5 · ")); @@ -1010,7 +1008,7 @@ test "buildHintLine clips an overlong session title on a character boundary" { test "buildHintLine omits the session segment when no title is cached" { var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{ + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{ .session_title = null, }, 80, &buf); try std.testing.expectEqualStrings("ask · gpt-5", line); @@ -1018,7 +1016,7 @@ test "buildHintLine omits the session segment when no title is cached" { test "buildHintLine shows the workspace and Git branch" { var buf: [256]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{ + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{ .workspace_label = "/workspace/code/fx", .git_branch = "feature/statusline", }, 100, &buf); @@ -1030,7 +1028,7 @@ test "buildHintLine shows the workspace and Git branch" { test "buildHintLine keeps workspace and branch readable at narrow widths" { var buf: [256]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{ + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{ .workspace_label = "/a/very/long/path/to/fx-repo", .git_branch = "feature/statusline", }, 36, &buf); @@ -1043,7 +1041,7 @@ test "buildHintLine keeps workspace and branch readable at narrow widths" { test "buildHintLine workspace identity does not displace existing status segments" { var buf: [256]u8 = undefined; - const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.8", .auto, 0, null, true, true, types.ReasoningEffort.literal("xhigh"), true, .{ + const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.8", .auto, 0, null, true, types.ReasoningEffort.literal("xhigh"), true, .{ .workspace_label = "/a/very/long/path/to/the/active/workspace", .git_branch = "feature/statusline", .context_used = 1_000, @@ -1056,7 +1054,7 @@ test "buildHintLine workspace identity does not displace existing status segment test "buildHintLine shows a non-Git workspace without branch punctuation" { var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{ + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{ .workspace_label = "/tmp/plain-workspace", }, 80, &buf); try std.testing.expectEqualStrings( @@ -1067,7 +1065,7 @@ test "buildHintLine shows a non-Git workspace without branch punctuation" { test "buildHintLine labels detached HEAD" { var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, false, .auto, false, .{ + const line = buildHintLine(false, false, true, "openai/gpt-5", .ask, 0, null, false, .auto, false, .{ .workspace_label = "/tmp/fx", .git_branch = "detached:0123456789ab", }, 80, &buf); @@ -1079,7 +1077,7 @@ test "buildHintLine labels detached HEAD" { test "buildHintLine keeps system labels and dot separators" { var buf: [256]u8 = undefined; - const line = buildHintLine(false, false, false, "anthropic/claude-opus-4.8", .auto, 2, null, true, true, types.ReasoningEffort.literal("low"), true, .{ + const line = buildHintLine(false, false, false, "anthropic/claude-opus-4.8", .auto, 2, null, true, types.ReasoningEffort.literal("low"), true, .{ .context_used = 43_000, .context_total = 1_000_000, }, 256, &buf); @@ -1097,7 +1095,7 @@ test "buildHintLine keeps system labels and dot separators" { test "buildHintLine skips an over-capacity segment without a dangling dot" { var buf: [16]u8 = undefined; - const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.7", .ask, 0, null, true, true, .auto, true, .{}, 80, &buf); + const line = buildHintLine(false, false, true, "anthropic/claude-opus-4.7", .ask, 0, null, true, .auto, true, .{}, 80, &buf); try std.testing.expectEqualStrings("ask · opus 4.7", line); } @@ -1106,7 +1104,7 @@ test "buildHintLine colors auto mode with theme accent" { const dark_accent = permission_auto_style; const dark_status = statusline_style; var dark_buf: [128]u8 = undefined; - const dark_line = buildHintLine(false, false, true, "openai/gpt-4o", .auto, 0, null, false, false, .auto, false, .{}, 80, &dark_buf); + const dark_line = buildHintLine(false, false, true, "openai/gpt-4o", .auto, 0, null, false, .auto, false, .{}, 80, &dark_buf); const dark_expected = try std.fmt.allocPrint(std.testing.allocator, "{s}auto{s} · gpt-4o", .{ dark_accent, dark_status }); defer std.testing.allocator.free(dark_expected); try std.testing.expectEqualStrings(dark_expected, dark_line); @@ -1115,7 +1113,7 @@ test "buildHintLine colors auto mode with theme accent" { defer initTheme(false, null); try std.testing.expect(!std.mem.eql(u8, permission_auto_style, dark_accent)); var light_buf: [128]u8 = undefined; - const light_line = buildHintLine(false, false, true, "openai/gpt-4o", .auto, 0, null, false, false, .auto, false, .{}, 80, &light_buf); + const light_line = buildHintLine(false, false, true, "openai/gpt-4o", .auto, 0, null, false, .auto, false, .{}, 80, &light_buf); const light_expected = try std.fmt.allocPrint(std.testing.allocator, "{s}auto{s} · gpt-4o", .{ permission_auto_style, statusline_style }); defer std.testing.allocator.free(light_expected); try std.testing.expectEqualStrings(light_expected, light_line); @@ -1124,7 +1122,7 @@ test "buildHintLine colors auto mode with theme accent" { test "buildHintLine renders yolo uppercase with subdued permission styling" { initTheme(false, null); var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-4o", .yolo, 0, null, false, false, .auto, false, .{}, 80, &buf); + const line = buildHintLine(false, false, true, "openai/gpt-4o", .yolo, 0, null, false, .auto, false, .{}, 80, &buf); const expected = try std.fmt.allocPrint( std.testing.allocator, "{s}YOLO{s} · gpt-4o", @@ -1140,7 +1138,7 @@ test "buildHintLine clips styled auto mode by visible width" { defer initTheme(false, null); var buf: [128]u8 = undefined; - const line = buildHintLine(false, false, true, "openai/gpt-4o", .auto, 0, null, false, false, .auto, false, .{}, 13, &buf); + const line = buildHintLine(false, false, true, "openai/gpt-4o", .auto, 0, null, false, .auto, false, .{}, 13, &buf); const expected = try std.fmt.allocPrint(std.testing.allocator, "{s}auto{s} · gpt-4o", .{ permission_auto_style, statusline_style }); defer std.testing.allocator.free(expected); diff --git a/tests/e2e/config-persistence.test.ts b/tests/e2e/config-persistence.test.ts index 36af9d069..be2c69940 100644 --- a/tests/e2e/config-persistence.test.ts +++ b/tests/e2e/config-persistence.test.ts @@ -606,83 +606,114 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { ); test( - "configured effort and Fast are visible before model catalog resolves", + "Kimi Fast indicator remains stable while model catalog resolves", async () => { - const root = mkdtempSync(join(tmpdir(), "fx-startup-preferences-")); - let releaseCatalog: (() => void) | null = null; - const catalogRelease = new Promise((resolve) => { - releaseCatalog = resolve; - }); - const gateway = startFakeGateway([], { - models: async () => { - await catalogRelease; - return [{ - id: "anthropic/claude-opus-4.8", - type: "language", - released: 1, - tags: ["fast", "tool-use"], - reasoning_options: [{ type: "effort", values: ["high", "xhigh"] }], - pricing: { - fast: { input: "0.1", output: "0.2" }, - }, - }]; + const cases = [ + { + label: "normal", + model: "moonshotai/kimi-k3", + fastMode: false, + supportsFastMode: true, + expectedFastIndicator: false, }, - }); - try { - const home = join(root, "home"); - const workspace = join(root, "workspace"); - const stderrPath = join(root, "stderr.log"); - mkdirSync(join(home, ".fx"), { recursive: true, mode: 0o700 }); - mkdirSync(workspace); - writeFileSync( - join(home, ".fx", "settings.json"), - JSON.stringify({ - model: "anthropic/claude-opus-4.8", - permission_mode: "auto", - effort: "xhigh", - fast_mode: true, - }) + "\n", - { mode: 0o600 }, - ); + { + label: "toggle", + model: "moonshotai/kimi-k3", + fastMode: true, + supportsFastMode: true, + expectedFastIndicator: true, + }, + { + label: "intrinsic", + model: "moonshotai/kimi-k3-fast", + fastMode: false, + supportsFastMode: false, + expectedFastIndicator: true, + }, + ] as const; - session = await TmuxSession.create({ - cwd: realpathSync(workspace), - env: { - ...NO_AUTH, - HOME: home, - FX_AUTO_UPGRADE: "0", - FX_E2E_GATEWAY_MODELS_URL: `${gateway.baseUrl}/coding-agent/v1/models`, + for (const testCase of cases) { + const root = mkdtempSync(join(tmpdir(), `fx-startup-fast-${testCase.label}-`)); + let releaseCatalog: (() => void) | null = null; + const catalogRelease = new Promise((resolve) => { + releaseCatalog = resolve; + }); + const gateway = startFakeGateway([], { + models: async () => { + await catalogRelease; + return [{ + id: testCase.model, + type: "language", + released: 1, + tags: ["reasoning", "tool-use"], + pricing: testCase.supportsFastMode + ? { fast: { input: "0.1", output: "0.2" } } + : undefined, + }]; }, - stderrPath, }); - const pane = await session.waitForText("auto · opus 4.8", TIMEOUT); - expect(pane).toContain("auto · opus 4.8 · xhigh · ⚡︎"); - releaseCatalog?.(); - releaseCatalog = null; + try { + const home = join(root, "home"); + const workspace = join(root, "workspace"); + const stderrPath = join(root, "stderr.log"); + mkdirSync(join(home, ".fx"), { recursive: true, mode: 0o700 }); + mkdirSync(workspace); + writeFileSync( + join(home, ".fx", "settings.json"), + JSON.stringify({ + model: testCase.model, + permission_mode: "auto", + fast_mode: testCase.fastMode, + }) + "\n", + { mode: 0o600 }, + ); - await session.sendText("/quit"); - await session.waitForSessionEnd(TIMEOUT); - session = null; - expect(readFileSync(stderrPath, "utf8")).toBe(""); - } finally { - releaseCatalog?.(); - gateway.stop(); - rmSync(root, { recursive: true, force: true }); + session = await TmuxSession.create({ + cwd: realpathSync(workspace), + env: { + ...NO_AUTH, + HOME: home, + FX_AUTO_UPGRADE: "0", + FX_E2E_GATEWAY_MODELS_URL: `${gateway.baseUrl}/coding-agent/v1/models`, + }, + stderrPath, + }); + const modelLabel = testCase.model.split("/").at(-1)!; + const before = await session.waitForText(`auto · ${modelLabel}`, TIMEOUT); + expect(before.includes("⚡︎")).toBe(testCase.expectedFastIndicator); + releaseCatalog?.(); + releaseCatalog = null; + + await session.sendText("/model"); + await session.waitForText(testCase.model, TIMEOUT); + await session.sendKeys("Escape"); + const settled = await session.waitForStableComposer(TIMEOUT); + expect(settled.includes("⚡︎")).toBe(testCase.expectedFastIndicator); + + await session.sendText("/quit"); + await session.waitForSessionEnd(TIMEOUT); + session = null; + expect(readFileSync(stderrPath, "utf8")).toBe(""); + } finally { + releaseCatalog?.(); + gateway.stop(); + rmSync(root, { recursive: true, force: true }); + } } }, - 30_000, + 60_000, ); test( - "Fast command rejects a tag-only intrinsic Fast alias", + "Fast command rejects an intrinsically Fast Kimi alias", async () => { const root = mkdtempSync(join(tmpdir(), "fx-fast-unsupported-")); const gateway = startFakeGateway([], { models: [{ - id: "anthropic/claude-opus-4.8-fast", + id: "moonshotai/kimi-k3-fast", type: "language", released: 1, - tags: ["fast", "tool-use"], + tags: ["reasoning", "tool-use"], }], }); try { @@ -693,7 +724,7 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { mkdirSync(workspace); const settingsPath = join(home, ".fx", "settings.json"); const initialSettings = JSON.stringify({ - model: "anthropic/claude-opus-4.8-fast", + model: "moonshotai/kimi-k3-fast", fast_mode: false, }) + "\n"; writeFileSync(settingsPath, initialSettings, { mode: 0o600 }); @@ -714,7 +745,7 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { "This model does not come with a fast mode.", TIMEOUT, ); - expect(pane).not.toContain("⚡︎"); + expect(pane).toContain("⚡︎"); expect(gateway.requests).toHaveLength(0); expect(readFileSync(settingsPath, "utf8")).toBe(initialSettings); From dd7225189ccc733ee9516790359a50dd6dc067e7 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 13:09:48 -0400 Subject: [PATCH 2/5] Hide stale fast state on unsupported models Gate the active footer preference by resolved fast support while retaining the loading fallback and intrinsic fast identity. --- src/core/app/app_render_runtime.zig | 25 ++++++++++++++++++++++++- tests/e2e/config-persistence.test.ts | 3 ++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 8ae2668ed..9345e920e 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -610,7 +610,8 @@ pub fn Runtime(comptime App: type) type { visible_capabilities.intrinsic_fast or (model_supports_fast and pendingPickerFastMode(model_query, app.input_runtime.picker.model_picker_fast_index)) else - visible_capabilities.intrinsic_fast or app.fast_mode; + visible_capabilities.intrinsic_fast or + ((model_supports_fast or active_capabilities_pending) and app.fast_mode); const upgrade_label = app.upgrader.statusLabel(upgrade_status_buf); const yolo_warning_active = @@ -4853,6 +4854,28 @@ test "core.app_render_runtime keeps Kimi fast indicator stable across catalog hy } } +test "core.app_render_runtime hides stale fast preference for unsupported model" { + var app = CoordinatorTestApp{ + .alloc = std.testing.allocator, + .shell = .{}, + .fast_mode = true, + .gateway_metadata_model = "anthropic/claude-fable-5", + }; + defer app.deinit(); + try app.selected_model.appendSlice(std.testing.allocator, "anthropic/claude-fable-5"); + + var upgrade_status_buf: [64]u8 = undefined; + const queued_cards: QueuedCardProjection = .{}; + const ctx = Runtime(CoordinatorTestApp).footerContext( + &app, + &upgrade_status_buf, + 0, + &queued_cards, + ); + + try std.testing.expect(!ctx.fast_indicator_active); +} + test "core.app_render_runtime projects only the visible inline completion suffix" { const alloc = std.testing.allocator; var app = CoordinatorTestApp{ diff --git a/tests/e2e/config-persistence.test.ts b/tests/e2e/config-persistence.test.ts index be2c69940..ae37531ee 100644 --- a/tests/e2e/config-persistence.test.ts +++ b/tests/e2e/config-persistence.test.ts @@ -1161,7 +1161,8 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { for (let i = 0; i < 2; i += 1) await session.sendKeys("Down"); await session.waitForText("xhigh", TIMEOUT); await session.sendKeys("Enter"); - await session.waitForText("fable-5 · xhigh", TIMEOUT); + const selected = await session.waitForText("fable-5 · xhigh", TIMEOUT); + expect(selected).not.toContain("⚡︎"); await session.sendText("/quit"); await session.waitForSessionEnd(TIMEOUT); session = null; From e1a46c7e93c0df83208fe4c6b82db04a8f977d1e Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 15:22:47 -0400 Subject: [PATCH 3/5] Recognize intrinsic fast model suffixes Derive intrinsic fast identity from the terminal -fast suffix across provider capability resolution without granting toggle support. --- src/core/config/model_capabilities.zig | 45 ++++++++++++++++++-------- src/gateway/vercel_model_policy.zig | 4 +-- tests/e2e/config-persistence.test.ts | 6 ++-- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/core/config/model_capabilities.zig b/src/core/config/model_capabilities.zig index 50616a159..8ae869313 100644 --- a/src/core/config/model_capabilities.zig +++ b/src/core/config/model_capabilities.zig @@ -99,19 +99,22 @@ pub fn mergeCapabilities(capabilities_value: Capabilities, gateway_metadata: ?Ga return capabilities; } -pub fn resolveCapabilities(_: []const u8, gateway_metadata: ?GatewayMetadata) Capabilities { - return mergeCapabilities(.{}, gateway_metadata); +pub fn resolveCapabilities(model: []const u8, gateway_metadata: ?GatewayMetadata) Capabilities { + return mergeCapabilities(capabilitiesForModel(model), gateway_metadata); } pub fn capabilitiesForModel(model: []const u8) Capabilities { - return resolveCapabilities(model, null); + return .{ .intrinsic_fast = std.mem.endsWith(u8, model, "-fast") }; } pub fn resolveForApp(comptime App: type, app: *App, model: []const u8) Capabilities { - if (comptime @hasDecl(App, "resolvedModelCapabilities")) { - return app.resolvedModelCapabilities(model); - } - return capabilitiesForModel(model); + const generic = capabilitiesForModel(model); + var capabilities = if (comptime @hasDecl(App, "resolvedModelCapabilities")) + app.resolvedModelCapabilities(model) + else + generic; + capabilities.intrinsic_fast = capabilities.intrinsic_fast or generic.intrinsic_fast; + return capabilities; } pub fn reasoningEffortSupported(capabilities: Capabilities, effort: types.ReasoningEffort) bool { @@ -160,20 +163,34 @@ pub fn resolveProviderOptionsForCapabilities( return resolved; } -test "capabilities never infer reasoning or Fast controls from model IDs" { - const models = [_][]const u8{ - "openai/gpt-5.6-sol", - "anthropic/claude-opus-4.8", - "zai/glm-5.2", - "zai/glm-5.2-fast", +test "capabilities infer intrinsic fast identity but not controls from model IDs" { + const models = [_]struct { id: []const u8, intrinsic_fast: bool }{ + .{ .id = "openai/gpt-5.6-sol", .intrinsic_fast = false }, + .{ .id = "anthropic/claude-opus-4.8", .intrinsic_fast = false }, + .{ .id = "zai/glm-5.2", .intrinsic_fast = false }, + .{ .id = "zai/glm-5.2-fast", .intrinsic_fast = true }, + .{ .id = "provider/breakfast", .intrinsic_fast = false }, }; for (models) |model| { - const capabilities = capabilitiesForModel(model); + const capabilities = capabilitiesForModel(model.id); try std.testing.expectEqual(@as(usize, 0), capabilities.reasoning_efforts.len); try std.testing.expect(!capabilities.supports_fast_mode); + try std.testing.expectEqual(model.intrinsic_fast, capabilities.intrinsic_fast); } } +test "resolveForApp adds intrinsic fast identity to provider capabilities" { + const App = struct { + pub fn resolvedModelCapabilities(_: *@This(), _: []const u8) Capabilities { + return .{}; + } + }; + var app = App{}; + + try std.testing.expect(resolveForApp(App, &app, "provider/model-fast").intrinsic_fast); + try std.testing.expect(!resolveForApp(App, &app, "provider/model-default").intrinsic_fast); +} + test "mergeCapabilities preserves provider controls and supplied fallback policy" { const efforts = [_]types.ReasoningEffort{ types.ReasoningEffort.literal("future-tier"), diff --git a/src/gateway/vercel_model_policy.zig b/src/gateway/vercel_model_policy.zig index 15e502a70..28ba82ef6 100644 --- a/src/gateway/vercel_model_policy.zig +++ b/src/gateway/vercel_model_policy.zig @@ -2,8 +2,7 @@ const std = @import("std"); const model_capabilities = @import("../core/config/model_capabilities.zig"); pub fn capabilitiesForModel(model: []const u8) model_capabilities.Capabilities { - var capabilities: model_capabilities.Capabilities = .{}; - capabilities.intrinsic_fast = std.mem.eql(u8, model, "moonshotai/kimi-k3-fast"); + var capabilities = model_capabilities.capabilitiesForModel(model); if (std.mem.startsWith(u8, model, "anthropic/")) { capabilities.prompt_caching = true; } else if (std.mem.startsWith(u8, model, "xai/")) { @@ -58,6 +57,7 @@ fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool { test "Vercel fallback policy owns vendor model heuristics" { try std.testing.expect(!capabilitiesForModel("moonshotai/kimi-k3").intrinsic_fast); try std.testing.expect(capabilitiesForModel("moonshotai/kimi-k3-fast").intrinsic_fast); + try std.testing.expect(capabilitiesForModel("anthropic/claude-opus-4.8-fast").intrinsic_fast); try std.testing.expect(!capabilitiesForModel("moonshotai/kimi-k3-fast").supports_fast_mode); try std.testing.expectEqual(@as(?u32, 1_000_000), contextWindowSize("anthropic/claude-opus-4.8")); try std.testing.expect(capabilitiesForModel("anthropic/claude-opus-4.8").prompt_caching); diff --git a/tests/e2e/config-persistence.test.ts b/tests/e2e/config-persistence.test.ts index ae37531ee..403b36e29 100644 --- a/tests/e2e/config-persistence.test.ts +++ b/tests/e2e/config-persistence.test.ts @@ -705,12 +705,12 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { ); test( - "Fast command rejects an intrinsically Fast Kimi alias", + "Fast command rejects an intrinsic Fast alias", async () => { const root = mkdtempSync(join(tmpdir(), "fx-fast-unsupported-")); const gateway = startFakeGateway([], { models: [{ - id: "moonshotai/kimi-k3-fast", + id: "anthropic/claude-opus-4.8-fast", type: "language", released: 1, tags: ["reasoning", "tool-use"], @@ -724,7 +724,7 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { mkdirSync(workspace); const settingsPath = join(home, ".fx", "settings.json"); const initialSettings = JSON.stringify({ - model: "moonshotai/kimi-k3-fast", + model: "anthropic/claude-opus-4.8-fast", fast_mode: false, }) + "\n"; writeFileSync(settingsPath, initialSettings, { mode: 0o600 }); From b1dbc9fde1c14c7907325cff2befa0f40cb0c1b0 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 17:15:42 -0400 Subject: [PATCH 4/5] Bind fast indicators to model selections Persist model and fast preferences together so unsupported models cannot inherit a stale startup marker. --- src/core/app/app_bootstrap_runtime.zig | 9 ++++ src/core/app/app_input_runtime.zig | 8 ++-- src/core/app/app_lifecycle.zig | 66 ++++++++++++++++++++++++-- src/core/app/app_render_runtime.zig | 10 ++-- src/core/app/app_session_runtime.zig | 24 +++++++++- src/core/config/config_runtime.zig | 11 +++++ src/core/config/settings_store.zig | 37 +++++++++++++++ src/core/session/session_commands.zig | 61 +++++++++++++++++------- src/main.zig | 4 ++ tests/e2e/config-persistence.test.ts | 27 +++++++++-- 10 files changed, 222 insertions(+), 35 deletions(-) diff --git a/src/core/app/app_bootstrap_runtime.zig b/src/core/app/app_bootstrap_runtime.zig index f1c15ef33..55216c35c 100644 --- a/src/core/app/app_bootstrap_runtime.zig +++ b/src/core/app/app_bootstrap_runtime.zig @@ -48,6 +48,7 @@ fn BootstrapDeps(comptime App: type) type { []const u8, types.ReasoningEffort, bool, + bool, ) anyerror!void; const InitializePersistenceFn = *const fn (*App, bool) anyerror!void; const StageRequestedResumeViewFn = *const fn (*App) app_session_runtime.ResumeViewStage; @@ -142,6 +143,7 @@ pub fn Runtime(comptime App: type) type { selected_model: []const u8, effort: types.ReasoningEffort, fast_mode: bool, + fast_mode_model_bound: bool, ) !void { try app_session_runtime.Runtime(App).configureStartupPreferences( app, @@ -151,6 +153,7 @@ pub fn Runtime(comptime App: type) type { selected_model, effort, fast_mode, + fast_mode_model_bound, ); } @@ -276,6 +279,7 @@ pub fn Runtime(comptime App: type) type { active_model, startup.effort, startup.fast_mode, + startup.fast_mode_model_bound, ); app.permission_engine.mode = startup.permission_mode; app.permission_engine.replaceRules(app.alloc, startup.takePermissionRules()); @@ -486,6 +490,7 @@ const TestCapture = struct { runtime_model_len: usize = 0, configured_effort: types.ReasoningEffort = .auto, configured_fast_mode: bool = false, + configured_fast_mode_model_bound: bool = false, initialize_required: bool = false, load_skills_workspace: []const u8 = "", load_skills_workspace_root_count: usize = 0, @@ -714,6 +719,7 @@ fn makeStartupState(alloc: Allocator) !app_lifecycle.StartupState { state.permission_mode = .auto; state.context_enabled = false; state.fast_mode = true; + state.fast_mode_model_bound = true; state.auto_upgrade = false; state.update_channel = .dev; state.effort = types.ReasoningEffort.literal("high"); @@ -791,6 +797,7 @@ fn configureSessionPreferencesForTest( selected_model: []const u8, effort: types.ReasoningEffort, fast_mode: bool, + fast_mode_model_bound: bool, ) !void { const capture = active_capture.?; capture.configured_model_len = @min( @@ -812,6 +819,7 @@ fn configureSessionPreferencesForTest( ); capture.configured_effort = effort; capture.configured_fast_mode = fast_mode; + capture.configured_fast_mode_model_bound = fast_mode_model_bound; } fn beginFreshPersistedSessionForTest(app: *TestApp) !void { @@ -901,6 +909,7 @@ test "app_bootstrap_runtime transfers startup state and starts a fresh session" capture.configured_effort, ); try std.testing.expect(capture.configured_fast_mode); + try std.testing.expect(capture.configured_fast_mode_model_bound); try std.testing.expectEqual( update_target.Channel.dev, app.upgrader.channel(), diff --git a/src/core/app/app_input_runtime.zig b/src/core/app/app_input_runtime.zig index 893e4c83a..ecfc50593 100644 --- a/src/core/app/app_input_runtime.zig +++ b/src/core/app/app_input_runtime.zig @@ -6575,7 +6575,7 @@ test "active stream Enter commits a complete model choice for the next turn" { try std.testing.expectEqualStrings(model, app.last_preference_model.items); try std.testing.expectEqual(types.ReasoningEffort.auto, app.effort); try std.testing.expectEqual(types.ReasoningEffort.auto, app.last_preference_effort.?); - try std.testing.expect(app.last_preference_fast_mode == null); + try std.testing.expectEqual(false, app.last_preference_fast_mode.?); try std.testing.expectEqualStrings("", app.input_runtime.edit_state.input.items); try std.testing.expectEqual(@as(usize, 0), app.submitted_prompt_count); try std.testing.expectEqualStrings( @@ -7455,7 +7455,7 @@ test "app_input_runtime model picker commits a model without options directly" { try std.testing.expectEqual(@as(usize, 1), app.preference_commit_count); try std.testing.expectEqualStrings("openai/gpt-4o", app.selected_model.items); try std.testing.expect(app.last_preference_effort == null); - try std.testing.expect(app.last_preference_fast_mode == null); + try std.testing.expectEqual(false, app.last_preference_fast_mode.?); try std.testing.expectEqualStrings("", app.input_runtime.edit_state.input.items); } @@ -7482,7 +7482,7 @@ test "app_input_runtime model picker skips effort stage for reasoning model with try std.testing.expectEqual(ModelPickerStage.model, app.input_runtime.picker.model_picker_stage); try std.testing.expect(!app.input_runtime.picker.hasPendingModelPickerSelection()); try std.testing.expect(app.last_preference_effort == null); - try std.testing.expect(app.last_preference_fast_mode == null); + try std.testing.expectEqual(false, app.last_preference_fast_mode.?); try std.testing.expectEqualStrings("", app.input_runtime.edit_state.input.items); } @@ -7514,7 +7514,7 @@ test "app_input_runtime model picker exposes opaque Gateway reasoning effort" { try std.testing.expectEqual(types.ReasoningEffort.literal("future-tier"), app.effort); try std.testing.expect(!app.fast_mode); try std.testing.expectEqual(types.ReasoningEffort.literal("future-tier"), app.last_preference_effort.?); - try std.testing.expect(app.last_preference_fast_mode == null); + try std.testing.expectEqual(false, app.last_preference_fast_mode.?); try std.testing.expectEqualStrings("", app.input_runtime.edit_state.input.items); } diff --git a/src/core/app/app_lifecycle.zig b/src/core/app/app_lifecycle.zig index 81fd92bc2..6187abda9 100644 --- a/src/core/app/app_lifecycle.zig +++ b/src/core/app/app_lifecycle.zig @@ -131,6 +131,7 @@ pub const StartupState = struct { context_limits: config_runtime.context_limits.Values = .{}, context_enabled: bool = true, fast_mode: bool = false, + fast_mode_model_bound: bool = false, fast_mode_source: config_runtime.ConfigSource = .compiled_default, slash_menu_categories: bool = true, collapse_tool_calls: bool = false, @@ -422,8 +423,16 @@ fn loadStartupStateFromOwnedWorkspace( state.max_tool_result_bytes = tool_result_limits.resolveMaxToolResultBytes(settings.max_tool_result_bytes, tool_result_limits.default_max_tool_result_bytes); state.context_limits = config_runtime.resolveContextLimits(settings, &.{}); state.context_enabled = settings.context orelse true; - state.fast_mode = settings.fast_mode orelse - (state.provider == .gateway and state.model_source == .compiled_default); + const fast_mode = resolveStartupFastMode( + state.provider, + state.model_source, + settings.fast_mode, + detailed.sources.fast_mode, + settings.fast_mode_model_bound, + detailed.sources.fast_mode_model_bound, + ); + state.fast_mode = fast_mode.enabled; + state.fast_mode_model_bound = fast_mode.model_bound; state.fast_mode_source = detailed.sources.fast_mode; state.slash_menu_categories = settings.slash_menu_categories orelse true; state.collapse_tool_calls = settings.collapse_tool_calls orelse false; @@ -445,6 +454,32 @@ fn loadStartupStateFromOwnedWorkspace( return state; } +const StartupFastMode = struct { + enabled: bool, + model_bound: bool, +}; + +fn resolveStartupFastMode( + provider: model_provider.ProviderId, + model_source: config_runtime.ModelSource, + configured_fast_mode: ?bool, + fast_mode_source: config_runtime.ConfigSource, + model_bound: ?bool, + binding_source: config_runtime.ConfigSource, +) StartupFastMode { + if (configured_fast_mode) |enabled| { + return .{ + .enabled = enabled, + .model_bound = enabled and + model_bound == true and + model_source == fast_mode_source and + fast_mode_source == binding_source, + }; + } + const enabled = provider == .gateway and model_source == .compiled_default; + return .{ .enabled = enabled, .model_bound = enabled }; +} + pub fn bootstrapInteractiveApp(cfg: BootstrapConfig) !StartupState { try cfg.terminal.ensureInteractive(); try cfg.terminal.captureOriginalTermios(); @@ -2013,7 +2048,7 @@ test "loadStartupState applies core env overrides" { try std.testing.expectEqual(@as(usize, 37), state.agent_step_limit); } -test "loadStartupState defaults fast mode on only for the compiled Gateway default and preserves explicit preferences" { +test "loadStartupState defaults fast mode on only for the compiled Gateway default and requires bound explicit preferences" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -2021,6 +2056,8 @@ test "loadStartupState defaults fast mode on only for the compiled Gateway defau try tmp.dir.createDirPath(io_mod.getIo(), "absent"); try tmp.dir.createDirPath(io_mod.getIo(), "configured"); try tmp.dir.createDirPath(io_mod.getIo(), "disabled"); + try tmp.dir.createDirPath(io_mod.getIo(), "legacy-fast"); + try tmp.dir.createDirPath(io_mod.getIo(), "bound-fast"); try tmp.dir.createDirPath(io_mod.getIo(), "codex"); const home_root = try io_mod.dirRealpathAlloc(std.testing.allocator, tmp.dir, "home"); @@ -2031,13 +2068,17 @@ test "loadStartupState defaults fast mode on only for the compiled Gateway defau defer std.testing.allocator.free(configured_root); const disabled_root = try io_mod.dirRealpathAlloc(std.testing.allocator, tmp.dir, "disabled"); defer std.testing.allocator.free(disabled_root); + const legacy_fast_root = try io_mod.dirRealpathAlloc(std.testing.allocator, tmp.dir, "legacy-fast"); + defer std.testing.allocator.free(legacy_fast_root); + const bound_fast_root = try io_mod.dirRealpathAlloc(std.testing.allocator, tmp.dir, "bound-fast"); + defer std.testing.allocator.free(bound_fast_root); const codex_root = try io_mod.dirRealpathAlloc(std.testing.allocator, tmp.dir, "codex"); defer std.testing.allocator.free(codex_root); const fixture = try std.fmt.allocPrint( std.testing.allocator, - "{{\"workspaces\":{{\"{s}\":{{\"model\":\"openai/gpt-5\"}},\"{s}\":{{\"fast_mode\":false}},\"{s}\":{{\"provider\":\"codex\",\"codex_model\":\"gpt-5.4-mini\"}}}}}}\n", - .{ configured_root, disabled_root, codex_root }, + "{{\"workspaces\":{{\"{s}\":{{\"model\":\"openai/gpt-5\"}},\"{s}\":{{\"fast_mode\":false}},\"{s}\":{{\"model\":\"zai/glm-5.3\",\"fast_mode\":true}},\"{s}\":{{\"model\":\"provider/fast-toggle\",\"fast_mode\":true,\"fast_mode_model_bound\":true}},\"{s}\":{{\"provider\":\"codex\",\"codex_model\":\"gpt-5.4-mini\"}}}}}}\n", + .{ configured_root, disabled_root, legacy_fast_root, bound_fast_root, codex_root }, ); defer std.testing.allocator.free(fixture); try writeFixtureFile(tmp.dir, "home/.fx/settings.json", fixture); @@ -2050,16 +2091,31 @@ test "loadStartupState defaults fast mode on only for the compiled Gateway defau try std.testing.expectEqualStrings("zai/glm-5.2", absent.selected_model); try std.testing.expectEqualStrings("zai/glm-5.2", absent.configured_model); try std.testing.expect(absent.fast_mode); + try std.testing.expect(absent.fast_mode_model_bound); var configured = try loadStartupStateForWorkspace(std.testing.allocator, configured_root, "zai/glm-5.2", 25); defer configured.deinit(std.testing.allocator); try std.testing.expectEqualStrings("openai/gpt-5", configured.selected_model); try std.testing.expectEqualStrings("openai/gpt-5", configured.configured_model); try std.testing.expect(!configured.fast_mode); + try std.testing.expect(!configured.fast_mode_model_bound); var disabled = try loadStartupStateForWorkspace(std.testing.allocator, disabled_root, "zai/glm-5.2", 25); defer disabled.deinit(std.testing.allocator); try std.testing.expect(!disabled.fast_mode); + try std.testing.expect(!disabled.fast_mode_model_bound); + + var legacy_fast = try loadStartupStateForWorkspace(std.testing.allocator, legacy_fast_root, "zai/glm-5.2", 25); + defer legacy_fast.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("zai/glm-5.3", legacy_fast.selected_model); + try std.testing.expect(legacy_fast.fast_mode); + try std.testing.expect(!legacy_fast.fast_mode_model_bound); + + var bound_fast = try loadStartupStateForWorkspace(std.testing.allocator, bound_fast_root, "zai/glm-5.2", 25); + defer bound_fast.deinit(std.testing.allocator); + try std.testing.expectEqualStrings("provider/fast-toggle", bound_fast.selected_model); + try std.testing.expect(bound_fast.fast_mode); + try std.testing.expect(bound_fast.fast_mode_model_bound); var codex = try loadStartupStateForWorkspace(std.testing.allocator, codex_root, "zai/glm-5.2", 25); defer codex.deinit(std.testing.allocator); diff --git a/src/core/app/app_render_runtime.zig b/src/core/app/app_render_runtime.zig index 9345e920e..e058053b1 100644 --- a/src/core/app/app_render_runtime.zig +++ b/src/core/app/app_render_runtime.zig @@ -606,12 +606,16 @@ pub fn Runtime(comptime App: type) type { app.effort else .auto; + const active_fast_mode_model_bound = if (comptime @hasDecl(App, "fastModeModelBound")) + app.fastModeModelBound() + else + true; const fast_indicator_active = if (pending_model != null) visible_capabilities.intrinsic_fast or (model_supports_fast and pendingPickerFastMode(model_query, app.input_runtime.picker.model_picker_fast_index)) else visible_capabilities.intrinsic_fast or - ((model_supports_fast or active_capabilities_pending) and app.fast_mode); + (app.fast_mode and active_fast_mode_model_bound); const upgrade_label = app.upgrader.statusLabel(upgrade_status_buf); const yolo_warning_active = @@ -4854,7 +4858,7 @@ test "core.app_render_runtime keeps Kimi fast indicator stable across catalog hy } } -test "core.app_render_runtime hides stale fast preference for unsupported model" { +test "core.app_render_runtime keeps a bound fast preference stable after catalog hydration" { var app = CoordinatorTestApp{ .alloc = std.testing.allocator, .shell = .{}, @@ -4873,7 +4877,7 @@ test "core.app_render_runtime hides stale fast preference for unsupported model" &queued_cards, ); - try std.testing.expect(!ctx.fast_indicator_active); + try std.testing.expect(ctx.fast_indicator_active); } test "core.app_render_runtime projects only the visible inline completion suffix" { diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 39a19e729..85d8ba7e8 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -1077,6 +1077,7 @@ pub const Persistence = struct { subagent_host: ?*subagent_tool_host.Runtime = null, workspace_preferences: ?session_codec.DurableSessionPreferences = null, session_preferences: ?session_codec.DurableSessionPreferences = null, + fast_mode_model_bound: bool = false, js_host_store: JsHostSessionStore = .{}, js_host_session: ?JsHostSessionOwner = null, process_model_override: ?[]u8 = null, @@ -1095,7 +1096,7 @@ pub const Persistence = struct { /// in a static release-binary template. pub fn initInto(storage: *Persistence) void { comptime { - if (std.meta.fields(Persistence).len != 19) { + if (std.meta.fields(Persistence).len != 20) { @compileError("update Persistence.initInto for the changed field set"); } } @@ -1106,6 +1107,7 @@ pub const Persistence = struct { storage.subagent_host = null; storage.workspace_preferences = null; storage.session_preferences = null; + storage.fast_mode_model_bound = false; storage.js_host_store = .{}; storage.js_host_session = null; storage.process_model_override = null; @@ -1158,6 +1160,7 @@ test "persistence in-place initialization preserves empty ownership" { try std.testing.expect(persistence.store == null); try std.testing.expect(persistence.writable == null); try std.testing.expect(persistence.subagent_host == null); + try std.testing.expect(!persistence.fast_mode_model_bound); try std.testing.expect(!persistence.session_picker.active); try std.testing.expect(persistence.session_picker_load.task == null); try std.testing.expect(!persistence.session_picker_current_cache.ready); @@ -1337,6 +1340,7 @@ pub fn Runtime(comptime App: type) type { selected_model: []const u8, effort: types.ReasoningEffort, fast_mode: bool, + fast_mode_model_bound: bool, ) !void { try replacePreferences( app.alloc, @@ -1353,6 +1357,7 @@ pub fn Runtime(comptime App: type) type { &app.session_persistence.session_preferences, app.session_persistence.workspace_preferences.?, ); + app.session_persistence.fast_mode_model_bound = fast_mode_model_bound; if (app.session_persistence.process_model_override) |model| { app.alloc.free(model); app.session_persistence.process_model_override = null; @@ -2781,6 +2786,10 @@ pub fn Runtime(comptime App: type) type { applySessionPreferencePatch(app, patch) catch |err| { result.session_error = err; }; + if (patch.model != null or patch.fast_mode != null) { + app.session_persistence.fast_mode_model_bound = + patch.model != null and patch.fast_mode != null; + } var settings_attempt = config_runtime.attemptUserPreferences( app.alloc, @@ -4919,10 +4928,15 @@ pub fn Runtime(comptime App: type) type { ); app.effort = preferences.effort; app.fast_mode = preferences.fast_mode; + app.session_persistence.fast_mode_model_bound = true; app.worker.syncQueuedPromptEffort(preferences.effort); app.worker.syncQueuedPromptFastMode(preferences.fast_mode); } + pub fn fastModeModelBound(app: *const App) bool { + return app.session_persistence.fast_mode_model_bound; + } + fn applySessionPreferencePatch( app: *App, patch: SessionPreferencePatch, @@ -5649,6 +5663,7 @@ test "js-host resume restores transcript context preferences usage and revision" "startup/model", .auto, false, + true, ); app.session_persistence.js_host_store = fake.store(); app.requested_resume = .last; @@ -5705,6 +5720,7 @@ test "js-host resume store failures and missing records fall back to fresh sessi "fresh/model", .auto, false, + true, ); app.session_persistence.js_host_store = fake.store(); app.requested_resume = .last; @@ -5734,6 +5750,7 @@ test "js-host picker request stays unsupported and starts fresh" { "fresh/model", .auto, false, + true, ); app.session_persistence.js_host_store = fake.store(); app.requested_resume = .pick; @@ -5759,6 +5776,7 @@ test "js-host completed and interrupted turns propagate revisions preserve owner "fresh/model", .auto, false, + true, ); app.session_persistence.js_host_store = fake.store(); try Runtime(TestApp).beginFreshJsHostSession(&app); @@ -5826,6 +5844,7 @@ test "js-host preference changes snapshot the updated session preferences" { "fresh/model", .auto, false, + true, ); app.session_persistence.js_host_store = fake.store(); try Runtime(TestApp).beginFreshJsHostSession(&app); @@ -5921,6 +5940,7 @@ fn configureTestPreferences(app: *TestApp) !void { "configured/model", types.ReasoningEffort.literal("high"), true, + true, ); } @@ -7551,6 +7571,7 @@ test "upgrade resume restores active session with the installed version notice" "env/model", types.ReasoningEffort.literal("high"), true, + false, ); try Runtime(TestApp).initializePersistence(&app, true); var calls = [_]types.ToolCall{.{ @@ -9008,6 +9029,7 @@ test "fresh interactive session retains one writable schema-v3 handle" { "configured/model", types.ReasoningEffort.literal("high"), true, + true, ); try Runtime(TestApp).initializePersistence(&app, true); try Runtime(TestApp).beginFreshPersistedSession(&app); diff --git a/src/core/config/config_runtime.zig b/src/core/config/config_runtime.zig index 0f9753637..4fac6c177 100644 --- a/src/core/config/config_runtime.zig +++ b/src/core/config/config_runtime.zig @@ -48,6 +48,7 @@ pub const Settings = struct { first_call_tool_choice: ?types.ToolChoice = null, context: ?bool = null, fast_mode: ?bool = null, + fast_mode_model_bound: ?bool = null, slash_menu_categories: ?bool = null, collapse_tool_calls: ?bool = null, auto_upgrade: ?bool = null, @@ -115,6 +116,7 @@ pub const ConfigSources = struct { permission_mode: ConfigSource = .compiled_default, effort: ConfigSource = .compiled_default, fast_mode: ConfigSource = .compiled_default, + fast_mode_model_bound: ConfigSource = .compiled_default, slash_menu_categories: ConfigSource = .compiled_default, collapse_tool_calls: ConfigSource = .compiled_default, startup_scrollback: ConfigSource = .compiled_default, @@ -574,6 +576,7 @@ fn hasLegacyWorkspacePreferences(root: std.json.Value) bool { "model", "effort", "fast_mode", + "fast_mode_model_bound", "slash_menu_categories", "collapse_tool_calls", "startup_scrollback", @@ -604,6 +607,7 @@ fn isProfileOnlySettingKey(key: []const u8) bool { "grok_model", "effort", "fast_mode", + "fast_mode_model_bound", "slash_menu_categories", "collapse_tool_calls", "startup_scrollback", @@ -651,6 +655,7 @@ fn updateConfigSources(sources: *ConfigSources, settings: Settings, source: Conf if (settings.permission_mode != null) sources.permission_mode = source; if (settings.effort != null) sources.effort = source; if (settings.fast_mode != null) sources.fast_mode = source; + if (settings.fast_mode_model_bound != null) sources.fast_mode_model_bound = source; if (settings.slash_menu_categories != null) sources.slash_menu_categories = source; if (settings.collapse_tool_calls != null) sources.collapse_tool_calls = source; if (settings.startup_scrollback != null) sources.startup_scrollback = source; @@ -1418,6 +1423,11 @@ fn parseProfileOnlyFields( settings.fast_mode = value.bool; } + if (root.object.get("fast_mode_model_bound")) |bound_value| { + if (bound_value != .bool) return error.InvalidFastModeBindingType; + settings.fast_mode_model_bound = bound_value.bool; + } + if (root.object.get("slash_menu_categories")) |slash_menu_categories_value| { const value = slash_menu_categories_value; if (value != .bool) return error.InvalidSlashMenuCategoriesType; @@ -1546,6 +1556,7 @@ fn mergeSettings(target: *Settings, incoming: *Settings, alloc: Allocator) void if (incoming.first_call_tool_choice) |value| target.first_call_tool_choice = value; if (incoming.context) |value| target.context = value; if (incoming.fast_mode) |value| target.fast_mode = value; + if (incoming.fast_mode_model_bound) |value| target.fast_mode_model_bound = value; if (incoming.slash_menu_categories) |value| target.slash_menu_categories = value; if (incoming.collapse_tool_calls) |value| target.collapse_tool_calls = value; if (incoming.auto_upgrade) |value| target.auto_upgrade = value; diff --git a/src/core/config/settings_store.zig b/src/core/config/settings_store.zig index 5cb8e756a..2b15c4ddc 100644 --- a/src/core/config/settings_store.zig +++ b/src/core/config/settings_store.zig @@ -956,6 +956,31 @@ test "provider patch writes one bounded provider model collection" { try std.testing.expectEqual(model_provider.ProviderId.codex, model_provider.parse(root.object.get("provider").?.string).?); } +test "model and fast patch binds the fast preference atomically" { + const alloc = std.testing.allocator; + var arena = std.heap.ArenaAllocator.init(alloc); + defer arena.deinit(); + + var root = try std.json.parseFromSliceLeaky( + std.json.Value, + arena.allocator(), + "{\"models\":{\"gateway\":\"provider/old\"},\"fast_mode\":true}", + .{}, + ); + _ = try applyUserPatchToRoot(arena.allocator(), &root, .{ + .model_preference = .{ .provider = .gateway, .model = "provider/fast-toggle" }, + .fast_mode = true, + }); + const binding = root.object.get("fast_mode_model_bound"); + try std.testing.expect(binding != null); + try std.testing.expect(binding.?.bool); + + _ = try applyUserPatchToRoot(arena.allocator(), &root, .{ + .model_preference = .{ .provider = .gateway, .model = "provider/default" }, + }); + try std.testing.expect(!root.object.contains("fast_mode_model_bound")); +} + fn applyMutationToRoot( arena: Allocator, root: *std.json.Value, @@ -995,6 +1020,12 @@ fn applyUserPatchToRoot( if (patch.yolo_acknowledged) |value| application.changed = try putBool(arena, &root.object, "yolo_acknowledged", value) or application.changed; if (patch.effort) |value| application.changed = try putString(arena, &root.object, "effort", value.label()) or application.changed; if (patch.fast_mode) |value| application.changed = try putBool(arena, &root.object, "fast_mode", value) or application.changed; + if (patch.model_preference != null and patch.fast_mode != null) { + application.changed = try putBool(arena, &root.object, "fast_mode_model_bound", true) or application.changed; + } else if ((patch.model_preference != null or patch.fast_mode != null) and root.object.contains("fast_mode_model_bound")) { + _ = root.object.orderedRemove("fast_mode_model_bound"); + application.changed = true; + } if (patch.slash_menu_categories) |value| application.changed = try putBool(arena, &root.object, "slash_menu_categories", value) or application.changed; if (patch.collapse_tool_calls) |value| application.changed = try putBool(arena, &root.object, "collapse_tool_calls", value) or application.changed; if (patch.update_channel) |value| application.changed = try putString(arena, &root.object, "update_channel", value.label()) or application.changed; @@ -1115,6 +1146,12 @@ fn cleanupLegacyWorkspacePreferences( patch.fast_mode != null, application, ); + if ((patch.model_preference != null or patch.fast_mode != null) and + entry.value_ptr.object.contains("fast_mode_model_bound")) + { + _ = entry.value_ptr.object.orderedRemove("fast_mode_model_bound"); + application.legacy_fields_removed += 1; + } removeLegacyLeaf( &entry.value_ptr.object, "slash_menu_categories", diff --git a/src/core/session/session_commands.zig b/src/core/session/session_commands.zig index cfc25b94d..1cd195d45 100644 --- a/src/core/session/session_commands.zig +++ b/src/core/session/session_commands.zig @@ -723,7 +723,11 @@ pub fn Commands(comptime App: type) type { if (persist) { try persistPreferenceTargets( app, - .{ .fast_mode = app.fast_mode }, + .{ + .provider = provider_runtime.provider(app), + .model = provider_runtime.model(app), + .fast_mode = app.fast_mode, + }, "fast", !announce, ); @@ -767,19 +771,15 @@ pub fn Commands(comptime App: type) type { .model = model, }; const capabilities = model_capabilities.resolveForApp(App, app, model); - if (capabilities.reasoning_efforts.len == 0) { - if (capabilities.supports_fast_mode) { - try applyFastMode(app, fast_mode, false, false); - patch.fast_mode = fast_mode; - } - } else { + if (capabilities.reasoning_efforts.len > 0) { try applyEffort(app, effort, false, false); patch.effort = effort; - if (capabilities.supports_fast_mode) { - try applyFastMode(app, fast_mode, false, false); - patch.fast_mode = fast_mode; - } } + const selected_fast_mode = capabilities.supports_fast_mode and fast_mode; + if (selected_fast_mode != app.fast_mode) { + try applyFastMode(app, selected_fast_mode, false, false); + } + patch.fast_mode = selected_fast_mode; try persistPreferenceTargets(app, patch, "model picker", false); } @@ -1035,12 +1035,17 @@ pub fn Commands(comptime App: type) type { } fn setResolvedModel(app: *App, resolved: []const u8, announce: bool) !void { + const model_changed = !std.mem.eql(u8, provider_runtime.model(app), resolved); try setResolvedModelRuntime(app, resolved, announce); + if (model_changed and app.fast_mode) { + try applyFastMode(app, false, false, false); + } try persistPreferenceTargets( app, .{ .provider = provider_runtime.provider(app), .model = resolved, + .fast_mode = app.fast_mode, }, "model", !announce, @@ -2768,7 +2773,29 @@ test "session_commands selectModelFromPicker persists portable Gateway reasoning try std.testing.expectEqual(@as(?types.ReasoningEffort, types.ReasoningEffort.literal("low")), app.worker.synced_effort); try std.testing.expectEqual(@as(usize, 1), app.worker.effort_sync_count); try std.testing.expectEqual(types.ReasoningEffort.literal("low"), app.last_preference_effort.?); - try std.testing.expect(app.last_preference_fast_mode == null); + try std.testing.expectEqual(false, app.last_preference_fast_mode.?); +} + +test "session_commands model selection clears fast mode when the selected model has no fast control" { + const alloc = std.testing.allocator; + var app = try FakeApp.init(alloc, "/tmp/workspace", "anthropic/claude-opus-4.6"); + defer app.deinit(); + app.fast_mode = true; + app.worker.synced_fast_mode = true; + const efforts = [_]types.ReasoningEffort{types.ReasoningEffort.literal("max")}; + app.setGatewayControls("zai/glm-5.3", &efforts, false); + + try Commands(FakeApp).selectModelFromPicker( + &app, + "zai/glm-5.3", + types.ReasoningEffort.literal("max"), + true, + ); + + try std.testing.expectEqualStrings("zai/glm-5.3", app.selected_model.items); + try std.testing.expect(!app.fast_mode); + try std.testing.expectEqual(@as(?bool, false), app.worker.synced_fast_mode); + try std.testing.expectEqual(false, app.last_preference_fast_mode.?); } test "session_commands selectModelFromPicker syncs queued fast mode and effort for supported models" { @@ -3057,7 +3084,7 @@ test "session_commands no-op model still attempts its durable targets" { ); } -test "session_commands model controls remain catalog validated" { +test "session_commands model controls remain catalog validated and clear unsupported fast state" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -3078,9 +3105,9 @@ test "session_commands model controls remain catalog validated" { try Commands(FakeApp).selectModelFromPicker(&app, "openai/gpt-4o", types.ReasoningEffort.literal("low"), false); - try std.testing.expect(app.fast_mode); - try std.testing.expectEqual(@as(usize, 0), app.worker.fast_sync_count); - try std.testing.expectEqual(@as(?bool, true), app.worker.synced_fast_mode); + try std.testing.expect(!app.fast_mode); + try std.testing.expectEqual(@as(usize, 1), app.worker.fast_sync_count); + try std.testing.expectEqual(@as(?bool, false), app.worker.synced_fast_mode); const unsupported_options = model_capabilities.resolveProviderOptionsForCapabilities( app.resolvedModelCapabilities(app.selected_model.items), app.effort, @@ -3099,7 +3126,7 @@ test "session_commands model controls remain catalog validated" { try Commands(FakeApp).selectModelFromPicker(&app, "anthropic/claude-opus-4.6", types.ReasoningEffort.literal("high"), true); try std.testing.expectEqual(@as(?bool, true), app.worker.synced_fast_mode); - try std.testing.expectEqual(@as(usize, 1), app.worker.fast_sync_count); + try std.testing.expectEqual(@as(usize, 2), app.worker.fast_sync_count); const supported_options = model_capabilities.resolveProviderOptionsForCapabilities( app.resolvedModelCapabilities(app.selected_model.items), app.effort, diff --git a/src/main.zig b/src/main.zig index b5314ce1d..e88da5fad 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2552,6 +2552,10 @@ const App = struct { return SessionAppRuntime.commitRuntimePreferences(self, patch); } + pub fn fastModeModelBound(self: *const App) bool { + return SessionAppRuntime.fastModeModelBound(self); + } + pub fn appendFinishedPrompt(self: *App, finished: types.FinishedPrompt) !void { try SessionAppRuntime.appendFinishedPrompt(self, finished); if (finished.summary) |summary| { diff --git a/tests/e2e/config-persistence.test.ts b/tests/e2e/config-persistence.test.ts index 403b36e29..29534668b 100644 --- a/tests/e2e/config-persistence.test.ts +++ b/tests/e2e/config-persistence.test.ts @@ -235,6 +235,7 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { expect(stored.permission_mode).toBe("auto"); expect(stored.effort).toBe("auto"); expect(stored.fast_mode).toBe(true); + expect(stored.fast_mode_model_bound).toBe(true); expect(stored.startup_scrollback).toBe(false); expect(stored.prompt_history).toMatchObject({ enabled: false }); expect(stored.statusLine).toMatchObject({ @@ -606,13 +607,14 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { ); test( - "Kimi Fast indicator remains stable while model catalog resolves", + "Fast indicator remains stable while model catalog resolves", async () => { const cases = [ { label: "normal", model: "moonshotai/kimi-k3", fastMode: false, + modelBound: true, supportsFastMode: true, expectedFastIndicator: false, }, @@ -620,6 +622,7 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { label: "toggle", model: "moonshotai/kimi-k3", fastMode: true, + modelBound: true, supportsFastMode: true, expectedFastIndicator: true, }, @@ -627,9 +630,18 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { label: "intrinsic", model: "moonshotai/kimi-k3-fast", fastMode: false, + modelBound: true, supportsFastMode: false, expectedFastIndicator: true, }, + { + label: "legacy-unbound", + model: "zai/glm-5.3", + fastMode: true, + modelBound: false, + supportsFastMode: false, + expectedFastIndicator: false, + }, ] as const; for (const testCase of cases) { @@ -664,6 +676,7 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { model: testCase.model, permission_mode: "auto", fast_mode: testCase.fastMode, + fast_mode_model_bound: testCase.modelBound, }) + "\n", { mode: 0o600 }, ); @@ -1172,7 +1185,8 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { models: { gateway: "anthropic/claude-fable-5" }, effort: "xhigh", }); - expect(stored).not.toHaveProperty("fast_mode"); + expect(stored.fast_mode).toBe(false); + expect(stored.fast_mode_model_bound).toBe(true); expect(readFileSync(stderrPath, "utf8")).toBe(""); } finally { gateway.stop(); @@ -1234,7 +1248,8 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { const stored = JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")); expect(stored.models.gateway).toBe("xai/grok-build-1"); expect(stored).not.toHaveProperty("effort"); - expect(stored).not.toHaveProperty("fast_mode"); + expect(stored.fast_mode).toBe(false); + expect(stored.fast_mode_model_bound).toBe(true); const scrollback = await session.captureFullScrollbackEscapes(); expect(scrollback).toContain("grok-build-1"); @@ -1313,7 +1328,8 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { let stored = JSON.parse(readFileSync(join(home, ".fx", "settings.json"), "utf8")); expect(stored.models.gateway).toBe("provider/new-reasoning-model"); - expect(stored).not.toHaveProperty("fast_mode"); + expect(stored.fast_mode).toBe(false); + expect(stored.fast_mode_model_bound).toBe(true); await session.sendText("Use portable auto."); await session.waitForText("portable auto complete", TIMEOUT); @@ -1374,7 +1390,8 @@ describe.skipIf(!tmuxAvailable())("config persistence", () => { models: { gateway: "provider/new-reasoning-model" }, effort: "future-tier", }); - expect(stored).not.toHaveProperty("fast_mode"); + expect(stored.fast_mode).toBe(false); + expect(stored.fast_mode_model_bound).toBe(true); expect(readFileSync(stderrPath, "utf8")).toBe(""); } finally { gateway.stop(); From 58f6acc3c2e9d50f77cf4452fd9b28b8c0725f50 Mon Sep 17 00:00:00 2001 From: Pranit Date: Tue, 1 Sep 2026 18:01:36 -0400 Subject: [PATCH 5/5] Keep resumed fast indicators model-bound Preserve the startup binding only when resumed provider, model, and fast preferences match the current bound selection. --- src/core/app/app_session_runtime.zig | 48 +++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/src/core/app/app_session_runtime.zig b/src/core/app/app_session_runtime.zig index 85d8ba7e8..b02cfdefe 100644 --- a/src/core/app/app_session_runtime.zig +++ b/src/core/app/app_session_runtime.zig @@ -4918,6 +4918,11 @@ pub fn Runtime(comptime App: type) type { app: *App, preferences: session_codec.DurableSessionPreferences, ) !void { + const fast_mode_model_bound = restoredFastModeModelBound( + app.session_persistence.fast_mode_model_bound, + app.session_persistence.workspace_preferences, + preferences, + ); try provider_runtime.replaceSelection(app, preferences.provider, preferences.model); if (app.session_persistence.process_model_override) |model| { try provider_runtime.replaceModel(app, model); @@ -4928,7 +4933,7 @@ pub fn Runtime(comptime App: type) type { ); app.effort = preferences.effort; app.fast_mode = preferences.fast_mode; - app.session_persistence.fast_mode_model_bound = true; + app.session_persistence.fast_mode_model_bound = fast_mode_model_bound; app.worker.syncQueuedPromptEffort(preferences.effort); app.worker.syncQueuedPromptFastMode(preferences.fast_mode); } @@ -4991,6 +4996,46 @@ fn replacePreferences( target.* = replacement; } +fn restoredFastModeModelBound( + current_bound: bool, + configured: ?session_codec.DurableSessionPreferences, + restored: session_codec.DurableSessionPreferences, +) bool { + if (!current_bound) return false; + const current = configured orelse return false; + return current.provider == restored.provider and + std.mem.eql(u8, current.model, restored.model) and + current.fast_mode == restored.fast_mode; +} + +test "restored fast mode remains bound only for the configured model selection" { + const configured = session_codec.DurableSessionPreferences{ + .model = @constCast("provider/model"), + .effort = .auto, + .fast_mode = true, + }; + const matching = session_codec.DurableSessionPreferences{ + .model = @constCast("provider/model"), + .effort = types.ReasoningEffort.literal("high"), + .fast_mode = true, + }; + const different_model = session_codec.DurableSessionPreferences{ + .model = @constCast("provider/other"), + .effort = .auto, + .fast_mode = true, + }; + const different_fast_mode = session_codec.DurableSessionPreferences{ + .model = @constCast("provider/model"), + .effort = .auto, + .fast_mode = false, + }; + + try std.testing.expect(restoredFastModeModelBound(true, configured, matching)); + try std.testing.expect(!restoredFastModeModelBound(false, configured, matching)); + try std.testing.expect(!restoredFastModeModelBound(true, configured, different_model)); + try std.testing.expect(!restoredFastModeModelBound(true, configured, different_fast_mode)); +} + fn applyPreferencePatch( alloc: Allocator, target: *?session_codec.DurableSessionPreferences, @@ -5678,6 +5723,7 @@ test "js-host resume restores transcript context preferences usage and revision" try std.testing.expectEqualStrings("restored/model", app.selected_model.items); try std.testing.expectEqual(types.ReasoningEffort.literal("high"), app.effort); try std.testing.expect(app.fast_mode); + try std.testing.expect(!Runtime(TestApp).fastModeModelBound(&app)); try std.testing.expectEqual(@as(u64, 17), app.total_input_tokens); try std.testing.expectEqual(@as(u64, 23), app.total_output_tokens); var restored_usage = try app.session.usage.snapshot(alloc);