From e951a42bcb863fd03495c9ad1781574f2dfdd6b2 Mon Sep 17 00:00:00 2001 From: Nachx639 <71144023+Nachx639@users.noreply.github.com> Date: Fri, 13 Mar 2026 02:35:39 +0100 Subject: [PATCH] fix(mac): adopt canonical session key and add reset triggers (#10898) Add shared native chat handling for /new, /reset, and /clear. This also aligns main session key handling in the shared chat UI and includes follow-up test and CI fixes needed to keep the branch mergeable. Co-authored-by: Nachx639 <71144023+Nachx639@users.noreply.github.com> Co-authored-by: Luke <92253590+ImLukeF@users.noreply.github.com> --- CHANGELOG.md | 1 + .../Chat/IOSGatewayChatTransport.swift | 7 + .../Tests/IOSGatewayChatTransportTests.swift | 5 + .../Sources/OpenClaw/WebChatSwiftUI.swift | 25 ++- .../OpenClawProtocol/GatewayModels.swift | 4 + .../Sources/OpenClawChatUI/ChatSessions.swift | 21 ++ .../OpenClawChatUI/ChatTransport.swift | 8 + .../OpenClawChatUI/ChatViewModel.swift | 51 ++++- .../OpenClawProtocol/GatewayModels.swift | 4 + .../OpenClawKitTests/ChatViewModelTests.swift | 186 +++++++++++++++++- ...p-recipient-besteffortdeliver-true.test.ts | 4 +- src/infra/exec-obfuscation-detect.ts | 4 +- .../outbound/outbound-send-service.test.ts | 12 +- src/memory/embeddings-voyage.test.ts | 14 ++ src/memory/embeddings.test.ts | 15 ++ 15 files changed, 342 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70230001f1d..365023ad617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -74,6 +74,7 @@ Docs: https://docs.openclaw.ai - Models/OpenRouter native ids: canonicalize native OpenRouter model keys across config writes, runtime lookups, fallback management, and `models list --plain`, and migrate legacy duplicated `openrouter/openrouter/...` config entries forward on write. - Gateway/hooks: bucket hook auth failures by forwarded client IP behind trusted proxies and warn when `hooks.allowedAgentIds` leaves hook routing unrestricted. - Agents/compaction: skip the post-compaction `cache-ttl` marker write when a compaction completed in the same attempt, preventing the next turn from immediately triggering a second tiny compaction. (#28548) thanks @MoerAI. +- Native chat/macOS: add `/new`, `/reset`, and `/clear` reset triggers, keep shared main-session aliases aligned, and ignore stale model-selection completions so native chat state stays in sync across reset and fast model changes. (#10898) Thanks @Nachx639. ## 2026.3.11 diff --git a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift index 67f01138803..297811d3ee7 100644 --- a/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift +++ b/apps/ios/Sources/Chat/IOSGatewayChatTransport.swift @@ -39,6 +39,13 @@ struct IOSGatewayChatTransport: OpenClawChatTransport, Sendable { // (chat.subscribe is a node event, not an operator RPC method.) } + func resetSession(sessionKey: String) async throws { + struct Params: Codable { var key: String } + let data = try JSONEncoder().encode(Params(key: sessionKey)) + let json = String(data: data, encoding: .utf8) + _ = try await self.gateway.request(method: "sessions.reset", paramsJSON: json, timeoutSeconds: 10) + } + func requestHistory(sessionKey: String) async throws -> OpenClawChatHistoryPayload { struct Params: Codable { var sessionKey: String } let data = try JSONEncoder().encode(Params(sessionKey: sessionKey)) diff --git a/apps/ios/Tests/IOSGatewayChatTransportTests.swift b/apps/ios/Tests/IOSGatewayChatTransportTests.swift index f49f242ff24..42526dd21c4 100644 --- a/apps/ios/Tests/IOSGatewayChatTransportTests.swift +++ b/apps/ios/Tests/IOSGatewayChatTransportTests.swift @@ -26,5 +26,10 @@ import Testing _ = try await transport.requestHealth(timeoutMs: 250) Issue.record("Expected requestHealth to throw when gateway not connected") } catch {} + + do { + try await transport.resetSession(sessionKey: "node-test") + Issue.record("Expected resetSession to throw when gateway not connected") + } catch {} } } diff --git a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift index 9110ce59faf..86c225f9ef0 100644 --- a/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift +++ b/apps/macos/Sources/OpenClaw/WebChatSwiftUI.swift @@ -59,7 +59,23 @@ struct MacGatewayChatTransport: OpenClawChatTransport { method: "sessions.list", params: params, timeoutMs: 15000) - return try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) + let decoded = try JSONDecoder().decode(OpenClawChatSessionsListResponse.self, from: data) + let mainSessionKey = await GatewayConnection.shared.cachedMainSessionKey() + let defaults = decoded.defaults.map { + OpenClawChatSessionsDefaults( + model: $0.model, + contextTokens: $0.contextTokens, + mainSessionKey: mainSessionKey) + } ?? OpenClawChatSessionsDefaults( + model: nil, + contextTokens: nil, + mainSessionKey: mainSessionKey) + return OpenClawChatSessionsListResponse( + ts: decoded.ts, + path: decoded.path, + count: decoded.count, + defaults: defaults, + sessions: decoded.sessions) } func setSessionModel(sessionKey: String, model: String?) async throws { @@ -103,6 +119,13 @@ struct MacGatewayChatTransport: OpenClawChatTransport { try await GatewayConnection.shared.healthOK(timeoutMs: timeoutMs) } + func resetSession(sessionKey: String) async throws { + _ = try await GatewayConnection.shared.request( + method: "sessions.reset", + params: ["key": AnyCodable(sessionKey)], + timeoutMs: 10000) + } + func events() -> AsyncStream { AsyncStream { continuation in let task = Task { diff --git a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift index 3ffe84fabb6..3003ae79f7b 100644 --- a/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/macos/Sources/OpenClawProtocol/GatewayModels.swift @@ -1322,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let key: String public let label: AnyCodable? public let thinkinglevel: AnyCodable? + public let fastmode: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? public let responseusage: AnyCodable? @@ -1343,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable { key: String, label: AnyCodable?, thinkinglevel: AnyCodable?, + fastmode: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, responseusage: AnyCodable?, @@ -1363,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.key = key self.label = label self.thinkinglevel = thinkinglevel + self.fastmode = fastmode self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel self.responseusage = responseusage @@ -1385,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable { case key case label case thinkinglevel = "thinkingLevel" + case fastmode = "fastMode" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" case responseusage = "responseUsage" diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift index 48f01e09c6a..c5a74c9a9aa 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatSessions.swift @@ -34,6 +34,13 @@ public struct OpenClawChatModelChoice: Identifiable, Codable, Sendable, Hashable public struct OpenClawChatSessionsDefaults: Codable, Sendable { public let model: String? public let contextTokens: Int? + public let mainSessionKey: String? + + public init(model: String?, contextTokens: Int?, mainSessionKey: String? = nil) { + self.model = model + self.contextTokens = contextTokens + self.mainSessionKey = mainSessionKey + } } public struct OpenClawChatSessionEntry: Codable, Identifiable, Sendable, Hashable { @@ -69,4 +76,18 @@ public struct OpenClawChatSessionsListResponse: Codable, Sendable { public let count: Int? public let defaults: OpenClawChatSessionsDefaults? public let sessions: [OpenClawChatSessionEntry] + + public init( + ts: Double?, + path: String?, + count: Int?, + defaults: OpenClawChatSessionsDefaults?, + sessions: [OpenClawChatSessionEntry]) + { + self.ts = ts + self.path = path + self.count = count + self.defaults = defaults + self.sessions = sessions + } } diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift index bfbd33bfda3..49bd91db372 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatTransport.swift @@ -27,11 +27,19 @@ public protocol OpenClawChatTransport: Sendable { func events() -> AsyncStream func setActiveSessionKey(_ sessionKey: String) async throws + func resetSession(sessionKey: String) async throws } extension OpenClawChatTransport { public func setActiveSessionKey(_: String) async throws {} + public func resetSession(sessionKey _: String) async throws { + throw NSError( + domain: "OpenClawChatTransport", + code: 0, + userInfo: [NSLocalizedDescriptionKey: "sessions.reset not supported by this transport"]) + } + public func abortRun(sessionKey _: String, runId _: String) async throws { throw NSError( domain: "OpenClawChatTransport", diff --git a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift index a136469fbd8..92413aefe64 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawChatUI/ChatViewModel.swift @@ -138,21 +138,23 @@ public final class OpenClawChatViewModel { let now = Date().timeIntervalSince1970 * 1000 let cutoff = now - (24 * 60 * 60 * 1000) let sorted = self.sessions.sorted { ($0.updatedAt ?? 0) > ($1.updatedAt ?? 0) } + let mainSessionKey = self.resolvedMainSessionKey var result: [OpenClawChatSessionEntry] = [] var included = Set() - // Always show the main session first, even if it hasn't been updated recently. - if let main = sorted.first(where: { $0.key == "main" }) { + // Always show the resolved main session first, even if it hasn't been updated recently. + if let main = sorted.first(where: { $0.key == mainSessionKey }) { result.append(main) included.insert(main.key) } else { - result.append(self.placeholderSession(key: "main")) - included.insert("main") + result.append(self.placeholderSession(key: mainSessionKey)) + included.insert(mainSessionKey) } for entry in sorted { guard !included.contains(entry.key) else { continue } + guard entry.key == self.sessionKey || !Self.isHiddenInternalSession(entry.key) else { continue } guard (entry.updatedAt ?? 0) >= cutoff else { continue } result.append(entry) included.insert(entry.key) @@ -169,6 +171,18 @@ public final class OpenClawChatViewModel { return result } + private var resolvedMainSessionKey: String { + let trimmed = self.sessionDefaults?.mainSessionKey? + .trimmingCharacters(in: .whitespacesAndNewlines) + return (trimmed?.isEmpty == false ? trimmed : nil) ?? "main" + } + + private static func isHiddenInternalSession(_ key: String) -> Bool { + let trimmed = key.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return false } + return trimmed == "onboarding" || trimmed.hasSuffix(":onboarding") + } + public var showsModelPicker: Bool { !self.modelChoices.isEmpty } @@ -365,10 +379,19 @@ public final class OpenClawChatViewModel { return "\(message.role)|\(timestamp)|\(text)" } + private static let resetTriggers: Set = ["/new", "/reset", "/clear"] + private func performSend() async { guard !self.isSending else { return } let trimmed = self.input.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty || !self.attachments.isEmpty else { return } + + if Self.resetTriggers.contains(trimmed.lowercased()) { + self.input = "" + await self.performReset() + return + } + let sessionKey = self.sessionKey guard self.healthOK else { @@ -499,6 +522,22 @@ public final class OpenClawChatViewModel { await self.bootstrap() } + private func performReset() async { + self.isLoading = true + self.errorText = nil + defer { self.isLoading = false } + + do { + try await self.transport.resetSession(sessionKey: self.sessionKey) + } catch { + self.errorText = error.localizedDescription + chatUILogger.error("session reset failed \(error.localizedDescription, privacy: .public)") + return + } + + await self.bootstrap() + } + private func performSelectThinkingLevel(_ level: String) async { let next = Self.normalizedThinkingLevel(level) ?? "off" guard next != self.thinkingLevel else { return } @@ -549,7 +588,9 @@ public final class OpenClawChatViewModel { sessionKey: sessionKey, model: nextModelRef) guard requestID == self.latestModelSelectionRequestIDsBySession[sessionKey] else { - self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: false) + // Keep older successful patches as rollback state, but do not replay + // stale UI/session state over a newer in-flight or completed selection. + self.lastSuccessfulModelSelectionIDsBySession[sessionKey] = next return } self.applySuccessfulModelSelection(next, sessionKey: sessionKey, syncSelection: true) diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 3ffe84fabb6..3003ae79f7b 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -1322,6 +1322,7 @@ public struct SessionsPatchParams: Codable, Sendable { public let key: String public let label: AnyCodable? public let thinkinglevel: AnyCodable? + public let fastmode: AnyCodable? public let verboselevel: AnyCodable? public let reasoninglevel: AnyCodable? public let responseusage: AnyCodable? @@ -1343,6 +1344,7 @@ public struct SessionsPatchParams: Codable, Sendable { key: String, label: AnyCodable?, thinkinglevel: AnyCodable?, + fastmode: AnyCodable?, verboselevel: AnyCodable?, reasoninglevel: AnyCodable?, responseusage: AnyCodable?, @@ -1363,6 +1365,7 @@ public struct SessionsPatchParams: Codable, Sendable { self.key = key self.label = label self.thinkinglevel = thinkinglevel + self.fastmode = fastmode self.verboselevel = verboselevel self.reasoninglevel = reasoninglevel self.responseusage = responseusage @@ -1385,6 +1388,7 @@ public struct SessionsPatchParams: Codable, Sendable { case key case label case thinkinglevel = "thinkingLevel" + case fastmode = "fastMode" case verboselevel = "verboseLevel" case reasoninglevel = "reasoningLevel" case responseusage = "responseUsage" diff --git a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift index abfd267a66c..6d1fa88e569 100644 --- a/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift +++ b/apps/shared/OpenClawKit/Tests/OpenClawKitTests/ChatViewModelTests.swift @@ -83,6 +83,7 @@ private func makeViewModel( historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil, setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil, initialThinkingLevel: String? = nil, @@ -93,6 +94,7 @@ private func makeViewModel( historyResponses: historyResponses, sessionsResponses: sessionsResponses, modelResponses: modelResponses, + resetSessionHook: resetSessionHook, setSessionModelHook: setSessionModelHook, setSessionThinkingHook: setSessionThinkingHook) let vm = await MainActor.run { @@ -199,6 +201,7 @@ private actor TestChatTransportState { var historyCallCount: Int = 0 var sessionsCallCount: Int = 0 var modelsCallCount: Int = 0 + var resetSessionKeys: [String] = [] var sentRunIds: [String] = [] var sentThinkingLevels: [String] = [] var abortedRunIds: [String] = [] @@ -211,6 +214,7 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor private let historyResponses: [OpenClawChatHistoryPayload] private let sessionsResponses: [OpenClawChatSessionsListResponse] private let modelResponses: [[OpenClawChatModelChoice]] + private let resetSessionHook: (@Sendable (String) async throws -> Void)? private let setSessionModelHook: (@Sendable (String?) async throws -> Void)? private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)? @@ -221,12 +225,14 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor historyResponses: [OpenClawChatHistoryPayload], sessionsResponses: [OpenClawChatSessionsListResponse] = [], modelResponses: [[OpenClawChatModelChoice]] = [], + resetSessionHook: (@Sendable (String) async throws -> Void)? = nil, setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil, setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil) { self.historyResponses = historyResponses self.sessionsResponses = sessionsResponses self.modelResponses = modelResponses + self.resetSessionHook = resetSessionHook self.setSessionModelHook = setSessionModelHook self.setSessionThinkingHook = setSessionThinkingHook var cont: AsyncStream.Continuation! @@ -301,6 +307,13 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor } } + func resetSession(sessionKey: String) async throws { + await self.state.resetSessionKeysAppend(sessionKey) + if let resetSessionHook = self.resetSessionHook { + try await resetSessionHook(sessionKey) + } + } + func setSessionThinking(sessionKey _: String, thinkingLevel: String) async throws { await self.state.patchedThinkingLevelsAppend(thinkingLevel) if let setSessionThinkingHook = self.setSessionThinkingHook { @@ -336,6 +349,10 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor func patchedThinkingLevels() async -> [String] { await self.state.patchedThinkingLevels } + + func resetSessionKeys() async -> [String] { + await self.state.resetSessionKeys + } } extension TestChatTransportState { @@ -370,6 +387,10 @@ extension TestChatTransportState { fileprivate func patchedThinkingLevelsAppend(_ v: String) { self.patchedThinkingLevels.append(v) } + + fileprivate func resetSessionKeysAppend(_ v: String) { + self.resetSessionKeys.append(v) + } } @Suite struct ChatViewModelTests { @@ -592,6 +613,151 @@ extension TestChatTransportState { #expect(keys == ["main", "custom"]) } + @Test func sessionChoicesUseResolvedMainSessionKeyInsteadOfLiteralMain() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (30 * 60 * 1000) + let recentOlder = now - (90 * 60 * 1000) + let history = historyPayload(sessionKey: "Luke’s MacBook Pro", sessionId: "sess-main") + let sessions = OpenClawChatSessionsListResponse( + ts: now, + path: nil, + count: 2, + defaults: OpenClawChatSessionsDefaults( + model: nil, + contextTokens: nil, + mainSessionKey: "Luke’s MacBook Pro"), + sessions: [ + OpenClawChatSessionEntry( + key: "Luke’s MacBook Pro", + kind: nil, + displayName: "Luke’s MacBook Pro", + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: nil, + model: nil, + contextTokens: nil), + sessionEntry(key: "recent-1", updatedAt: recentOlder), + ]) + + let (_, vm) = await makeViewModel( + sessionKey: "Luke’s MacBook Pro", + historyResponses: [history], + sessionsResponses: [sessions]) + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["Luke’s MacBook Pro", "recent-1"]) + } + + @Test func sessionChoicesHideInternalOnboardingSession() async throws { + let now = Date().timeIntervalSince1970 * 1000 + let recent = now - (2 * 60 * 1000) + let recentOlder = now - (5 * 60 * 1000) + let history = historyPayload(sessionKey: "agent:main:main", sessionId: "sess-main") + let sessions = OpenClawChatSessionsListResponse( + ts: now, + path: nil, + count: 2, + defaults: OpenClawChatSessionsDefaults( + model: nil, + contextTokens: nil, + mainSessionKey: "agent:main:main"), + sessions: [ + OpenClawChatSessionEntry( + key: "agent:main:onboarding", + kind: nil, + displayName: "Luke’s MacBook Pro", + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recent, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: nil, + model: nil, + contextTokens: nil), + OpenClawChatSessionEntry( + key: "agent:main:main", + kind: nil, + displayName: "Luke’s MacBook Pro", + surface: nil, + subject: nil, + room: nil, + space: nil, + updatedAt: recentOlder, + sessionId: nil, + systemSent: nil, + abortedLastRun: nil, + thinkingLevel: nil, + verboseLevel: nil, + inputTokens: nil, + outputTokens: nil, + totalTokens: nil, + modelProvider: nil, + model: nil, + contextTokens: nil), + ]) + + let (_, vm) = await makeViewModel( + sessionKey: "agent:main:main", + historyResponses: [history], + sessionsResponses: [sessions]) + await MainActor.run { vm.load() } + try await waitUntil("sessions loaded") { await MainActor.run { !vm.sessions.isEmpty } } + + let keys = await MainActor.run { vm.sessionChoices.map(\.key) } + #expect(keys == ["agent:main:main"]) + } + + @Test func resetTriggerResetsSessionAndReloadsHistory() async throws { + let before = historyPayload( + messages: [ + chatTextMessage(role: "assistant", text: "before reset", timestamp: 1), + ]) + let after = historyPayload( + messages: [ + chatTextMessage(role: "assistant", text: "after reset", timestamp: 2), + ]) + + let (transport, vm) = await makeViewModel(historyResponses: [before, after]) + try await loadAndWaitBootstrap(vm: vm) + try await waitUntil("initial history loaded") { + await MainActor.run { vm.messages.first?.content.first?.text == "before reset" } + } + + await MainActor.run { + vm.input = "/new" + vm.send() + } + + try await waitUntil("reset called") { + await transport.resetSessionKeys() == ["main"] + } + try await waitUntil("history reloaded") { + await MainActor.run { vm.messages.first?.content.first?.text == "after reset" } + } + #expect(await transport.lastSentRunId() == nil) + } + @Test func bootstrapsModelSelectionFromSessionAndDefaults() async throws { let now = Date().timeIntervalSince1970 * 1000 let history = historyPayload() @@ -758,7 +924,8 @@ extension TestChatTransportState { } #expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4-pro") - #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4-pro") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4-pro") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") } @Test func sendWaitsForInFlightModelPatchToFinish() async throws { @@ -852,11 +1019,15 @@ extension TestChatTransportState { } try await waitUntil("older model completion wins after latest failure") { - await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" } + await MainActor.run { + vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" && + vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai" + } } #expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4") - #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } @@ -1012,12 +1183,17 @@ extension TestChatTransportState { } try await waitUntil("late model completion updates only the original session") { - await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model == "openai/gpt-5.4" } + await MainActor.run { + vm.sessions.first(where: { $0.key == "main" })?.model == "gpt-5.4" && + vm.sessions.first(where: { $0.key == "main" })?.modelProvider == "openai" + } } #expect(await MainActor.run { vm.modelSelectionID } == "openai/gpt-5.4") - #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "openai/gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.model } == "gpt-5.4") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "main" })?.modelProvider } == "openai") #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.model } == "openai/gpt-5.4-pro") + #expect(await MainActor.run { vm.sessions.first(where: { $0.key == "other" })?.modelProvider } == nil) #expect(await transport.patchedModels() == ["openai/gpt-5.4", "openai/gpt-5.4-pro"]) } diff --git a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts index 43fe8c5d688..b9c0fddb3a3 100644 --- a/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts +++ b/src/cron/isolated-agent.skips-delivery-without-whatsapp-recipient-besteffortdeliver-true.test.ts @@ -197,7 +197,7 @@ describe("runCronIsolatedAgentTurn", () => { setupIsolatedAgentTurnMocks(); }); - it("delivers explicit targets directly", async () => { + it("delivers explicit targets with direct text", async () => { await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { await assertExplicitTelegramTargetDelivery({ home, @@ -209,7 +209,7 @@ describe("runCronIsolatedAgentTurn", () => { }); }); - it("delivers explicit targets with final payload text", async () => { + it("delivers explicit targets with final-payload text", async () => { await withTelegramAnnounceFixture(async ({ home, storePath, deps }) => { await assertExplicitTelegramTargetDelivery({ home, diff --git a/src/infra/exec-obfuscation-detect.ts b/src/infra/exec-obfuscation-detect.ts index 015001f7b7e..18a4c581d82 100644 --- a/src/infra/exec-obfuscation-detect.ts +++ b/src/infra/exec-obfuscation-detect.ts @@ -236,8 +236,8 @@ export function detectCommandObfuscation(command: string): ObfuscationDetection continue; } - const suppressed = - pattern.id === "curl-pipe-shell" && urlCount <= 1 && shouldSuppressCurlPipeShell(command); + const suppressed = + pattern.id === "curl-pipe-shell" && urlCount <= 1 && shouldSuppressCurlPipeShell(command); if (suppressed) { continue; diff --git a/src/infra/outbound/outbound-send-service.test.ts b/src/infra/outbound/outbound-send-service.test.ts index 5988e337dda..4c2580344ba 100644 --- a/src/infra/outbound/outbound-send-service.test.ts +++ b/src/infra/outbound/outbound-send-service.test.ts @@ -17,10 +17,14 @@ vi.mock("./message.js", () => ({ sendPoll: mocks.sendPoll, })); -vi.mock("../../media/local-roots.js", () => ({ - getDefaultMediaLocalRoots: mocks.getDefaultMediaLocalRoots, - getAgentScopedMediaLocalRoots: mocks.getAgentScopedMediaLocalRoots, -})); +vi.mock("../../media/local-roots.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getDefaultMediaLocalRoots: mocks.getDefaultMediaLocalRoots, + getAgentScopedMediaLocalRoots: mocks.getAgentScopedMediaLocalRoots, + }; +}); import { executePollAction, executeSendAction } from "./outbound-send-service.js"; diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts index 4851d3743da..2f4bedc87c3 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/memory/embeddings-voyage.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as authModule from "../agents/model-auth.js"; +import * as ssrf from "../infra/net/ssrf.js"; import { type FetchMock, withFetchPreconnect } from "../test-utils/fetch-mock.js"; import { createVoyageEmbeddingProvider, normalizeVoyageModel } from "./embeddings-voyage.js"; @@ -27,6 +28,18 @@ function mockVoyageApiKey() { }); } +function mockPublicPinnedHostname() { + return vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); +} + async function createDefaultVoyageProvider( model: string, fetchMock: ReturnType, @@ -77,6 +90,7 @@ describe("voyage embedding provider", () => { it("respects remote overrides for baseUrl and apiKey", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); const result = await createVoyageEmbeddingProvider({ config: {} as never, diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index df22885fefd..206eb53326f 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import * as authModule from "../agents/model-auth.js"; +import * as ssrf from "../infra/net/ssrf.js"; import { DEFAULT_GEMINI_EMBEDDING_MODEL } from "./embeddings-gemini.js"; import { createEmbeddingProvider, DEFAULT_LOCAL_MODEL } from "./embeddings.js"; @@ -32,6 +33,18 @@ function readFirstFetchRequest(fetchMock: { mock: { calls: unknown[][] } }) { return { url, init: init as RequestInit | undefined }; } +function mockPublicPinnedHostname() { + return vi.spyOn(ssrf, "resolvePinnedHostnameWithPolicy").mockImplementation(async (hostname) => { + const normalized = hostname.trim().toLowerCase().replace(/\.$/, ""); + const addresses = ["93.184.216.34"]; + return { + hostname: normalized, + addresses, + lookup: ssrf.createPinnedLookup({ hostname: normalized, addresses }), + }; + }); +} + afterEach(() => { vi.resetAllMocks(); vi.unstubAllGlobals(); @@ -92,6 +105,7 @@ describe("embedding provider remote overrides", () => { it("uses remote baseUrl/apiKey and merges headers", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey("provider-key"); const cfg = { @@ -141,6 +155,7 @@ describe("embedding provider remote overrides", () => { it("falls back to resolved api key when remote apiKey is blank", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey("provider-key"); const cfg = {