diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Configuration/CLICommandConfiguration.swift b/app/modules/AgentHubCore/Sources/AgentHub/Configuration/CLICommandConfiguration.swift index 5851daed..a925a838 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Configuration/CLICommandConfiguration.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Configuration/CLICommandConfiguration.swift @@ -147,6 +147,13 @@ public struct CLICommandConfiguration: Codable, Sendable { ] } + // System-prompt text is per-invocation, not part of conversation + // history, so resume must re-pass it or the session silently loses the + // launch context / simulator guidance it started with. + if let appendSystemPrompt, !appendSystemPrompt.isEmpty { + providerArgs += ["--append-system-prompt", appendSystemPrompt] + } + // Add flags only for NEW sessions (not resume) if isNewSession { if permissionModePlan { @@ -155,9 +162,6 @@ public struct CLICommandConfiguration: Codable, Sendable { } else if dangerouslySkipPermissions { providerArgs.append("--dangerously-skip-permissions") } - if let appendSystemPrompt, !appendSystemPrompt.isEmpty { - providerArgs += ["--append-system-prompt", appendSystemPrompt] - } if let name = worktreeName { if name.isEmpty { providerArgs.append("--worktree") @@ -207,6 +211,12 @@ public struct CLICommandConfiguration: Codable, Sendable { xcodeBuildMCP: xcodeBuildMCPBootstrap ) } + // developer_instructions is a config override, not conversation + // history — resume must re-pass it or the session loses its launch + // context / simulator guidance. + if let appendSystemPrompt, !appendSystemPrompt.isEmpty { + providerArgs += ["-c", "developer_instructions=\(tomlStringLiteral(appendSystemPrompt))"] + } return assembleArguments(prefix: prefix, providerArgs: providerArgs, trailingArgs: ["resume", sessionId]) } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Models/PendingHubSession.swift b/app/modules/AgentHubCore/Sources/AgentHub/Models/PendingHubSession.swift index d697896f..4794e62c 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Models/PendingHubSession.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Models/PendingHubSession.swift @@ -16,6 +16,9 @@ public struct PendingHubSession: Identifiable { public let startedAt: Date public let initialPrompt: String? public let initialInputText: String? + /// Curated context delivered out-of-band (system-prompt channel), never + /// merged into the first user message. + public let launchContext: String? public let dangerouslySkipPermissions: Bool public let permissionModePlan: Bool /// nil = no worktree flag; "" = --worktree (auto-name); non-empty = --worktree @@ -26,6 +29,7 @@ public struct PendingHubSession: Identifiable { launchPath: String? = nil, initialPrompt: String? = nil, initialInputText: String? = nil, + launchContext: String? = nil, dangerouslySkipPermissions: Bool = false, permissionModePlan: Bool = false, worktreeName: String? = nil @@ -36,6 +40,7 @@ public struct PendingHubSession: Identifiable { self.startedAt = Date() self.initialPrompt = initialPrompt self.initialInputText = initialInputText + self.launchContext = launchContext self.dangerouslySkipPermissions = dangerouslySkipPermissions self.permissionModePlan = permissionModePlan self.worktreeName = worktreeName diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Models/SessionLaunchContextRecord.swift b/app/modules/AgentHubCore/Sources/AgentHub/Models/SessionLaunchContextRecord.swift new file mode 100644 index 00000000..93728fac --- /dev/null +++ b/app/modules/AgentHubCore/Sources/AgentHub/Models/SessionLaunchContextRecord.swift @@ -0,0 +1,44 @@ +// +// SessionLaunchContextRecord.swift +// AgentHub +// +// SQLite record for the curated launch context attached to a session. +// +// Launch context is delivered out-of-band (Claude `--append-system-prompt`, +// Codex `-c developer_instructions=`) instead of inside the first user +// message, so it is not part of the conversation history and would be lost +// on resume. This row lets a resume launch re-pass the exact text the +// session started with. +// + +import Foundation +import GRDB + +public struct SessionLaunchContextRecord: Codable, Equatable, Sendable, FetchableRecord, PersistableRecord { + public var sessionId: String + public var provider: String + public var projectPath: String + /// The exact text passed at launch: either the inline `` block or + /// the short file-reference prompt for oversized payloads. + public var contextText: String + public var createdAt: Date + public var updatedAt: Date + + public static var databaseTableName: String { "session_launch_context" } + + public init( + sessionId: String, + provider: String, + projectPath: String, + contextText: String, + createdAt: Date = Date(), + updatedAt: Date = Date() + ) { + self.sessionId = sessionId + self.provider = provider + self.projectPath = projectPath + self.contextText = contextText + self.createdAt = createdAt + self.updatedAt = updatedAt + } +} diff --git a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionMetadataStore.swift b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionMetadataStore.swift index 078db721..9d53fc18 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionMetadataStore.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/Services/SessionMetadataStore.swift @@ -31,6 +31,7 @@ public actor SessionMetadataStore: TerminalWorkspaceStoreProtocol, AgentWorkspac static let createSessionMeasurements = "v14_create_session_measurements" static let addMeasurementProjectPath = "v15_add_measurement_project_path" static let createContextProfiles = "v16_create_context_profiles" + static let createSessionLaunchContext = "v17_create_session_launch_context" } static let migrationIdentifiers = [ @@ -49,7 +50,8 @@ public actor SessionMetadataStore: TerminalWorkspaceStoreProtocol, AgentWorkspac MigrationID.createPinnedSessionOrder, MigrationID.createSessionMeasurements, MigrationID.addMeasurementProjectPath, - MigrationID.createContextProfiles + MigrationID.createContextProfiles, + MigrationID.createSessionLaunchContext ] private let dbQueue: DatabaseQueue @@ -331,6 +333,21 @@ public actor SessionMetadataStore: TerminalWorkspaceStoreProtocol, AgentWorkspac """) } + // Curated launch context per session, so resume launches can re-pass the + // out-of-band context (system-prompt channel) the session started with — + // that text never enters conversation history, unlike the old + // first-message injection. + migrator.registerMigration(MigrationID.createSessionLaunchContext) { db in + try db.create(table: "session_launch_context") { t in + t.column("sessionId", .text).primaryKey() + t.column("provider", .text).notNull() + t.column("projectPath", .text).notNull() + t.column("contextText", .text).notNull() + t.column("createdAt", .datetime).notNull() + t.column("updatedAt", .datetime).notNull() + } + } + return migrator } @@ -1014,6 +1031,39 @@ extension SessionMetadataStore { } } +// MARK: - Session Launch Context + +extension SessionMetadataStore { + /// Rows older than this are pruned on save: launch context is only re-read + /// when a session is resumed, and a month-old session losing its background + /// context is preferable to unbounded growth of up-to-48 KB rows. + public static let sessionLaunchContextMaxAge: TimeInterval = 30 * 24 * 60 * 60 + + /// Records the curated context a session was launched with (upsert), pruning + /// stale rows in the same transaction. + public func saveSessionLaunchContext(_ record: SessionLaunchContextRecord) throws { + try dbQueue.write { db in + let cutoff = Date().addingTimeInterval(-Self.sessionLaunchContextMaxAge) + try SessionLaunchContextRecord + .filter(Column("updatedAt") < cutoff) + .deleteAll(db) + var record = record + record.updatedAt = Date() + try record.save(db) + } + } + + /// Synchronous read for command-line assembly at resume time, safe from + /// non-async contexts (mirrors `getAIConfigSync`). + public nonisolated func getSessionLaunchContextTextSync(for sessionId: String) -> String? { + try? dbQueue.read { db in + try SessionLaunchContextRecord + .fetchOne(db, key: sessionId)? + .contextText + } + } +} + extension SessionMetadataStore: ClaudeHookInstallStateStoreProtocol { public func loadClaudeHookInstalledPaths() async throws -> Set { try await dbQueue.read { db in diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalLaunchBuilder.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalLaunchBuilder.swift index bf85054e..1ee3dd5d 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalLaunchBuilder.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalLaunchBuilder.swift @@ -49,6 +49,7 @@ public enum EmbeddedTerminalLaunchBuilder { projectPath: String, cliConfiguration: CLICommandConfiguration, initialPrompt: String?, + launchContext: String? = nil, dangerouslySkipPermissions: Bool, permissionModePlan: Bool, worktreeName: String?, @@ -60,6 +61,7 @@ public enum EmbeddedTerminalLaunchBuilder { projectPath: projectPath, cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, + launchContext: launchContext, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, worktreeName: worktreeName, @@ -79,6 +81,7 @@ public enum EmbeddedTerminalLaunchBuilder { projectPath: String, cliConfiguration: CLICommandConfiguration, initialPrompt: String?, + launchContext: String? = nil, dangerouslySkipPermissions: Bool, permissionModePlan: Bool, worktreeName: String?, @@ -146,7 +149,24 @@ public enum EmbeddedTerminalLaunchBuilder { // Xcode projects get simulator-loop guidance at system-prompt level so // agents verify through the same live app surface the user is watching. // Tied to the bootstrap: guidance without the tools misleads agents. - let appendSystemPrompt = xcodeBuildMCPBootstrap == nil ? nil : SimulatorAgentGuidance.systemPrompt + let simulatorGuidance = xcodeBuildMCPBootstrap == nil ? nil : SimulatorAgentGuidance.systemPrompt + // Curated launch context rides the same out-of-band channel (Claude + // `--append-system-prompt`, Codex `-c developer_instructions=`) instead of + // the first user message. New sessions get it from the launch flow; resume + // has no in-memory copy, so the text persisted at session resolution is + // re-passed — it never entered conversation history. + let resolvedLaunchContext: String? + if isNewSession { + resolvedLaunchContext = launchContext + } else if let sessionId, !sessionId.isEmpty { + resolvedLaunchContext = metadataStore?.getSessionLaunchContextTextSync(for: sessionId) + } else { + resolvedLaunchContext = nil + } + let appendSystemPrompt = combinedAppendSystemPrompt( + simulatorGuidance: simulatorGuidance, + launchContext: resolvedLaunchContext + ) let args = cliConfiguration.argumentsForSession( sessionId: sessionId, prompt: initialPrompt, @@ -264,6 +284,27 @@ public enum EmbeddedTerminalLaunchBuilder { return environment } + /// One line of provenance so the agent knows the block below is deliberate + /// user input, not something that leaked into its system prompt. + static let launchContextPreamble = + "The user attached the following curated context when launching this session. Use it as background for their upcoming tasks." + + /// Single append-system-prompt value both providers receive. Guidance first + /// (short operational instructions), then the bulky context block. + static func combinedAppendSystemPrompt( + simulatorGuidance: String?, + launchContext: String? + ) -> String? { + var parts: [String] = [] + if let simulatorGuidance, !simulatorGuidance.isEmpty { + parts.append(simulatorGuidance) + } + if let context = launchContext?.trimmingCharacters(in: .whitespacesAndNewlines), !context.isEmpty { + parts.append(launchContextPreamble + "\n" + context) + } + return parts.isEmpty ? nil : parts.joined(separator: "\n\n") + } + static func shellEscape(_ value: String) -> String { value.replacingOccurrences(of: "'", with: "'\\''") } diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalSurface.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalSurface.swift index 24431a8f..453703d8 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalSurface.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalSurface.swift @@ -35,6 +35,7 @@ public protocol EmbeddedTerminalSurface: AnyObject { cliConfiguration: CLICommandConfiguration, initialPrompt: String?, initialInputText: String?, + launchContext: String?, isDark: Bool, dangerouslySkipPermissions: Bool, permissionModePlan: Bool, diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalView.swift index 02268df7..3878374c 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/EmbeddedTerminalView.swift @@ -95,6 +95,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable { let cliConfiguration: CLICommandConfiguration let initialPrompt: String? // Optional: prompt to include with resume command let initialInputText: String? // Optional: text to prefill terminal input without Enter + let launchContext: String? // Optional: curated context, delivered via the system-prompt channel let viewModel: CLISessionsViewModel? // For shared terminal storage let dangerouslySkipPermissions: Bool // One-shot flag for new sessions let permissionModePlan: Bool // One-shot flag: start session in plan mode @@ -110,6 +111,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable { cliConfiguration: CLICommandConfiguration, initialPrompt: String? = nil, initialInputText: String? = nil, + launchContext: String? = nil, viewModel: CLISessionsViewModel? = nil, dangerouslySkipPermissions: Bool = false, permissionModePlan: Bool = false, @@ -124,6 +126,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable { self.cliConfiguration = cliConfiguration self.initialPrompt = initialPrompt self.initialInputText = initialInputText + self.launchContext = launchContext self.viewModel = viewModel self.dangerouslySkipPermissions = dangerouslySkipPermissions self.permissionModePlan = permissionModePlan @@ -176,6 +179,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable { cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, initialInputText: initialInputText, + launchContext: launchContext, isDark: isDark, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, @@ -197,6 +201,7 @@ public struct EmbeddedTerminalView: NSViewRepresentable { cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, initialInputText: initialInputText, + launchContext: launchContext, isDark: isDark, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, @@ -297,6 +302,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg cliConfiguration: CLICommandConfiguration, initialPrompt: String? = nil, initialInputText: String? = nil, + launchContext: String? = nil, isDark: Bool = true, dangerouslySkipPermissions: Bool = false, permissionModePlan: Bool = false, @@ -328,6 +334,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg projectPath: projectPath, cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, + launchContext: launchContext, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, worktreeName: worktreeName @@ -740,6 +747,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg projectPath: String, cliConfiguration: CLICommandConfiguration, initialPrompt: String? = nil, + launchContext: String? = nil, dangerouslySkipPermissions: Bool = false, permissionModePlan: Bool = false, worktreeName: String? = nil @@ -749,6 +757,7 @@ public class TerminalContainerView: NSView, ManagedLocalProcessTerminalViewDeleg projectPath: projectPath, cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, + launchContext: launchContext, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, worktreeName: worktreeName, diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift index 8ac01e3f..cbfc774b 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringCardView.swift @@ -21,6 +21,7 @@ public struct MonitoringCardView: View { let providerKind: SessionProviderKind let initialPrompt: String? let initialInputText: String? + let launchContext: String? // Curated context, delivered via the system-prompt channel let terminalKey: String? // Key for terminal storage (session ID or "pending-{pendingId}") let viewModel: CLISessionsViewModel? let editorProjectPath: String @@ -74,6 +75,7 @@ public struct MonitoringCardView: View { providerKind: SessionProviderKind = .claude, initialPrompt: String? = nil, initialInputText: String? = nil, + launchContext: String? = nil, terminalKey: String? = nil, viewModel: CLISessionsViewModel? = nil, contentMode: Binding = .constant(.terminal), @@ -113,6 +115,7 @@ public struct MonitoringCardView: View { self.providerKind = providerKind self.initialPrompt = initialPrompt self.initialInputText = initialInputText + self.launchContext = launchContext self.terminalKey = terminalKey self.viewModel = viewModel self._contentMode = contentMode @@ -976,6 +979,7 @@ public struct MonitoringCardView: View { cliConfiguration: liveCLIConfiguration, initialPrompt: initialPrompt, initialInputText: initialInputText, + launchContext: launchContext, viewModel: viewModel, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, @@ -1172,6 +1176,7 @@ private struct MonitoringCardTerminalContent: View { let cliConfiguration: CLICommandConfiguration let initialPrompt: String? let initialInputText: String? + let launchContext: String? let viewModel: CLISessionsViewModel? let dangerouslySkipPermissions: Bool let permissionModePlan: Bool @@ -1188,6 +1193,7 @@ private struct MonitoringCardTerminalContent: View { cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, initialInputText: initialInputText, + launchContext: launchContext, viewModel: viewModel, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringPanelView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringPanelView.swift index bd07b88a..61aef9c7 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringPanelView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/MonitoringPanelView.swift @@ -173,6 +173,7 @@ public struct MonitoringPanelView: View { providerKind: viewModel.providerKind, initialPrompt: pending.initialPrompt, initialInputText: pending.initialInputText, + launchContext: pending.launchContext, terminalKey: pendingId, viewModel: viewModel, contentMode: editorContentModeBinding(for: monitoringItem), diff --git a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift index 935e0b30..ee99f056 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/UI/MultiProviderMonitoringPanelView.swift @@ -546,6 +546,7 @@ public struct MultiProviderMonitoringPanelView: View { providerKind: item.providerKind, initialPrompt: pending.initialPrompt, initialInputText: pending.initialInputText, + launchContext: pending.launchContext, terminalKey: pendingId, viewModel: viewModel, contentMode: editorContentModeBinding(for: item), diff --git a/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift b/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift index 0a2b7be9..f66621a7 100644 --- a/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift +++ b/app/modules/AgentHubCore/Sources/AgentHub/ViewModels/CLISessionsViewModel.swift @@ -929,6 +929,7 @@ public final class CLISessionsViewModel { cliConfiguration: CLICommandConfiguration, initialPrompt: String?, initialInputText: String?, + launchContext: String?, isDark: Bool, dangerouslySkipPermissions: Bool, permissionModePlan: Bool, @@ -945,6 +946,7 @@ public final class CLISessionsViewModel { cliConfiguration, initialPrompt, initialInputText, + launchContext, isDark, dangerouslySkipPermissions, permissionModePlan, @@ -956,6 +958,7 @@ public final class CLISessionsViewModel { cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, initialInputText: initialInputText, + launchContext: launchContext, isDark: isDark, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, @@ -975,17 +978,22 @@ public final class CLISessionsViewModel { cliConfiguration, _, _, + _, isDark, dangerouslySkipPermissions, permissionModePlan, worktreeName ): + // launchContext drops with the prompt: a relaunch from this descriptor + // resumes by real session id, and the resume path re-reads persisted + // context from the metadata store. return .cli( sessionId: sessionId, projectPath: projectPath, cliConfiguration: cliConfiguration, initialPrompt: nil, initialInputText: nil, + launchContext: nil, isDark: isDark, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, @@ -1416,6 +1424,7 @@ public final class CLISessionsViewModel { cliConfiguration: CLICommandConfiguration? = nil, initialPrompt: String?, initialInputText: String? = nil, + launchContext: String? = nil, isDark: Bool = true, dangerouslySkipPermissions: Bool = false, permissionModePlan: Bool = false, @@ -1429,6 +1438,9 @@ public final class CLISessionsViewModel { || sessionId?.hasPrefix("pending-") == true ) let launchInitialPrompt = isNewSession ? initialPrompt : nil + // Resume launches re-read persisted context from the metadata store; the + // in-memory copy only rides along for brand-new sessions. + let newSessionLaunchContext = isNewSession ? launchContext : nil let descriptorInputText = key.hasPrefix("pending-") ? combinedInputText(initialInputText, queuedInputText) : queuedInputText @@ -1439,6 +1451,7 @@ public final class CLISessionsViewModel { cliConfiguration: config, initialPrompt: launchInitialPrompt, initialInputText: descriptorInputText, + launchContext: newSessionLaunchContext, isDark: isDark, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, @@ -1476,6 +1489,7 @@ public final class CLISessionsViewModel { cliConfiguration: config, initialPrompt: launchInitialPrompt, initialInputText: createInputText, + launchContext: newSessionLaunchContext, isDark: isDark, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, @@ -1624,6 +1638,7 @@ public final class CLISessionsViewModel { cliConfiguration: currentCLIConfiguration, initialPrompt: nil, initialInputText: nil, + launchContext: nil, isDark: true, dangerouslySkipPermissions: false, permissionModePlan: false, @@ -3585,8 +3600,11 @@ public final class CLISessionsViewModel { /// - Parameters: /// - worktree: The worktree to start the session in /// - contextBlock: Already-materialized curated context (inline block or - /// temp-file reference prompt) prepended to the first message. Nil keeps - /// the launch identical to a launch without the context feature. + /// temp-file reference prompt). Delivered out-of-band via the + /// append-system-prompt channel (`--append-system-prompt` / + /// `-c developer_instructions=`), never merged into the first user + /// message. Nil keeps the launch identical to a launch without the + /// context feature. /// - dangerouslySkipPermissions: If true, adds --dangerously-skip-permissions flag public func startNewSessionInHub( _ worktree: WorktreeBranch, @@ -3598,9 +3616,8 @@ public final class CLISessionsViewModel { permissionModePlan: Bool = false, worktreeName: String? = nil ) { - let mergedPrompt = Self.mergedLaunchPrompt(contextBlock: contextBlock, initialPrompt: initialPrompt) - let promptForTerminalSubmission = providerKind == .claude ? nonEmpty(mergedPrompt) : nil - let promptForProcessLaunch = providerKind == .claude ? nil : mergedPrompt + let promptForTerminalSubmission = providerKind == .claude ? nonEmpty(initialPrompt) : nil + let promptForProcessLaunch = providerKind == .claude ? nil : initialPrompt // Each pending session gets a unique ID, so no need to clear existing terminals // Terminals are now keyed by session ID, not worktree path @@ -3609,6 +3626,7 @@ public final class CLISessionsViewModel { launchPath: launchPath, initialPrompt: promptForProcessLaunch, initialInputText: initialInputText, + launchContext: nonEmpty(contextBlock?.trimmingCharacters(in: .whitespacesAndNewlines)), dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, worktreeName: worktreeName @@ -3632,18 +3650,23 @@ public final class CLISessionsViewModel { } } - /// Merges curated context with the user's first prompt — one rule for both - /// providers. Nil/empty context returns the prompt untouched, so a launch - /// without curated context is byte-for-byte today's behavior. - static func mergedLaunchPrompt(contextBlock: String?, initialPrompt: String?) -> String? { - guard let context = contextBlock?.trimmingCharacters(in: .whitespacesAndNewlines), !context.isEmpty else { - return initialPrompt - } - guard let prompt = initialPrompt, !prompt.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { - return context - + "\n\nThis is background context for the tasks I'm about to give you. Load it and acknowledge in one line." + /// Persists the curated context a pending session was launched with, keyed + /// by the real session id, so resume launches can re-pass it on the + /// append-system-prompt channel (that text never enters conversation + /// history). Fire-and-forget: the running session already has its context. + private func persistLaunchContext(pending: PendingHubSession, sessionId: String) { + guard let context = pending.launchContext, !context.isEmpty, + !sessionId.isEmpty, !sessionId.hasPrefix("pending-"), + let metadataStore else { return } + let record = SessionLaunchContextRecord( + sessionId: sessionId, + provider: providerKind.rawValue, + projectPath: pending.projectPath, + contextText: context + ) + Task { + try? await metadataStore.saveSessionLaunchContext(record) } - return context + "\n\n" + prompt } /// Removes a pending Hub session (e.g., if user cancels) @@ -3834,6 +3857,7 @@ public final class CLISessionsViewModel { // Remove pending only after finding the real session pendingHubSessions.removeAll { $0.id == pending.id } resolvedPendingSessions[pending.id] = session.id + persistLaunchContext(pending: pending, sessionId: session.id) AppLogger.session.info("[HandleNewSession] Resolved: pending=\(pending.id.uuidString.prefix(8), privacy: .public) -> real=\(session.id.prefix(8), privacy: .public)") transferTerminal(fromPendingId: pending.id, toSessionId: session.id) transferAuxiliaryShellTerminal(fromPendingId: pending.id, toSessionId: session.id) @@ -3851,6 +3875,7 @@ public final class CLISessionsViewModel { if let session = wt.sessions.first(where: { $0.id == sessionId }) { pendingHubSessions.removeAll { $0.id == pending.id } resolvedPendingSessions[pending.id] = session.id + persistLaunchContext(pending: pending, sessionId: session.id) AppLogger.session.info("[HandleNewSession] Resolved: pending=\(pending.id.uuidString.prefix(8), privacy: .public) -> real=\(session.id.prefix(8), privacy: .public)") transferTerminal(fromPendingId: pending.id, toSessionId: session.id) transferAuxiliaryShellTerminal(fromPendingId: pending.id, toSessionId: session.id) @@ -3894,6 +3919,7 @@ public final class CLISessionsViewModel { pendingHubSessions.removeAll { $0.id == pending.id } resolvedPendingSessions[pending.id] = sessionId + persistLaunchContext(pending: pending, sessionId: sessionId) AppLogger.session.info("[HandleNewSession] Resolved: pending=\(pending.id.uuidString.prefix(8), privacy: .public) -> real=\(sessionId.prefix(8), privacy: .public)") transferTerminal(fromPendingId: pending.id, toSessionId: sessionId) transferAuxiliaryShellTerminal(fromPendingId: pending.id, toSessionId: sessionId) @@ -3937,6 +3963,7 @@ public final class CLISessionsViewModel { pendingHubSessions.removeAll { $0.id == pending.id } resolvedPendingSessions[pending.id] = sessionId + persistLaunchContext(pending: pending, sessionId: sessionId) AppLogger.session.info("[HandleNewSession] Resolved: pending=\(pending.id.uuidString.prefix(8), privacy: .public) -> real=\(sessionId.prefix(8), privacy: .public)") transferTerminal(fromPendingId: pending.id, toSessionId: sessionId) transferAuxiliaryShellTerminal(fromPendingId: pending.id, toSessionId: sessionId) diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/AIConfigSettingsTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/AIConfigSettingsTests.swift index 317d6d10..65fac571 100644 --- a/app/modules/AgentHubCore/Tests/AgentHubTests/AIConfigSettingsTests.swift +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/AIConfigSettingsTests.swift @@ -283,8 +283,8 @@ struct CLICommandConfigurationArgumentHandlingTests { #expect(Array(args.suffix(2)) == ["--debug", "Start work"]) } - @Test("New Claude sessions receive the appended system prompt; resumes do not") - func appendSystemPromptOnlyForNewClaudeSessions() { + @Test("Claude sessions receive the appended system prompt on launch and on resume") + func appendSystemPromptForClaudeSessions() { let config = CLICommandConfiguration.claudeDefault let newSession = config.argumentsForSession( @@ -299,18 +299,25 @@ struct CLICommandConfigurationArgumentHandlingTests { #expect(newSession[flagIndex + 1].contains("XcodeBuildMCP")) #expect(newSession[flagIndex + 1].contains("build_run_sim")) + // System-prompt text is per-invocation, not conversation history: a + // resume that drops the flag silently loses launch context and guidance. let resumed = config.argumentsForSession( sessionId: "existing-session", prompt: nil, appendSystemPrompt: SimulatorAgentGuidance.systemPrompt ) - #expect(!resumed.contains("--append-system-prompt")) + guard let resumedIndex = resumed.firstIndex(of: "--append-system-prompt") else { + Issue.record("missing --append-system-prompt in resume args \(resumed)") + return + } + #expect(resumed[resumedIndex + 1].contains("XcodeBuildMCP")) + #expect(Array(resumed.suffix(2)) == ["-r", "existing-session"]) let withoutGuidance = config.argumentsForSession(sessionId: nil, prompt: nil) #expect(!withoutGuidance.contains("--append-system-prompt")) } - @Test("New Codex sessions receive developer instructions; resumes do not") + @Test("Codex sessions receive developer instructions on launch and on resume") func appendSystemPromptBecomesCodexDeveloperInstructions() { let config = CLICommandConfiguration.codexDefault @@ -323,13 +330,19 @@ struct CLICommandConfigurationArgumentHandlingTests { #expect(args.contains { $0.hasPrefix("developer_instructions=") && $0.contains("XcodeBuildMCP") }) #expect(args.contains { $0.hasPrefix("developer_instructions=") && $0.contains("build_sim") }) + // developer_instructions is a config override, not history — resume must + // re-pass it just like a fresh launch. let resumed = config.argumentsForSession( sessionId: "existing-session", prompt: nil, appendSystemPrompt: SimulatorAgentGuidance.systemPrompt ) #expect(!resumed.contains("--append-system-prompt")) - #expect(!resumed.contains { $0.hasPrefix("developer_instructions=") }) + #expect(resumed.contains { $0.hasPrefix("developer_instructions=") && $0.contains("XcodeBuildMCP") }) + #expect(Array(resumed.suffix(2)) == ["resume", "existing-session"]) + + let withoutGuidance = config.argumentsForSession(sessionId: "existing-session", prompt: nil) + #expect(!withoutGuidance.contains { $0.hasPrefix("developer_instructions=") }) } @Test("Decodes previous CLI configuration payloads without extra args") diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/AgentWorkspacesViewModelTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/AgentWorkspacesViewModelTests.swift index d461d17f..3e5293b8 100644 --- a/app/modules/AgentHubCore/Tests/AgentHubTests/AgentWorkspacesViewModelTests.swift +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/AgentWorkspacesViewModelTests.swift @@ -604,6 +604,7 @@ private final class WorkspaceTerminalSurfaceSpy: NSView, EmbeddedTerminalSurface cliConfiguration: CLICommandConfiguration, initialPrompt: String?, initialInputText: String?, + launchContext: String?, isDark: Bool, dangerouslySkipPermissions: Bool, permissionModePlan: Bool, diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/AuxiliaryShellTerminalManagementTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/AuxiliaryShellTerminalManagementTests.swift index 74adce87..c77f06e6 100644 --- a/app/modules/AgentHubCore/Tests/AgentHubTests/AuxiliaryShellTerminalManagementTests.swift +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/AuxiliaryShellTerminalManagementTests.swift @@ -67,6 +67,7 @@ private final class TestTerminalSurface: NSView, EmbeddedTerminalSurface { private(set) var configuredShellPath: String? private(set) var configuredInitialPrompt: String? private(set) var configuredInitialInputText: String? + private(set) var configuredLaunchContext: String? private(set) var typedTexts: [String] = [] private(set) var initialTypedTexts: [String] = [] private(set) var sentPrompts: [String] = [] @@ -87,6 +88,7 @@ private final class TestTerminalSurface: NSView, EmbeddedTerminalSurface { cliConfiguration: CLICommandConfiguration, initialPrompt: String?, initialInputText: String?, + launchContext: String?, isDark: Bool, dangerouslySkipPermissions: Bool, permissionModePlan: Bool, @@ -96,6 +98,7 @@ private final class TestTerminalSurface: NSView, EmbeddedTerminalSurface { configuredProjectPath = projectPath configuredInitialPrompt = initialPrompt configuredInitialInputText = initialInputText + configuredLaunchContext = launchContext configureCallCount += 1 } diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/EmbeddedTerminalLaunchBuilderTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/EmbeddedTerminalLaunchBuilderTests.swift index 95d173ca..64227eeb 100644 --- a/app/modules/AgentHubCore/Tests/AgentHubTests/EmbeddedTerminalLaunchBuilderTests.swift +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/EmbeddedTerminalLaunchBuilderTests.swift @@ -526,3 +526,162 @@ struct EmbeddedTerminalLaunchBuilderAgentHubCLITests { .path } } + +@Suite("EmbeddedTerminalLaunchBuilder launch context") +struct EmbeddedTerminalLaunchBuilderLaunchContextTests { + private let agentHubCLIPath = "/Applications/AgentHub.app/Contents/Helpers/agenthub" + + private func makeStore() throws -> SessionMetadataStore { + try SessionMetadataStore( + path: FileManager.default.temporaryDirectory + .appendingPathComponent("AgentHubLaunchContextTests-\(UUID().uuidString).sqlite") + .path + ) + } + + private func makeProjectDirectory() throws -> URL { + let directory = FileManager.default.temporaryDirectory + .appendingPathComponent("AgentHubLaunchContextProject-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + private func shellCommand( + sessionId: String?, + mode: CLICommandMode, + projectPath: String, + initialPrompt: String?, + launchContext: String?, + metadataStore: SessionMetadataStore? + ) throws -> String { + let result = EmbeddedTerminalLaunchBuilder.cliLaunch( + sessionId: sessionId, + projectPath: projectPath, + cliConfiguration: CLICommandConfiguration(command: "echo", additionalPaths: ["/bin"], mode: mode), + initialPrompt: initialPrompt, + launchContext: launchContext, + dangerouslySkipPermissions: false, + permissionModePlan: false, + worktreeName: nil, + metadataStore: metadataStore, + agentHubCLIPath: agentHubCLIPath, + installAgentHubWorktreeSkill: {}, + xcodeBuildMCPEnabled: false + ) + guard case .success(let launch) = result else { + throw NSError(domain: "LaunchContextTests", code: 1) + } + return launch.shellCommand + } + + @Test("Claude new session: context lands in --append-system-prompt, the prompt stays pure") + func claudeNewSessionCarriesContextInSystemPrompt() throws { + let project = try makeProjectDirectory() + defer { try? FileManager.default.removeItem(at: project) } + + let command = try shellCommand( + sessionId: nil, + mode: .claude, + projectPath: project.path, + initialPrompt: "fix the bug", + launchContext: "x", + metadataStore: nil + ) + + #expect(command.contains("--append-system-prompt")) + #expect(command.contains(EmbeddedTerminalLaunchBuilder.launchContextPreamble)) + #expect(command.contains("x")) + // The user's prompt remains the lone trailing positional argument. + #expect(command.hasSuffix("'fix the bug'")) + } + + @Test("Codex new session: context lands in developer_instructions, the prompt stays pure") + func codexNewSessionCarriesContextInDeveloperInstructions() throws { + let project = try makeProjectDirectory() + defer { try? FileManager.default.removeItem(at: project) } + + let command = try shellCommand( + sessionId: nil, + mode: .codex, + projectPath: project.path, + initialPrompt: "fix the bug", + launchContext: "x", + metadataStore: nil + ) + + #expect(command.contains("developer_instructions=")) + #expect(command.contains(EmbeddedTerminalLaunchBuilder.launchContextPreamble)) + #expect(command.contains("x")) + #expect(command.hasSuffix("'fix the bug'")) + } + + @Test("Claude resume re-passes the persisted launch context") + func claudeResumeRereadsPersistedContext() async throws { + let project = try makeProjectDirectory() + defer { try? FileManager.default.removeItem(at: project) } + let store = try makeStore() + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "resumed-session", + provider: "Claude", + projectPath: project.path, + contextText: "persisted" + )) + + let command = try shellCommand( + sessionId: "resumed-session", + mode: .claude, + projectPath: project.path, + initialPrompt: nil, + launchContext: nil, + metadataStore: store + ) + + #expect(command.contains("--append-system-prompt")) + #expect(command.contains("persisted")) + #expect(command.hasSuffix("'-r' 'resumed-session'")) + } + + @Test("Codex resume re-passes the persisted launch context") + func codexResumeRereadsPersistedContext() async throws { + let project = try makeProjectDirectory() + defer { try? FileManager.default.removeItem(at: project) } + let store = try makeStore() + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "resumed-session", + provider: "Codex", + projectPath: project.path, + contextText: "persisted" + )) + + let command = try shellCommand( + sessionId: "resumed-session", + mode: .codex, + projectPath: project.path, + initialPrompt: nil, + launchContext: nil, + metadataStore: store + ) + + #expect(command.contains("developer_instructions=")) + #expect(command.contains("persisted")) + #expect(command.hasSuffix("'resume' 'resumed-session'")) + } + + @Test("Resume without a persisted row adds no context flags") + func resumeWithoutPersistedRowAddsNothing() throws { + let project = try makeProjectDirectory() + defer { try? FileManager.default.removeItem(at: project) } + let store = try makeStore() + + let command = try shellCommand( + sessionId: "resumed-session", + mode: .claude, + projectPath: project.path, + initialPrompt: nil, + launchContext: nil, + metadataStore: store + ) + + #expect(!command.contains("--append-system-prompt")) + } +} diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/MultiSessionLaunchContextTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/MultiSessionLaunchContextTests.swift index dabea9df..070edcb1 100644 --- a/app/modules/AgentHubCore/Tests/AgentHubTests/MultiSessionLaunchContextTests.swift +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/MultiSessionLaunchContextTests.swift @@ -36,13 +36,15 @@ private actor ContextStubFileWatcher: SessionFileWatcherProtocol { // MARK: - Helpers @MainActor -private func makeSessionsViewModel() -> CLISessionsViewModel { +private func makeSessionsViewModel(provider: SessionProviderKind = .claude) -> CLISessionsViewModel { CLISessionsViewModel( monitorService: ContextStubMonitorService(), fileWatcher: ContextStubFileWatcher(), searchService: nil, - cliConfiguration: CLICommandConfiguration(command: "claude", mode: .claude), - providerKind: .claude, + cliConfiguration: provider == .claude + ? CLICommandConfiguration(command: "claude", mode: .claude) + : CLICommandConfiguration(command: "codex", mode: .codex), + providerKind: provider, approvalNotificationService: NoOpApprovalNotificationService() ) } @@ -80,31 +82,105 @@ private func write(_ relativePath: String, _ content: String, in project: URL) t try content.write(to: url, atomically: true, encoding: .utf8) } -// MARK: - Prompt merging +// MARK: - Out-of-band context routing @MainActor -@Suite("Merged launch prompt") -struct MergedLaunchPromptTests { - @Test("Nil or empty context leaves the prompt byte-identical") - func nilContextLeavesPromptUntouched() { - #expect(CLISessionsViewModel.mergedLaunchPrompt(contextBlock: nil, initialPrompt: "fix the bug") == "fix the bug") - #expect(CLISessionsViewModel.mergedLaunchPrompt(contextBlock: " \n ", initialPrompt: "fix the bug") == "fix the bug") - #expect(CLISessionsViewModel.mergedLaunchPrompt(contextBlock: nil, initialPrompt: nil) == nil) +@Suite("Launch context routing") +struct LaunchContextRoutingTests { + private func makeWorktree() -> WorktreeBranch { + WorktreeBranch(name: "main", path: "/tmp/project", isWorktree: false) } - @Test("Context with a prompt is prepended with a blank line") - func contextPrepended() { - let merged = CLISessionsViewModel.mergedLaunchPrompt( - contextBlock: "x", initialPrompt: "fix the bug") - #expect(merged == "x\n\nfix the bug") + @Test("Claude: context rides the pending session, the first prompt stays pure") + func claudeContextNeverMergesIntoPrompt() throws { + let viewModel = makeSessionsViewModel() + + viewModel.startNewSessionInHub( + makeWorktree(), + initialPrompt: "fix the bug", + contextBlock: "x" + ) + + let pending = try #require(viewModel.pendingHubSessions.first) + #expect(pending.launchContext == "x") + // Claude receives its prompt via the terminal paste — context must not be in it. + #expect(pending.initialPrompt == nil) + let pasted = viewModel.consumePendingPrompt(for: "pending-\(pending.id.uuidString)") + #expect(pasted == "fix the bug") } - @Test("Context with no prompt asks the agent to load and acknowledge") - func contextOnlyAddsTrailer() throws { - let merged = try #require(CLISessionsViewModel.mergedLaunchPrompt( - contextBlock: "x", initialPrompt: nil)) - #expect(merged.hasPrefix("x")) - #expect(merged.contains("background context")) + @Test("Claude: context-only launch types nothing into the terminal") + func claudeContextOnlyLaunchHasNoFirstMessage() throws { + let viewModel = makeSessionsViewModel() + + viewModel.startNewSessionInHub(makeWorktree(), contextBlock: "x") + + let pending = try #require(viewModel.pendingHubSessions.first) + #expect(pending.launchContext == "x") + #expect(pending.initialPrompt == nil) + #expect(viewModel.consumePendingPrompt(for: "pending-\(pending.id.uuidString)") == nil) + } + + @Test("Codex: context rides the pending session, argv prompt stays pure") + func codexContextNeverMergesIntoPrompt() throws { + let viewModel = makeSessionsViewModel(provider: .codex) + + viewModel.startNewSessionInHub( + makeWorktree(), + initialPrompt: "fix the bug", + contextBlock: "x" + ) + + let pending = try #require(viewModel.pendingHubSessions.first) + #expect(pending.launchContext == "x") + // Codex receives its prompt as a positional argv argument — context must not be in it. + #expect(pending.initialPrompt == "fix the bug") + #expect(viewModel.consumePendingPrompt(for: "pending-\(pending.id.uuidString)") == nil) + } + + @Test("Whitespace-only context launches byte-identical to no context") + func whitespaceContextIsDropped() throws { + let viewModel = makeSessionsViewModel() + + viewModel.startNewSessionInHub(makeWorktree(), initialPrompt: "fix the bug", contextBlock: " \n ") + + let pending = try #require(viewModel.pendingHubSessions.first) + #expect(pending.launchContext == nil) + #expect(viewModel.consumePendingPrompt(for: "pending-\(pending.id.uuidString)") == "fix the bug") + } +} + +@MainActor +@Suite("Combined append-system-prompt") +struct CombinedAppendSystemPromptTests { + @Test("Nil pieces collapse to nil") + func allNilIsNil() { + #expect(EmbeddedTerminalLaunchBuilder.combinedAppendSystemPrompt( + simulatorGuidance: nil, launchContext: nil) == nil) + #expect(EmbeddedTerminalLaunchBuilder.combinedAppendSystemPrompt( + simulatorGuidance: nil, launchContext: " \n ") == nil) + } + + @Test("Guidance alone passes through unchanged") + func guidanceAlone() { + let combined = EmbeddedTerminalLaunchBuilder.combinedAppendSystemPrompt( + simulatorGuidance: "verify in the simulator", launchContext: nil) + #expect(combined == "verify in the simulator") + } + + @Test("Context alone gets the provenance preamble") + func contextAlone() { + let combined = EmbeddedTerminalLaunchBuilder.combinedAppendSystemPrompt( + simulatorGuidance: nil, launchContext: "x") + #expect(combined == EmbeddedTerminalLaunchBuilder.launchContextPreamble + "\nx") + } + + @Test("Guidance comes first, then the context block") + func guidanceThenContext() { + let combined = EmbeddedTerminalLaunchBuilder.combinedAppendSystemPrompt( + simulatorGuidance: "verify in the simulator", launchContext: "x") + #expect(combined == "verify in the simulator\n\n" + + EmbeddedTerminalLaunchBuilder.launchContextPreamble + "\nx") } } diff --git a/app/modules/AgentHubCore/Tests/AgentHubTests/SessionLaunchContextStoreTests.swift b/app/modules/AgentHubCore/Tests/AgentHubTests/SessionLaunchContextStoreTests.swift new file mode 100644 index 00000000..1dfb64ad --- /dev/null +++ b/app/modules/AgentHubCore/Tests/AgentHubTests/SessionLaunchContextStoreTests.swift @@ -0,0 +1,175 @@ +import Foundation +import GRDB +import Testing + +@testable import AgentHubCore + +@Suite("Session launch context store") +struct SessionLaunchContextStoreTests { + + @Test("Round-trips launch context and reads it synchronously by session id") + func roundTripsLaunchContext() async throws { + let path = temporaryLaunchContextDatabasePath() + let store = try SessionMetadataStore(path: path) + + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "session-1", + provider: "Claude", + projectPath: "/tmp/project", + contextText: "x" + )) + + #expect(store.getSessionLaunchContextTextSync(for: "session-1") == "x") + #expect(store.getSessionLaunchContextTextSync(for: "missing") == nil) + } + + @Test("Re-saving a session's context replaces it instead of duplicating") + func resaveReplaces() async throws { + let path = temporaryLaunchContextDatabasePath() + let store = try SessionMetadataStore(path: path) + + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "session-1", + provider: "Claude", + projectPath: "/tmp/project", + contextText: "old" + )) + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "session-1", + provider: "Claude", + projectPath: "/tmp/project", + contextText: "new" + )) + + #expect(store.getSessionLaunchContextTextSync(for: "session-1") == "new") + } + + @Test("Saving prunes rows older than the retention window") + func savePrunesStaleRows() async throws { + let path = temporaryLaunchContextDatabasePath() + let store = try SessionMetadataStore(path: path) + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "stale-session", + provider: "Claude", + projectPath: "/tmp/project", + contextText: "stale" + )) + + // Backdate past the retention window through a second connection — the + // store API always stamps updatedAt with "now". + let backdated = Date().addingTimeInterval(-SessionMetadataStore.sessionLaunchContextMaxAge - 60) + let rawQueue = try DatabaseQueue(path: path) + try await rawQueue.write { db in + try db.execute( + sql: "UPDATE session_launch_context SET updatedAt = ? WHERE sessionId = ?", + arguments: [backdated, "stale-session"] + ) + } + + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "fresh-session", + provider: "Claude", + projectPath: "/tmp/project", + contextText: "fresh" + )) + + #expect(store.getSessionLaunchContextTextSync(for: "stale-session") == nil) + #expect(store.getSessionLaunchContextTextSync(for: "fresh-session") == "fresh") + } + + @Test("v17 migration preserves existing metadata") + func v17MigrationPreservesExistingMetadata() async throws { + let path = temporaryLaunchContextDatabasePath() + let dbQueue = try DatabaseQueue(path: path) + + try await dbQueue.write { db in + try seedMigrationBaseline( + before: SessionMetadataStore.MigrationID.createSessionLaunchContext, + in: db + ) + try seedPreLaunchContextBaselineData(in: db) + } + + let store = try SessionMetadataStore(path: path) + + #expect(try await store.getCustomName(for: "legacy-session") == "legacy-name") + let profiles = try await store.getContextProfiles(forProjectPath: "/tmp/project") + #expect(profiles.map(\.id) == ["legacy-profile"]) + + // The new table is usable immediately after migrating. + try await store.saveSessionLaunchContext(SessionLaunchContextRecord( + sessionId: "post-session", + provider: "Codex", + projectPath: "/tmp/project", + contextText: "post" + )) + #expect(store.getSessionLaunchContextTextSync(for: "post-session") == "post") + } +} + +// MARK: - Helpers + +/// The v16 shape of the tables asserted on above: session_metadata plus +/// context_profiles with its partial default index. +private func seedPreLaunchContextBaselineData(in db: Database) throws { + try db.create(table: "session_metadata") { t in + t.column("sessionId", .text).primaryKey() + t.column("customName", .text) + t.column("createdAt", .datetime).notNull() + t.column("updatedAt", .datetime).notNull() + t.column("isPinned", .boolean).notNull().defaults(to: false) + } + try db.execute( + sql: """ + INSERT INTO session_metadata (sessionId, customName, createdAt, updatedAt, isPinned) + VALUES (?, ?, ?, ?, ?) + """, + arguments: [ + "legacy-session", + "legacy-name", + Date(timeIntervalSince1970: 1_000), + Date(timeIntervalSince1970: 2_000), + false, + ] + ) + + try db.create(table: "context_profiles") { t in + t.column("id", .text).primaryKey(onConflict: .replace) + t.column("projectPath", .text).notNull().defaults(to: "").indexed() + t.column("scope", .text).notNull() + t.column("name", .text).notNull() + t.column("isDefault", .boolean).notNull().defaults(to: false) + t.column("createdAt", .datetime).notNull() + t.column("updatedAt", .datetime).notNull() + t.column("payloadVersion", .integer).notNull() + t.column("payloadData", .blob).notNull() + } + try db.execute(sql: """ + CREATE UNIQUE INDEX idx_context_profiles_default + ON context_profiles(projectPath) WHERE isDefault = 1 + """) + try db.execute( + sql: """ + INSERT INTO context_profiles + (id, projectPath, scope, name, isDefault, createdAt, updatedAt, payloadVersion, payloadData) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + arguments: [ + "legacy-profile", + "/tmp/project", + "project", + "Legacy", + false, + Date(timeIntervalSince1970: 1_000), + Date(timeIntervalSince1970: 2_000), + 1, + Data("{\"files\":[],\"externalPaths\":[],\"textSnippets\":[],\"instructions\":\"\"}".utf8), + ] + ) +} + +private func temporaryLaunchContextDatabasePath() -> String { + FileManager.default.temporaryDirectory + .appending(path: "session_launch_context_\(UUID().uuidString).sqlite") + .path +} diff --git a/app/modules/Ghostty/Sources/Ghostty/AgentHubGhosttyTerminalSurface.swift b/app/modules/Ghostty/Sources/Ghostty/AgentHubGhosttyTerminalSurface.swift index 27f7b383..fe8be234 100644 --- a/app/modules/Ghostty/Sources/Ghostty/AgentHubGhosttyTerminalSurface.swift +++ b/app/modules/Ghostty/Sources/Ghostty/AgentHubGhosttyTerminalSurface.swift @@ -136,6 +136,7 @@ public final class AgentHubGhosttyTerminalSurface: NSView, EmbeddedTerminalSurfa cliConfiguration: CLICommandConfiguration, initialPrompt: String?, initialInputText: String?, + launchContext: String?, isDark: Bool, dangerouslySkipPermissions: Bool, permissionModePlan: Bool, @@ -149,17 +150,22 @@ public final class AgentHubGhosttyTerminalSurface: NSView, EmbeddedTerminalSurfa configuredSessionId = sessionId configuredProcessProvider = SessionProviderKind(cliMode: cliConfiguration.mode) configuredExpectedExecutable = cliConfiguration.executableName - self.metadataStore = metadataStore + // Keep the stored reference when a re-configure (e.g. restart) passes nil, + // so resume launches can still read AI config and persisted launch context. + if let metadataStore { + self.metadataStore = metadataStore + } let launch = EmbeddedTerminalLaunchBuilder.cliLaunch( sessionId: sessionId, projectPath: projectPath, cliConfiguration: cliConfiguration, initialPrompt: initialPrompt, + launchContext: launchContext, dangerouslySkipPermissions: dangerouslySkipPermissions, permissionModePlan: permissionModePlan, worktreeName: worktreeName, - metadataStore: metadataStore + metadataStore: self.metadataStore ) switch launch { @@ -229,6 +235,7 @@ public final class AgentHubGhosttyTerminalSurface: NSView, EmbeddedTerminalSurfa cliConfiguration: cliConfiguration, initialPrompt: nil, initialInputText: nil, + launchContext: nil, isDark: currentIsDark, dangerouslySkipPermissions: false, permissionModePlan: false,