diff --git a/GraphcodeKit/Sources/Domain/BackendCommand.swift b/GraphcodeKit/Sources/Domain/BackendCommand.swift index cdd94965..ab4b7f18 100644 --- a/GraphcodeKit/Sources/Domain/BackendCommand.swift +++ b/GraphcodeKit/Sources/Domain/BackendCommand.swift @@ -21,6 +21,15 @@ extension CLISessionBackendKind { } } + public var supportsVersionPreference: Bool { self == .copilotCLI } + + public func versionArguments(_ settings: GraphcodeSettings) -> [String] { + guard supportsVersionPreference, let version = settings.normalizedCopilotPreferredVersion else { + return [] + } + return ["--prefer-version", version] + } + /// The model each tier maps to for this backend. /// /// Deliberately per-backend rather than one shared alias list: Claude Code takes short @@ -92,7 +101,7 @@ extension CLISessionBackendKind { sessionsDirectory: String? = nil ) -> [String] { let model = - modelArguments(for: tier) + permissionArguments(settings) + versionArguments(settings) + modelArguments(for: tier) + permissionArguments(settings) + presenceArguments( hooksFile: hooksFile, sessionName: sessionName, zmxPath: zmxPath, sessionsDirectory: sessionsDirectory) diff --git a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift index d690893b..a6f1bbb5 100644 --- a/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift +++ b/GraphcodeKit/Sources/Domain/GraphcodeSettings.swift @@ -289,6 +289,21 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { } public var claudePermissionMode: ClaudePermissionMode public var copilotPermissions: CopilotPermissions + /// Empty leaves Copilot's version selection unchanged. Applies to future launches, + /// including resumes, not to sessions already running. + public var copilotPreferredVersion: String + + public var normalizedCopilotPreferredVersion: String? { + let version = copilotPreferredVersion.trimmingCharacters(in: .whitespacesAndNewlines) + return version.isEmpty ? nil : version + } + + /// Installation remains an explicit action on the machine that runs Copilot. + public var copilotInstallCommand: String? { + guard let version = normalizedCopilotPreferredVersion else { return nil } + return "npm install -g " + PresenceHooks.singleQuoted("@github/copilot@\(version)") + } + /// Whether a session is told it's part of a graph and how to add loops to it /// (`SessionBriefing`). Off means loops behave exactly as they did before briefings /// existed — they do the work they were given and never create anything. @@ -441,6 +456,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { piProjectTrust: PiProjectTrust = .approve, claudePermissionMode: ClaudePermissionMode = .auto, copilotPermissions: CopilotPermissions = .allowEverything, + copilotPreferredVersion: String = "", briefsSessionsAboutTheGraph: Bool = true, autoSelectsModel: Bool = false, showsActivityStrip: Bool = false, @@ -460,6 +476,7 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { self.piProjectTrust = piProjectTrust self.claudePermissionMode = claudePermissionMode self.copilotPermissions = copilotPermissions + self.copilotPreferredVersion = copilotPreferredVersion self.briefsSessionsAboutTheGraph = briefsSessionsAboutTheGraph self.autoSelectsModel = autoSelectsModel self.showsActivityStrip = showsActivityStrip @@ -494,6 +511,8 @@ public struct GraphcodeSettings: Codable, Equatable, Sendable { copilotPermissions = try container.decodeIfPresent(CopilotPermissions.self, forKey: .copilotPermissions) ?? .allowEverything + copilotPreferredVersion = + try container.decodeIfPresent(String.self, forKey: .copilotPreferredVersion) ?? "" briefsSessionsAboutTheGraph = try container.decodeIfPresent(Bool.self, forKey: .briefsSessionsAboutTheGraph) ?? true endsResolvedSessionsAfterMinutes = diff --git a/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift b/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift index a96bd7e4..65e8276c 100644 --- a/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift +++ b/GraphcodeKit/Sources/Sessions/SummaryModelWriter.swift @@ -73,7 +73,8 @@ public enum SummaryModelWriter { /// The prompt is an argv element, never a shell string: it carries the agent's own /// sentence, which is arbitrary text from a model. public static func invocation( - forBackend backend: CLISessionBackendKind, prompt: String, tier: ModelTier = .fast + forBackend backend: CLISessionBackendKind, prompt: String, tier: ModelTier = .fast, + settings: GraphcodeSettings = GraphcodeSettingsStore.load() ) -> [String] { let model = backend.modelArguments(for: tier) switch backend { @@ -82,7 +83,7 @@ public enum SummaryModelWriter { case .copilotCLI: // No `--allow-all`: this asks for a sentence, and a summariser that can run tools is // a summariser that can change the repository it is describing. - return ["copilot", "-p", prompt] + model + return ["copilot"] + backend.versionArguments(settings) + ["-p", prompt] + model case .codex: return ["codex", "exec", prompt] + model case .openCode: @@ -117,9 +118,10 @@ public enum SummaryModelWriter { /// The beat, rewritten — or the beat unchanged, which is every failure path. public static func rewrite( - _ beat: SummaryBeat, backend: CLISessionBackendKind, workingDirectory: String? + _ beat: SummaryBeat, backend: CLISessionBackendKind, workingDirectory: String?, + settings: GraphcodeSettings = GraphcodeSettingsStore.load() ) async -> SummaryBeat { - let invocation = invocation(forBackend: backend, prompt: prompt(beat: beat)) + let invocation = invocation(forBackend: backend, prompt: prompt(beat: beat), settings: settings) // Through the launcher's login shell, not `Process`'s own launch. `Process` resolves // `executableURL` as a path and never searches `PATH`, so the bare `claude` above named // a file in the working directory: every rewrite on every backend threw at launch, and @@ -173,7 +175,8 @@ public enum SummaryModelWriter { let rewritten = await rewrite( newest, backend: node.backend, workingDirectory: ZmxSessionLauncher.workingDirectory( - forNode: node, projectPath: projectPath)) + forNode: node, projectPath: projectPath), + settings: settings) return reading.replacingNewestBeat(with: rewritten) } } diff --git a/README.md b/README.md index 9b5ed765..4e3cbabc 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,38 @@ Releases are Developer ID signed and notarized. State lives in `~/.graphcode/` — graphs, recents, layouts, the daemon socket and logs, and the installed binaries. **Nothing is ever written inside a project folder you open.** +### Use a known-good Copilot version + +If a Copilot CLI update is broken, first install a known-good published version on each +machine where Copilot runs, including remote hosts, and verify the reported version. +For example: + +```sh +npm install -g @github/copilot@v1.0.84-5 && +copilot --prefer-version 1.0.84-5 --version +``` + +Only after installation succeeds and the reported version matches, set +**Settings > Preferred versions > Copilot CLI** to **Specific version** and enter that version. +The section follows **Permissions** and has one row per backend, all starting at **Default**. +Claude Code, Codex, OpenCode, and Pi are locked to **Default** until version overrides are +supported for them. This does not change which backend new loops or Quick Chats use. +The Copilot field accepts any published version; `1.0.84-5` is just an example. GraphCode passes +`--prefer-version ` to new and resumed Copilot sessions (app and daemon, local and +SSH), and to Copilot title and summary requests. Running sessions are not interrupted. + +Settings provides a copyable install command for the chosen version; GraphCode does not run +it automatically or verify installation before activating the preference. The preference +takes effect immediately for subsequent launches. Keep `copilot` on the login shell's `PATH`. +Without the app, set +`"copilotPreferredVersion": "1.0.84-5"` in `~/.graphcode/settings.json` (or the workspace's +`GRAPHCODE_SUPPORT_DIR/settings.json`), preserving the other keys. The setting is read on +each launch, so no daemon restart is needed. + +Choose **Default**, clear the field, or remove the JSON key to stop passing `--prefer-version`. +**Default** follows the CLI's own version selection; it does not install the latest release. If you also +downgraded the global npm installation, run `npm install -g @github/copilot@latest` to update it. + ## Workspaces **File ▸ Workspace ▸ New Workspace…** opens a second GraphCode with projects, loops and terminal diff --git a/graphcode/Sources/Clients/TitleSuggestionClient.swift b/graphcode/Sources/Clients/TitleSuggestionClient.swift index e9ceee89..5ebe116e 100644 --- a/graphcode/Sources/Clients/TitleSuggestionClient.swift +++ b/graphcode/Sources/Clients/TitleSuggestionClient.swift @@ -79,11 +79,19 @@ extension TitleSuggestionClient: DependencyKey { /// `-i` as well as `-l` for the reason every other launch site gives (see /// `GhosttyTerminalView.agentCommand`): the agent's `PATH` usually comes from /// `~/.zshrc`, which zsh reads only when interactive. - static func invocation(for backend: CLISessionBackendKind) -> [String]? { + static func invocation( + for backend: CLISessionBackendKind, + settings: GraphcodeSettings = GraphcodeSettingsStore.load() + ) -> [String]? { let command: String switch backend { case .claudeCode: command = "exec claude -p \"$\(promptVariable)\"" - case .copilotCLI: command = "exec copilot -p \"$\(promptVariable)\"" + case .copilotCLI: + let prefix = + (["exec", "copilot"] + + backend.versionArguments(settings).map(PresenceHooks.singleQuoted)) + .joined(separator: " ") + command = "\(prefix) -p \"$\(promptVariable)\"" // This is a separate headless process, so it does not inherit the permission flags // from the loop session. Without Codex's unattended flag it can stop at an approval // prompt and the new loop remains named "NewNode" forever. diff --git a/graphcode/Sources/Features/Settings/PreferredVersionsSettingsSection.swift b/graphcode/Sources/Features/Settings/PreferredVersionsSettingsSection.swift new file mode 100644 index 00000000..800ad4f3 --- /dev/null +++ b/graphcode/Sources/Features/Settings/PreferredVersionsSettingsSection.swift @@ -0,0 +1,75 @@ +import GraphcodeKit +import SwiftUI + +struct PreferredVersionsSettingsSection: View { + @Binding var settings: GraphcodeSettings + @State private var isEnteringCopilotVersion = false + + var body: some View { + Section { + ForEach(CLISessionBackendKind.offerableAsDefault, id: \.self) { backend in + Picker( + backend.displayName, + selection: backend.supportsVersionPreference ? copilotSelection : .constant(false) + ) { + Text("Default").tag(false) + if backend.supportsVersionPreference { + Text("Specific version").tag(true) + } + } + .disabled(!backend.supportsVersionPreference) + + if backend == .copilotCLI, copilotSelection.wrappedValue { + copilotVersionEditor + } + } + } header: { + Text("Preferred versions") + } footer: { + Text( + "Default leaves version selection to each CLI; it does not install the latest release. " + + "Only Copilot currently supports an override. These settings do not change " + + "which backend new loops or chats use." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var copilotSelection: Binding { + Binding( + get: { isEnteringCopilotVersion || settings.normalizedCopilotPreferredVersion != nil }, + set: { usesSpecificVersion in + isEnteringCopilotVersion = usesSpecificVersion + if !usesSpecificVersion { settings.copilotPreferredVersion = "" } + }) + } + + private var copilotVersionEditor: some View { + Group { + TextField("Copilot version", text: $settings.copilotPreferredVersion, prompt: Text("Version")) + Text( + "Install and verify the chosen version on each machine before setting it here. " + + "Changes apply immediately to new and resumed sessions and to title and summary " + + "requests. Running sessions are unchanged. Installation is not automatic." + ) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let command = settings.copilotInstallCommand { + HStack { + Text(command) + .font(.system(.caption, design: .monospaced)) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + Spacer() + Button("Copy") { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(command, forType: .string) + } + .help("Copy the npm install command") + } + } + } + } +} diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index 590da31f..aa84ec74 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -107,6 +107,8 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + PreferredVersionsSettingsSection(settings: $model.settings) + Section { Toggle("Pick a model for each loop", isOn: $model.settings.autoSelectsModel) } header: { diff --git a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift index 6e098a12..e3a88509 100644 --- a/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift +++ b/graphcode/Sources/Infrastructure/Ghostty/GhosttyTerminalView.swift @@ -214,7 +214,7 @@ struct GhosttyTerminalView: NSViewRepresentable { return Self.interactiveLoginShell(parts) } - /// The words every launch of this surface's agent starts from — executable, model, + /// The words every launch of this surface's agent starts from — executable, version, model, /// permissions — shared by the fresh launch above and the reboot resume /// (`resumeCommand`), so a flag every session needs cannot land in one and not the /// other. @@ -225,6 +225,7 @@ struct GhosttyTerminalView: NSViewRepresentable { let model = backend.modelArguments(for: tier).joined(separator: " ") let permissions = backend.permissionArguments(settings).joined(separator: " ") var parts = ["exec", executable] + parts += backend.versionArguments(settings).map(PresenceHooks.singleQuoted) if !model.isEmpty { parts.append(model) } if !permissions.isEmpty { parts.append(permissions) } return parts diff --git a/graphcode/Tests/CopilotVersionTests.swift b/graphcode/Tests/CopilotVersionTests.swift new file mode 100644 index 00000000..3b8f2405 --- /dev/null +++ b/graphcode/Tests/CopilotVersionTests.swift @@ -0,0 +1,213 @@ +import Foundation +import Testing + +@testable import GraphcodeKit + +@testable import graphcode + +@Suite +struct CopilotVersionTests { + private let version = "1.0.84-5" + private let location = RemoteProjectLocation( + user: "dev", host: "host", remotePath: "/workspaces/widget") + + private func settings(_ version: String) -> GraphcodeSettings { + GraphcodeSettings(copilotPreferredVersion: version, briefsSessionsAboutTheGraph: false) + } + + private func surface(_ backend: CLISessionBackendKind = .copilotCLI) -> GhosttyTerminalView { + GhosttyTerminalView( + surfaceID: UUID(), + sessionName: SurfaceRef(id: UUID(), launchesClaudeCode: true).zmxSessionName, + launchesClaudeCode: true, backend: backend, initialPrompt: "go", + workingDirectory: nil, onProcessExited: { _ in }) + } + + @Test(arguments: ["", " \t\n "]) + func blankMeansNoOverride(_ value: String) { + let settings = settings(value) + #expect(settings.normalizedCopilotPreferredVersion == nil) + #expect(settings.copilotInstallCommand == nil) + #expect(CLISessionBackendKind.copilotCLI.versionArguments(settings).isEmpty) + #expect( + !CLISessionBackendKind.copilotCLI.launchArguments( + prompt: "go", tier: .standard, settings: settings + ).contains("--prefer-version")) + #expect( + surface().agentCommand(settings: settings)?.last?.contains("--prefer-version") == false) + #expect( + SummaryModelWriter.invocation( + forBackend: .copilotCLI, prompt: "go", tier: .standard, settings: settings) + == ["copilot", "-p", "go"]) + #expect( + TitleSuggestionClient.invocation(for: .copilotCLI, settings: settings)?.last + == #"exec copilot -p "$GRAPHCODE_TITLE_PROMPT""#) + } + + @Test + func surroundingWhitespaceIsNotPartOfTheVersion() { + let settings = settings(" \t\(version)\n") + #expect(settings.normalizedCopilotPreferredVersion == version) + #expect( + CLISessionBackendKind.copilotCLI.versionArguments(settings) + == ["--prefer-version", version]) + #expect(settings.copilotInstallCommand == "npm install -g '@github/copilot@\(version)'") + } + + @Test + func nullAndMissingKeysKeepTheDefault() throws { + for json in ["{}", #"{"copilotPreferredVersion": null}"#] { + let decoded = try JSONDecoder().decode(GraphcodeSettings.self, from: Data(json.utf8)) + #expect(decoded.copilotPreferredVersion.isEmpty) + } + } + + @Test(arguments: CLISessionBackendKind.allCases) + func versionPreferencesStartAtDefaultForEveryBackend(_ backend: CLISessionBackendKind) { + #expect(backend.supportsVersionPreference == (backend == .copilotCLI)) + #expect(backend.versionArguments(GraphcodeSettings()).isEmpty) + } + + @Test(arguments: CLISessionBackendKind.offerableAsDefault) + func aVersionOverrideDoesNotSelectADifferentBackend(_ backend: CLISessionBackendKind) { + var settings = GraphcodeSettings(defaultBackend: backend) + settings.copilotPreferredVersion = version + #expect(settings.defaultBackend == backend) + settings.copilotPreferredVersion = "" + #expect(settings.defaultBackend == backend) + } + + @Test(arguments: [nil, "", "go", "/loop 1h check CI"] as [String?]) + func everyLaunchShapeGetsTheVersionBeforeOtherFlags(_ prompt: String?) { + let arguments = CLISessionBackendKind.copilotCLI.launchArguments( + prompt: prompt, tier: .fast, settings: settings(version), sessionName: "session") + #expect(Array(arguments.prefix(4)) == ["--prefer-version", version, "--model", "gpt-5.6-luna"]) + #expect(arguments.filter { $0 == "--prefer-version" }.count == 1) + #expect(arguments.contains("--yolo")) + #expect(arguments.contains("--name")) + } + + @Test(arguments: [false, true]) + func daemonLaunchAndResumeHonorThePinLocallyAndRemotely(_ remote: Bool) throws { + let node = LoopNode( + title: "Ship", loopType: .goalBased, goal: GoalSpec(summary: "tests pass"), + backend: .copilotCLI) + let projectPath = remote ? location.projectPath : nil + let settings = settings(version) + let launch = try #require( + ZmxSessionLauncher.arguments(forNode: node, projectPath: projectPath, settings: settings)) + let resume = try #require( + ZmxSessionLauncher.resumeArguments( + forNode: node, sessionID: "saved-session", projectPath: projectPath, settings: settings)) + for arguments in [launch, resume] { + let index = try #require(arguments.firstIndex(of: "--prefer-version")) + #expect(arguments[index + 1] == version) + #expect(arguments.filter { $0 == "--prefer-version" }.count == 1) + #expect(arguments.contains("--yolo")) + } + #expect(resume.suffix(2) == ["--resume", "saved-session"]) + #expect(!resume.contains("--name")) + if remote { + let command = try #require( + ZmxSessionLauncher.remoteEnsureInvocation(forNode: node, at: location, settings: settings)? + .last) + #expect(command.contains("--prefer-version")) + #expect(command.contains(version)) + } + } + + @Test(arguments: [false, true]) + func appLaunchAndResumeHonorThePinLocallyAndRemotely(_ remote: Bool) throws { + let surface = surface() + let settings = settings(version) + let launch = try #require(surface.agentCommand(settings: settings, isRemote: remote)?.last) + let resume = try #require( + surface.resumeCommand(settings: settings, remoteSettingsPath: nil, isRemote: remote)?.last) + for command in [launch, resume] { + #expect(command.hasPrefix("exec copilot '--prefer-version' '\(version)' --yolo")) + } + #expect(!resume.contains("--name")) + let resumed = try recordedArguments(resume) + #expect(resumed == ["--prefer-version", version, "--yolo", "--resume", "saved-session"]) + if remote { + let command = try #require(surface.remoteCommand(at: location, settings: settings).last) + #expect(command.contains("--prefer-version")) + #expect(command.contains(version)) + } + } + + @Test(arguments: CLISessionBackendKind.allCases.filter { !$0.supportsVersionPreference }) + func otherBackendsAreUnchanged(_ backend: CLISessionBackendKind) { + let pinned = settings(version) + let defaults = settings("") + #expect( + backend.launchArguments(prompt: "go", tier: .standard, settings: pinned) + == backend.launchArguments(prompt: "go", tier: .standard, settings: defaults)) + #expect( + surface(backend).launchPrefix(settings: pinned) + == surface(backend).launchPrefix(settings: defaults)) + #expect( + SummaryModelWriter.invocation(forBackend: backend, prompt: "go", settings: pinned) + == SummaryModelWriter.invocation(forBackend: backend, prompt: "go", settings: defaults)) + #expect( + TitleSuggestionClient.invocation(for: backend, settings: pinned) + == TitleSuggestionClient.invocation(for: backend, settings: defaults)) + } + + @Test + func headlessRequestsUseTheSamePinWithoutAddingPermissions() throws { + let settings = settings(version) + #expect( + SummaryModelWriter.invocation( + forBackend: .copilotCLI, prompt: "summarise", tier: .standard, settings: settings) + == ["copilot", "--prefer-version", version, "-p", "summarise"]) + let title = try #require( + TitleSuggestionClient.invocation(for: .copilotCLI, settings: settings)?.last) + #expect(try recordedArguments(title) == ["--prefer-version", version, "-p", "name this"]) + } + + @Test(arguments: ["1.0.84-5", "bad' version; $HOME `printf unexpected`"]) + func shellCommandsKeepTheVersionAsOneLiteralArgument(_ value: String) throws { + let settings = settings(value) + let launch = try #require(surface().agentCommand(settings: settings)?.last) + let launched = try recordedArguments(launch) + #expect(Array(launched.prefix(2)) == ["--prefer-version", value]) + let install = try #require(settings.copilotInstallCommand) + #expect( + try recordedArguments(install, executable: "npm") + == ["install", "-g", "@github/copilot@\(value)"]) + let title = try #require( + TitleSuggestionClient.invocation(for: .copilotCLI, settings: settings)?.last) + #expect(try recordedArguments(title) == ["--prefer-version", value, "-p", "name this"]) + } + + private func recordedArguments( + _ command: String, executable: String = "copilot" + ) throws -> [String] { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("copilot-version-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: directory) } + let stub = directory.appendingPathComponent(executable) + try "#!/bin/sh\nprintf '%s\\n' \"$@\"\n".write(to: stub, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: stub.path) + + let process = Process() + process.executableURL = URL(fileURLWithPath: "/bin/sh") + process.arguments = ["-c", command] + process.environment = [ + "PATH": directory.path + ":/usr/bin:/bin", + "GRAPHCODE_TRIGGER_PROMPT": "go", + "GRAPHCODE_TITLE_PROMPT": "name this", + "GRAPHCODE_RESUME_ID": "saved-session", + ] + let output = Pipe() + process.standardOutput = output + try process.run() + let data = output.fileHandleForReading.readDataToEndOfFile() + process.waitUntilExit() + #expect(process.terminationStatus == 0) + let recorded = try #require(String(data: data, encoding: .utf8)) + return recorded.split(separator: "\n").map(String.init) + } +} diff --git a/graphcode/Tests/GraphcodeSettingsTests.swift b/graphcode/Tests/GraphcodeSettingsTests.swift index ebd8944c..39fb0cd5 100644 --- a/graphcode/Tests/GraphcodeSettingsTests.swift +++ b/graphcode/Tests/GraphcodeSettingsTests.swift @@ -21,6 +21,7 @@ struct GraphcodeSettingsTests { #expect(settings.claudePermissionMode == .auto) #expect(settings.codexApprovals == .yolo) #expect(settings.copilotPermissions == .allowEverything) + #expect(settings.copilotPreferredVersion.isEmpty) #expect(settings.briefsSessionsAboutTheGraph) } @@ -30,7 +31,8 @@ struct GraphcodeSettingsTests { defer { try? FileManager.default.removeItem(at: url.deletingLastPathComponent()) } let settings = GraphcodeSettings( defaultBackend: .copilotCLI, claudePermissionMode: .bypassPermissions, - copilotPermissions: .ask, briefsSessionsAboutTheGraph: false) + copilotPermissions: .ask, copilotPreferredVersion: "1.0.84-5", + briefsSessionsAboutTheGraph: false) #expect(GraphcodeSettingsStore.save(settings, to: url)) #expect(GraphcodeSettingsStore.load(from: url) == settings) @@ -69,6 +71,7 @@ struct GraphcodeSettingsTests { #expect(loaded.claudePermissionMode == .dontAsk) #expect(loaded.briefsSessionsAboutTheGraph) #expect(loaded.defaultBackend == .claudeCode) + #expect(loaded.copilotPreferredVersion.isEmpty) } @Test diff --git a/graphcode/Tests/SummaryStoreTests.swift b/graphcode/Tests/SummaryStoreTests.swift index eabb8410..7af80ffe 100644 --- a/graphcode/Tests/SummaryStoreTests.swift +++ b/graphcode/Tests/SummaryStoreTests.swift @@ -213,7 +213,8 @@ struct SummaryStoreTests { == ["claude", "-p", "p", "--model", "haiku"]) // Copilot's `--model` takes an explicit versioned id, not Claude Code's short alias. #expect( - SummaryModelWriter.invocation(forBackend: .copilotCLI, prompt: "p") + SummaryModelWriter.invocation( + forBackend: .copilotCLI, prompt: "p", settings: GraphcodeSettings()) == ["copilot", "-p", "p", "--model", "gpt-5.6-luna"]) // Codex's valid ids aren't visible from its `--help`, so it is given none and its own // default applies — an honest omission rather than a guessed id. Its non-interactive