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:
Nachx639
2026-03-13 02:35:39 +01:00
committed by GitHub
parent 268a8592de
commit e951a42bcb
15 changed files with 342 additions and 19 deletions

View File

@@ -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

View File

@@ -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))

View File

@@ -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 {}
}
}

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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",

View File

@@ -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)

View File

@@ -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"

View File

@@ -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: "Lukes MacBook Pro", sessionId: "sess-main")
let sessions = OpenClawChatSessionsListResponse(
ts: now,
path: nil,
count: 2,
defaults: OpenClawChatSessionsDefaults(
model: nil,
contextTokens: nil,
mainSessionKey: "Lukes MacBook Pro"),
sessions: [
OpenClawChatSessionEntry(
key: "Lukes MacBook Pro",
kind: nil,
displayName: "Lukes 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: "Lukes 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 == ["Lukes 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: "Lukes 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: "Lukes 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"])
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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";

View File

@@ -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,

View File

@@ -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 = {