mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-14 11:30:41 +00:00
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>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<OpenClawChatTransportEvent> {
|
||||
AsyncStream { continuation in
|
||||
let task = Task {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,11 +27,19 @@ public protocol OpenClawChatTransport: Sendable {
|
||||
func events() -> AsyncStream<OpenClawChatTransportEvent>
|
||||
|
||||
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",
|
||||
|
||||
@@ -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<String>()
|
||||
|
||||
// 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<String> = ["/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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<OpenClawChatTransportEvent>.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"])
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<typeof import("../../media/local-roots.js")>();
|
||||
return {
|
||||
...actual,
|
||||
getDefaultMediaLocalRoots: mocks.getDefaultMediaLocalRoots,
|
||||
getAgentScopedMediaLocalRoots: mocks.getAgentScopedMediaLocalRoots,
|
||||
};
|
||||
});
|
||||
|
||||
import { executePollAction, executeSendAction } from "./outbound-send-service.js";
|
||||
|
||||
|
||||
@@ -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<typeof createFetchMock>,
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user