Compare commits

..

3 Commits

413 changed files with 5678 additions and 20460 deletions

View File

@@ -317,23 +317,6 @@ pnpm release:check
pnpm test:install:smoke
```
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
package name did not exist in the base tag, verify the target registry package
already exists in npm/ClawHub or stop and help the owner mint/prepublish the
package first. Do not hide or disable release surfaces just to unblock a
train unless the owner explicitly decides the plugin should not ship in that
release; first-package registry ownership is release prep, not product
rollback. The mint/prepublish path must either be the real release publish
path for the auto-bumped beta version, or a deliberately non-consuming
registry-prep step that cannot occupy the next beta version/tag. Confirm
registry owner, npm scope/package-creation permission, provenance path, and
first-package publish plan before the full release publish continues. Useful
npm probe:
`npm view <package-name> version dist-tags --json --prefer-online`; a 404 for
a package newly added to the release is a release-prep blocker, not something
to discover from the publish job.
- Use `pnpm qa:otel:smoke` when release validation needs telemetry coverage.
It starts a local OTLP/HTTP trace receiver, runs QA-lab's
`otel-trace-smoke`, and checks span names plus content/identifier redaction
@@ -579,11 +562,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
- Use `NPM_TOKEN` only for explicit npm dist-tag management modes, because npm
does not support trusted publishing for `npm dist-tag add`.
- `@openclaw/*` plugin publishes use a separate maintainer-only flow.
- Publishable plugins that are new to npm require owner-led first-package
minting before the full release publish. Do not consume the next beta version
with an ad-hoc manual package publish; use the release-owned auto-bumped
version path, or a non-consuming registry setup/preflight step. Bundled
disk-tree-only plugins stay unpublished.
- Only publish plugins that already exist on npm; bundled disk-tree-only plugins stay unpublished.
## Fallback local mac publish
@@ -640,9 +619,7 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
mac app, signing, notarization, and appcast path.
12. Confirm the target npm version is not already published.
13. Create and push the git tag from the release branch.
14. Do not create or publish the matching GitHub release page yet. The real
publish workflow creates or undrafts it only after postpublish verification
and release evidence upload pass.
14. Create or refresh the matching GitHub release.
15. Dispatch Actions > `QA-Lab - All Lanes` against the release tag and wait
for the mock parity, live Matrix, and live Telegram credentialed-channel
lanes to pass.
@@ -665,29 +642,20 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
with `preflight_only=true` and wait for it to pass. Save that run id because
the real publish requires it to reuse the notarized mac artifacts.
21. If any preflight or validation run fails, fix the issue on a new commit,
delete the tag and any accidental draft/incomplete GitHub release, recreate
the tag from the fixed commit, and rerun all relevant preflights from
scratch before continuing. Never reuse old preflight results after the
commit changes. Once the npm version exists, do not rerun the publish
workflow for that same version; finalize the existing draft/evidence state
manually or cut a correction tag. For pushed or published beta tags, do not
delete/recreate; increment to the next beta tag. For preflight-only failures
where npm did not publish the beta version, delete/recreate the same beta
tag and any accidental draft/incomplete prerelease at the fixed commit
instead of skipping a prerelease number.
delete the tag and matching GitHub release, recreate them from the fixed
commit, and rerun all relevant preflights from scratch before continuing.
Never reuse old preflight results after the commit changes. For pushed or
published beta tags, do not delete/recreate; increment to the next beta tag.
For preflight-only failures where npm did not publish the beta version,
delete/recreate the same beta tag and prerelease at the fixed commit instead
of skipping a prerelease number.
22. Start `.github/workflows/openclaw-npm-release.yml` from the same branch with
the same tag for the real publish, choose `npm_dist_tag` (`beta` default,
`latest` only when you intentionally want direct stable publish), keep it
the same as the preflight run, and pass the successful npm
`preflight_run_id`.
23. Wait for `npm-release` approval from `@openclaw/openclaw-release-managers`.
24. Wait for the real publish workflow to run postpublish verification,
create or update the GitHub release as a draft, upload dependency evidence,
append release verification proof, and only then undraft/publish it. If a
waited plugin publish fails after OpenClaw npm succeeds, the workflow keeps
the release draft with OpenClaw npm evidence and exits red; do not undraft
until the plugin publish gap is repaired. The standalone verifier command
remains the recovery probe:
24. Run postpublish verification:
`node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>`.
25. Run the post-published beta verification roster. First scan current `main`
for critical fixes that landed after the release branch cut; backport only

View File

@@ -653,63 +653,6 @@ jobs:
done
}
guard_existing_public_release() {
local release_version asset_name release_json is_draft has_sha has_proof has_asset release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
if ! release_json="$(gh release view "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --json isDraft,assets,body,url 2>/dev/null)"; then
return 0
fi
is_draft="$(printf '%s' "${release_json}" | jq -r '.isDraft')"
if [[ "${is_draft}" == "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
asset_name="openclaw-${release_version}-dependency-evidence.zip"
has_sha="$(printf '%s' "${release_json}" | jq --arg sha "${TARGET_SHA}" -r '.body | contains($sha)')"
has_proof="$(printf '%s' "${release_json}" | jq -r '.body | contains("### Release verification")')"
has_asset="$(printf '%s' "${release_json}" | jq --arg name "${asset_name}" -r 'any(.assets[]?; .name == $name)')"
release_url="$(printf '%s' "${release_json}" | jq -r '.url')"
if [[ "${has_sha}" == "true" && "${has_proof}" == "true" && "${has_asset}" == "true" ]]; then
return 0
fi
{
echo "Release ${RELEASE_TAG} already has a public GitHub release page without complete postpublish evidence for ${TARGET_SHA}."
echo "Refusing to reuse a public prerelease tag after publication started: ${release_url}"
echo "Create a new beta tag or delete/draft the incomplete public release before retrying."
} >&2
exit 1
}
guard_openclaw_npm_not_already_published() {
local release_version release_url
if [[ "${PUBLISH_OPENCLAW_NPM}" != "true" ]]; then
return 0
fi
release_version="${RELEASE_TAG#v}"
if ! npm view "openclaw@${release_version}" version >/dev/null 2>&1; then
return 0
fi
release_url="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}"
{
echo "openclaw@${release_version} is already published on npm."
echo "Refusing to dispatch publish child workflows for an already-published version."
echo "If this is recovery from a failed postpublish evidence or draft-release step, repair/finalize the existing draft or create a correction tag; do not rerun the publish workflow for the same npm version."
echo "Release page, if present: ${release_url}"
} >&2
exit 1
}
create_or_update_github_release() {
local release_version notes_version title notes_file changelog_file latest_arg prerelease_args
release_version="${RELEASE_TAG#v}"
@@ -755,17 +698,11 @@ jobs:
else
gh release create "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" \
--verify-tag \
--draft \
--title "${title}" \
--notes-file "${notes_file}" \
"${prerelease_args[@]}" \
"${latest_arg}"
fi
echo "- GitHub release draft: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
publish_github_release() {
gh release edit "${RELEASE_TAG}" --repo "$GITHUB_REPOSITORY" --draft=false
echo "- GitHub release: https://github.com/${GITHUB_REPOSITORY}/releases/tag/${RELEASE_TAG}" >> "$GITHUB_STEP_SUMMARY"
}
@@ -798,11 +735,9 @@ jobs:
}
verify_published_release() {
local release_version evidence_path skip_clawhub
local release_version evidence_path
local -a verify_args
skip_clawhub="${1:-false}"
release_version="${RELEASE_TAG#v}"
evidence_path="${POSTPUBLISH_EVIDENCE_DIR}/release-postpublish-evidence.json"
mkdir -p "${POSTPUBLISH_EVIDENCE_DIR}"
@@ -819,12 +754,11 @@ jobs:
--plugin-npm-run "${plugin_npm_run_id}"
--openclaw-npm-run "${openclaw_npm_run_id}"
--evidence-out "${evidence_path}"
--skip-github-release
)
if [[ "${skip_clawhub}" == "true" || "${WAIT_FOR_CLAWHUB}" != "true" ]]; then
verify_args+=(--skip-clawhub)
else
if [[ "${WAIT_FOR_CLAWHUB}" == "true" ]]; then
verify_args+=(--plugin-clawhub-run "${plugin_clawhub_run_id}")
else
verify_args+=(--skip-clawhub)
fi
if [[ -n "${PLUGINS// }" ]]; then
verify_args+=(--plugins "${PLUGINS}")
@@ -865,7 +799,6 @@ jobs:
RELEASE_NOTES_FILE="${notes_file}" \
RELEASE_VERSION="${release_version}" \
RELEASE_TAG="${RELEASE_TAG}" \
RELEASE_SHA="${TARGET_SHA}" \
RELEASE_REPO="${GITHUB_REPOSITORY}" \
RELEASE_TARBALL="${tarball}" \
RELEASE_INTEGRITY="${integrity}" \
@@ -892,7 +825,6 @@ jobs:
`- npm package: https://www.npmjs.com/package/openclaw/v/${process.env.RELEASE_VERSION}`,
`- registry tarball: ${process.env.RELEASE_TARBALL}`,
`- integrity: \`${process.env.RELEASE_INTEGRITY}\``,
`- release SHA: \`${process.env.RELEASE_SHA}\``,
`- full release CI report: https://github.com/openclaw/releases/blob/main/evidence/${process.env.RELEASE_VERSION}/release-evidence.md`,
`- release publish: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.RELEASE_PUBLISH_RUN_ID}`,
`- npm preflight: https://github.com/${process.env.RELEASE_REPO}/actions/runs/${process.env.PREFLIGHT_RUN_ID}`,
@@ -931,9 +863,6 @@ jobs:
fi
} >> "$GITHUB_STEP_SUMMARY"
guard_existing_public_release
guard_openclaw_npm_not_already_published
npm_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
clawhub_args=(-f publish_scope="${PLUGIN_PUBLISH_SCOPE}" -f ref="${TARGET_SHA}" -f release_publish_run_id="${GITHUB_RUN_ID}")
if [[ -n "${PLUGINS}" ]]; then
@@ -1005,6 +934,11 @@ jobs:
openclaw_failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
create_or_update_github_release
upload_dependency_evidence_release_asset
fi
if [[ -n "${clawhub_pid}" ]] && ! wait "${clawhub_pid}"; then
failed=1
fi
@@ -1012,20 +946,9 @@ jobs:
failed=1
fi
if [[ -n "${openclaw_npm_run_id}" && "${openclaw_failed}" == "0" ]]; then
if [[ "${failed}" == "0" ]]; then
verify_published_release
else
verify_published_release true
fi
create_or_update_github_release
upload_dependency_evidence_release_asset
if [[ "${failed}" == "0" && -n "${openclaw_npm_run_id}" ]]; then
verify_published_release
append_release_proof_to_github_release
if [[ "${failed}" == "0" ]]; then
publish_github_release
else
echo "- GitHub release: left as draft because a required publish child failed" >> "$GITHUB_STEP_SUMMARY"
fi
fi
if [[ "${failed}" != "0" ]]; then
exit 1

View File

@@ -64,7 +64,6 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents: `sessions_send` now honors an explicit `sessionKey` when stale label metadata is also present, and denied session-id sends no longer echo the resolved canonical session key. Fixes #64699; refs #74009 and #41199. Thanks @Mintalix, @RevisitMoon, and @Mocha-s.
- Channel content boundaries: QQBot now strips reasoning/thinking tags before sending, preserving final answers while hiding internal model narration from users. (#89913, #90132) Thanks @openperf.
- Agents/MCP/providers: coerce non-text/image MCP tool-result blocks before they reach provider converters, preserving valid images and turning richer MCP content into text instead of malformed image blocks. (#90710, #90728) Thanks @RanSHammer and @849261680.
- Anthropic/Codex/ACP/agent recovery: defer Anthropic stream start events until `message_start`, strip stale compaction thinking signatures before Anthropic replay, detect unsigned thinking-only stalls, refresh prompt fences after compaction writes, reject empty completion handoffs, preserve parent streaming-off overrides/shared progress commentary, forward heartbeat metadata to context-engine hooks, and cover Codex session/thread migration edge cases. (#90667, #90697, #90163, #90108, #89874, #89505, #90632, #89302, #90729, #90317, #90319) Thanks @openperf, @100yenadmin, and @ooiuuii.

View File

@@ -72,7 +72,7 @@ final class CronJobsStore {
do {
if let status = try? await GatewayConnection.shared.cronStatus() {
self.schedulerEnabled = status.enabled
self.schedulerStorePath = status.sqlitePath ?? status.storePath
self.schedulerStorePath = status.storePath
self.schedulerNextWakeAtMs = status.nextWakeAtMs
}
self.jobs = try await GatewayConnection.shared.cronList(includeDisabled: true)

View File

@@ -1,5 +1,4 @@
import CryptoKit
import Darwin
import Foundation
import OSLog
import Security
@@ -230,12 +229,6 @@ enum ExecApprovalsStore {
private static let secureStateDirPermissions = 0o700
private static let fileLock = NSRecursiveLock()
private enum LegacyMigrationResult {
case notNeeded
case migrated
case blocked
}
private static func withFileLock<T>(_ body: () throws -> T) rethrows -> T {
self.fileLock.lock()
defer { self.fileLock.unlock() }
@@ -250,195 +243,6 @@ enum ExecApprovalsStore {
OpenClawPaths.stateDirURL.appendingPathComponent("exec-approvals.sock").path
}
private static func legacyStateDirURLs() -> [URL] {
if let home = OpenClawEnv.path("OPENCLAW_HOME") {
var urls = [
URL(fileURLWithPath: home, isDirectory: true)
.appendingPathComponent(".openclaw", isDirectory: true),
]
let osHomeURL = FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true)
if !urls.contains(where: {
$0.standardizedFileURL.path == osHomeURL.standardizedFileURL.path
}) {
urls.append(osHomeURL)
}
return urls
}
return [
FileManager().homeDirectoryForCurrentUser
.appendingPathComponent(".openclaw", isDirectory: true),
]
}
private static func legacyFileURLIfPending() -> URL? {
guard OpenClawEnv.path("OPENCLAW_STATE_DIR") != nil else { return nil }
let targetURL = self.fileURL()
for stateDirURL in self.legacyStateDirURLs() {
let legacyURL = stateDirURL
.appendingPathComponent("exec-approvals.json", isDirectory: false)
guard legacyURL.standardizedFileURL.path != targetURL.standardizedFileURL.path else {
continue
}
guard FileManager().fileExists(atPath: legacyURL.path) else { continue }
guard !FileManager().fileExists(atPath: targetURL.path) else { return nil }
return legacyURL
}
return nil
}
private static func unmigratedLegacyFallbackFile() -> ExecApprovalsFile {
ExecApprovalsFile(
version: 1,
socket: nil,
defaults: ExecApprovalsDefaults(
security: .deny,
ask: .always,
askFallback: .deny,
autoAllowSkills: nil),
agents: [:])
}
private static func isLegacyDefaultSocketPath(_ raw: String, legacyFileURL: URL) -> Bool {
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return true }
let expanded = self.expandPath(trimmed)
let legacySocket = legacyFileURL.deletingLastPathComponent()
.appendingPathComponent("exec-approvals.sock", isDirectory: false)
.path
return URL(fileURLWithPath: expanded).standardizedFileURL.path
== URL(fileURLWithPath: legacySocket).standardizedFileURL.path
}
private static func hasSymlinkParent(_ url: URL) -> Bool {
var cursor = url.deletingLastPathComponent()
let manager = FileManager()
while true {
var isDirectory = ObjCBool(false)
if manager.fileExists(atPath: cursor.path, isDirectory: &isDirectory) {
if (try? manager.destinationOfSymbolicLink(atPath: cursor.path)) != nil {
return true
}
}
let parent = cursor.deletingLastPathComponent()
if parent.path == cursor.path { return false }
cursor = parent
}
}
private static func archiveMigratedLegacyFile(_ legacyURL: URL) throws -> URL {
let manager = FileManager()
var archiveURL = URL(fileURLWithPath: "\(legacyURL.path).migrated")
if manager.fileExists(atPath: archiveURL.path) {
archiveURL = URL(fileURLWithPath: "\(archiveURL.path)-\(UUID().uuidString)")
}
try manager.moveItem(at: legacyURL, to: archiveURL)
return archiveURL
}
private static func writeMigratedFileExclusively(_ data: Data, to targetURL: URL) throws -> Bool {
let tempURL = targetURL.deletingLastPathComponent()
.appendingPathComponent(".exec-approvals.migration.\(UUID().uuidString)")
let fd = open(tempURL.path, O_WRONLY | O_CREAT | O_EXCL, S_IRUSR | S_IWUSR)
if fd == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
var closed = false
defer {
if !closed { close(fd) }
}
do {
try data.withUnsafeBytes { rawBuffer in
guard let base = rawBuffer.baseAddress else { return }
var offset = 0
while offset < rawBuffer.count {
let written = Darwin.write(
fd,
base.advanced(by: offset),
rawBuffer.count - offset)
if written < 0 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
offset += written
}
}
close(fd)
closed = true
let copied = copyfile(
tempURL.path,
targetURL.path,
nil,
copyfile_flags_t(COPYFILE_EXCL))
if copied == -1 {
if errno == EEXIST {
try? FileManager().removeItem(at: tempURL)
return false
}
try? FileManager().removeItem(at: targetURL)
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO)
}
try? FileManager().removeItem(at: tempURL)
return true
} catch {
try? FileManager().removeItem(at: tempURL)
throw error
}
}
private static func migrateLegacyFileIfNeeded() -> LegacyMigrationResult {
guard let legacyURL = self.legacyFileURLIfPending() else { return .notNeeded }
let targetURL = self.fileURL()
do {
if self.hasSymlinkParent(targetURL) {
throw NSError(domain: "ExecApprovals", code: 10, userInfo: [
NSLocalizedDescriptionKey: "target path has a symlink parent",
])
}
let data = try Data(contentsOf: legacyURL)
var file = try JSONDecoder().decode(ExecApprovalsFile.self, from: data)
guard file.version == 1 else {
throw NSError(domain: "ExecApprovals", code: 11, userInfo: [
NSLocalizedDescriptionKey: "unsupported legacy approvals version",
])
}
file = self.normalizeIncoming(file)
let rawSocketPath = file.socket?.path?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if self.isLegacyDefaultSocketPath(rawSocketPath, legacyFileURL: legacyURL) {
if file.socket == nil {
file.socket = ExecApprovalsSocketConfig(path: nil, token: nil)
}
file.socket?.path = self.socketPath()
}
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
let migrated = try encoder.encode(file)
self.ensureSecureStateDirectory()
try FileManager().createDirectory(
at: targetURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if FileManager().fileExists(atPath: targetURL.path) { return .notNeeded }
let created = try self.writeMigratedFileExclusively(migrated, to: targetURL)
if !created { return .notNeeded }
try? FileManager().setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: targetURL.path)
do {
_ = try self.archiveMigratedLegacyFile(legacyURL)
} catch {
self.logger
.warning(
"exec approvals legacy archive failed: \(error.localizedDescription, privacy: .public)")
}
return .migrated
} catch {
self.logger
.error(
"exec approvals legacy migration failed: \(error.localizedDescription, privacy: .public)")
return .blocked
}
}
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
@@ -474,14 +278,6 @@ enum ExecApprovalsStore {
static func readSnapshot() -> ExecApprovalsSnapshot {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
let file = self.unmigratedLegacyFallbackFile()
return ExecApprovalsSnapshot(
path: self.fileURL().path,
exists: false,
hash: self.hashRaw(nil),
file: file)
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsSnapshot(
@@ -526,14 +322,6 @@ enum ExecApprovalsStore {
static func loadFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
let url = self.fileURL()
guard FileManager().fileExists(atPath: url.path) else {
return ExecApprovalsFile(version: 1, socket: nil, defaults: nil, agents: [:])
@@ -573,14 +361,6 @@ enum ExecApprovalsStore {
static func ensureFile() -> ExecApprovalsFile {
self.withFileLock {
if self.legacyFileURLIfPending() != nil {
switch self.migrateLegacyFileIfNeeded() {
case .migrated, .notNeeded:
break
case .blocked:
return self.unmigratedLegacyFallbackFile()
}
}
self.ensureSecureStateDirectory()
let url = self.fileURL()
let existed = FileManager().fileExists(atPath: url.path)

View File

@@ -775,7 +775,6 @@ extension GatewayConnection {
struct CronSchedulerStatus: Decodable {
let enabled: Bool
let storePath: String
let sqlitePath: String?
let jobs: Int
let nextWakeAtMs: Int?
}

View File

@@ -16,23 +16,6 @@ struct ExecApprovalsStoreRefactorTests {
}
}
private func withTempHomeAndStateDir(
_ body: @escaping @Sendable (URL, URL) async throws -> Void) async throws
{
let root = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-home-state-\(UUID().uuidString)", isDirectory: true)
let home = root.appendingPathComponent("home", isDirectory: true)
let stateDir = root.appendingPathComponent("state", isDirectory: true)
defer { try? FileManager().removeItem(at: root) }
try await TestIsolation.withEnvValues([
"OPENCLAW_HOME": home.path,
"OPENCLAW_STATE_DIR": stateDir.path,
]) {
try await body(home, stateDir)
}
}
@Test
func `ensure file skips rewrite when unchanged`() async throws {
try await self.withTempStateDir { _ in
@@ -47,50 +30,6 @@ struct ExecApprovalsStoreRefactorTests {
}
}
@Test
func `ensure file migrates default approvals into custom state dir`() async throws {
try await self.withTempHomeAndStateDir { home, stateDir in
let legacyDir = home.appendingPathComponent(".openclaw", isDirectory: true)
try FileManager().createDirectory(
at: legacyDir,
withIntermediateDirectories: true)
let legacySocket = legacyDir.appendingPathComponent("exec-approvals.sock").path
let legacyFile = legacyDir.appendingPathComponent("exec-approvals.json")
let legacyJson = """
{
"version": 1,
"socket": {
"path": "\(legacySocket)",
"token": "legacy-token"
},
"defaults": {
"security": "deny",
"ask": "always"
},
"agents": {
"main": {
"allowlist": [{ "pattern": "git status" }]
}
}
}
"""
try Data(legacyJson.utf8).write(to: legacyFile)
let file = ExecApprovalsStore.ensureFile()
let targetURL = ExecApprovalsStore.fileURL()
#expect(targetURL.path == stateDir.appendingPathComponent("exec-approvals.json").path)
#expect(FileManager().fileExists(atPath: targetURL.path))
#expect(file.socket?.path == stateDir.appendingPathComponent("exec-approvals.sock").path)
#expect(file.socket?.token == "legacy-token")
#expect(file.defaults?.security == .deny)
#expect(file.defaults?.ask == .always)
#expect(file.agents?["main"]?.allowlist?.map(\.pattern) == ["git status"])
#expect(!FileManager().fileExists(atPath: legacyFile.path))
#expect(FileManager().fileExists(atPath: "\(legacyFile.path).migrated"))
}
}
@Test
func `update allowlist accepts basename pattern`() async throws {
try await self.withTempStateDir { _ in

View File

@@ -6362,7 +6362,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
public let security: AnyCodable?
public let ask: AnyCodable?
public let warningtext: AnyCodable?
public let unavailabledecisions: [String]?
public let commandspans: [[String: AnyCodable]]?
public let agentid: AnyCodable?
public let resolvedpath: AnyCodable?
@@ -6388,7 +6387,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
security: AnyCodable?,
ask: AnyCodable?,
warningtext: AnyCodable?,
unavailabledecisions: [String]?,
commandspans: [[String: AnyCodable]]?,
agentid: AnyCodable? = nil,
resolvedpath: AnyCodable?,
@@ -6413,7 +6411,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
self.security = security
self.ask = ask
self.warningtext = warningtext
self.unavailabledecisions = unavailabledecisions
self.commandspans = commandspans
self.agentid = agentid
self.resolvedpath = resolvedpath
@@ -6440,7 +6437,6 @@ public struct ExecApprovalRequestParams: Codable, Sendable {
case security
case ask
case warningtext = "warningText"
case unavailabledecisions = "unavailableDecisions"
case commandspans = "commandSpans"
case agentid = "agentId"
case resolvedpath = "resolvedPath"

View File

@@ -59,14 +59,6 @@ export CLICKCLACK_BOT_TOKEN="ccb_..."
openclaw gateway
```
If `plugins.allow` is a non-empty restrictive list, explicitly selecting
ClickClack in channel setup or running `openclaw plugins enable clickclack`
appends `clickclack` to that list. Onboarding installation uses the same
explicit-selection behavior. These paths do not override `plugins.deny` or a
global `plugins.enabled: false` setting. Direct `openclaw plugins install
clickclack` follows the normal plugin-install policy and also records ClickClack
in an existing allowlist.
## Multiple bots
Each account opens its own ClickClack realtime connection and uses its own bot token.

View File

@@ -27,7 +27,7 @@ Use it when you want to:
- inspect the local requested policy, host approvals file, and effective merge
- apply a local preset such as YOLO or deny-all
- synchronize local `tools.exec.*` and the local host approvals file
- synchronize local `tools.exec.*` and local `~/.openclaw/exec-approvals.json`
Examples:
@@ -183,9 +183,7 @@ Targeting notes:
- `--node` uses the same resolver as `openclaw nodes` (id, name, ip, or id prefix).
- `--agent` defaults to `"*"`, which applies to all agents.
- The node host must advertise `system.execApprovals.get/set` (macOS app or headless node host).
- Approvals files are stored per host in the OpenClaw state dir
(`$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset).
- Approvals files are stored per host at `~/.openclaw/exec-approvals.json`.
## Related

View File

@@ -162,8 +162,7 @@ The node host stores its node id, token, display name, and gateway connection in
`system.run` is gated by local exec approvals:
- `$OPENCLAW_STATE_DIR/exec-approvals.json`, or
`~/.openclaw/exec-approvals.json` when the variable is unset
- `~/.openclaw/exec-approvals.json`
- [Exec approvals](/tools/exec-approvals)
- `openclaw approvals --node <id|name|ip>` (edit from the Gateway)

View File

@@ -130,14 +130,12 @@ install method aligned:
missing or older than the current stable release.
The Gateway core auto-updater (when enabled via config) launches the CLI update path
outside the live Gateway request handler. Control-plane `update.run`
package-manager updates and supervised git-checkout updates also use a
managed-service handoff instead of replacing the package tree or rebuilding
`dist/` inside the live Gateway process. The Gateway starts a detached helper,
exits, and the helper runs the normal `openclaw update --yes --json` CLI path
from outside the Gateway process tree. If that handoff is unavailable,
`update.run` returns a structured response with the safe shell command to run
manually.
outside the live Gateway request handler. Control-plane `update.run` package-manager
updates also use a managed-service handoff instead of replacing the package tree
inside the live Gateway process. The Gateway starts a detached helper, exits,
and the helper runs the normal `openclaw update --yes --json` CLI path from
outside the Gateway process tree. If that handoff is unavailable, `update.run`
returns a structured response with the safe shell command to run manually.
For package-manager installs, `openclaw update` resolves the target package
version before invoking the package manager. npm global installs use a staged
@@ -152,33 +150,29 @@ installed OpenClaw build while leaving full plugin-command completion rebuilds t
explicit `openclaw completion --write-state` runs.
When a local managed Gateway service is installed and restart is enabled,
package-manager and git-checkout updates stop the running service before
replacing the package tree or mutating the checkout/build output. The updater
then refreshes the service metadata from the updated install, restarts the
service, and verifies the restarted Gateway before reporting
`Gateway: restarted and verified.`. Package-manager updates additionally verify
the restarted Gateway reports the expected package version; git-checkout updates
verify gateway health and service readiness after the rebuild. On macOS, the
post-update check also verifies the LaunchAgent is loaded/running for the active
profile and the configured loopback port is healthy. If the plist is installed
but launchd is not supervising it, OpenClaw re-bootstraps the LaunchAgent
automatically, then reruns the health/version/channel readiness checks. A fresh
bootstrap loads the RunAtLoad job directly, so update recovery does not
immediately `kickstart -k` the newly spawned Gateway. If the Gateway still does
not become healthy, the command exits non-zero and prints the restart log path
plus explicit restart, reinstall, and package rollback instructions. If restart
cannot run, the command prints `Gateway: restart skipped (...)` or
`Gateway: restart failed: ...` with a manual `openclaw gateway restart` hint.
With `--no-restart`, package replacement or git rebuild still runs but the
managed service is not stopped or restarted, so the running Gateway may keep old
code until you restart it manually.
package-manager updates stop the running service before replacing the package
tree, then refresh the service metadata from the updated install, restart the
service, and verify the restarted Gateway reports the expected version before
reporting `Gateway: restarted and verified.`. On macOS, the post-update check
also verifies the LaunchAgent is loaded/running for the active profile and the
configured loopback port is healthy. If the plist is installed but launchd is
not supervising it, OpenClaw re-bootstraps the LaunchAgent automatically, then
reruns the health/version/channel readiness checks. A fresh bootstrap loads the
RunAtLoad job directly, so update recovery does not immediately `kickstart -k`
the newly spawned Gateway. If the Gateway still does not become healthy, the
command exits non-zero and prints the restart log path plus explicit restart,
reinstall, and package rollback instructions. If restart cannot run, the command
prints `Gateway: restart skipped (...)` or `Gateway: restart failed: ...` with a
manual `openclaw gateway restart` hint. With `--no-restart`,
package replacement still runs but the managed service is not stopped or
restarted, so the running Gateway may keep old code until you restart it
manually.
### Control-plane response shape
When `update.run` is invoked through the Gateway control plane on a
package-manager install or supervised git checkout, the handler reports the
handoff initiation separately from the CLI update that continues after the
Gateway exits:
package-manager install, the handler reports the handoff initiation separately
from the CLI update that continues after the Gateway exits:
- `ok: true`, `result.status: "skipped"`,
`result.reason: "managed-service-handoff-started"`, and
@@ -187,11 +181,8 @@ Gateway exits:
`openclaw update --yes --json` outside the live service process.
- `ok: false`, `result.reason: "managed-service-handoff-unavailable"`, and
`handoff.status: "unavailable"` mean OpenClaw could not find a supervising
service boundary and durable service identity for a safe handoff. For
example, systemd handoff requires the OpenClaw unit identity
(`OPENCLAW_SYSTEMD_UNIT`), not only ambient systemd process markers. The
response includes `handoff.command`, the shell command to run from outside the
Gateway.
service boundary for a safe handoff. The response includes
`handoff.command`, the shell command to run from outside the Gateway.
- `ok: false`, `result.reason: "managed-service-handoff-failed"` means the
Gateway tried to create the handoff but could not spawn the detached helper.
@@ -202,8 +193,8 @@ health checks complete. During the handoff, the sentinel can carry
restarted Gateway keeps polling it and only fires the continuation after the CLI
has verified service health and rewritten the sentinel with the final `ok`
result. `openclaw status` and `openclaw status --all` show an `Update restart`
row while that sentinel is pending or failed, and `update.status` refreshes and
returns the latest sentinel.
row while that sentinel is pending or failed, and `update.status` returns the
latest cached sentinel.
## Git checkout flow

View File

@@ -493,8 +493,6 @@ example `~/.agents/skills/manager -> ~/Projects/manager/skills`.
- `extraDirs` scans the sibling repo as an explicit skill root.
- `allowSymlinkTargets` lets symlinked skill folders resolve into that trusted
real target root without allowing arbitrary symlink escapes.
- To let Skill Workshop apply write through the same trusted symlink target,
set `skills.workshop.allowSymlinkTargetWrites: true`.
## Common patterns

View File

@@ -200,9 +200,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
nodeManager: "npm", // npm | pnpm | yarn | bun
allowUploadedArchives: false,
},
workshop: {
allowSymlinkTargetWrites: false,
},
entries: {
"image-lab": {
apiKey: { source: "env", provider: "default", id: "GEMINI_API_KEY" }, // or plaintext string
@@ -219,8 +216,6 @@ See [MCP](/cli/mcp#openclaw-as-an-mcp-client-registry) and
- `load.extraDirs`: extra shared skill roots (lowest precedence).
- `load.allowSymlinkTargets`: trusted real target roots that skill symlinks may
resolve into when the link lives outside its configured source root.
- `workshop.allowSymlinkTargetWrites`: allows Skill Workshop apply to write
through already-trusted symlink targets (default: false).
- `install.preferBrew`: when true, prefer Homebrew installers when `brew` is
available before falling back to other installer kinds.
- `install.nodeManager`: node installer preference for `metadata.openclaw.install`

View File

@@ -411,8 +411,8 @@ enumeration of `src/gateway/server-methods/*.ts`.
- `config.apply` validates + replaces the full config payload.
- `config.schema` returns the live config schema payload used by Control UI and CLI tooling: schema, `uiHints`, version, and generation metadata, including plugin + channel schema metadata when the runtime can load it. The schema includes field `title` / `description` metadata derived from the same labels and help text used by the UI, including nested object, wildcard, array-item, and `anyOf` / `oneOf` / `allOf` composition branches when matching field documentation exists.
- `config.schema.lookup` returns a path-scoped lookup payload for one config path: normalized path, a shallow schema node, matched hint + `hintPath`, optional `reloadKind`, and immediate child summaries for UI/CLI drill-down. `reloadKind` is one of `restart`, `hot`, or `none` and mirrors the Gateway config reload planner for the requested path. Lookup schema nodes keep the user-facing docs and common validation fields (`title`, `description`, `type`, `enum`, `const`, `format`, `pattern`, numeric/string/array/object bounds, and flags like `additionalProperties`, `deprecated`, `readOnly`, `writeOnly`). Child summaries expose `key`, normalized `path`, `type`, `required`, `hasChildren`, optional `reloadKind`, plus the matched `hint` / `hintPath`.
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates and supervised git-checkout updates from the control plane use a detached managed-service handoff instead of replacing the package tree or mutating checkout/build output inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. An unavailable handoff means OpenClaw lacks a safe supervisor boundary or durable service identity, such as `OPENCLAW_SYSTEMD_UNIT` for systemd. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
- `update.status` refreshes and returns the latest update restart sentinel, including the post-restart running version when available.
- `update.run` runs the gateway update flow and schedules a restart only when the update itself succeeded; callers with a session can include `continuationMessage` so startup resumes one follow-up agent turn through the restart continuation queue. Package-manager updates from the control plane use a detached managed-service handoff instead of replacing the package tree inside the live Gateway. A started handoff returns `ok: true` with `result.reason: "managed-service-handoff-started"` and `handoff.status: "started"`; unavailable or failed handoffs return `ok: false` with `managed-service-handoff-unavailable` or `managed-service-handoff-failed`, plus `handoff.command` when a manual shell update is required. During a started handoff, the restart sentinel may briefly report `stats.reason: "restart-health-pending"`; the continuation is delayed until the CLI verifies the restarted Gateway and writes the final `ok` sentinel.
- `update.status` returns the latest cached update restart sentinel, including the post-restart running version when available.
- `wizard.start`, `wizard.next`, `wizard.status`, and `wizard.cancel` expose the onboarding wizard over WS RPC.
</Accordion>

View File

@@ -93,7 +93,7 @@ exhaustive):
| `tools.exec.host_sandbox_no_sandbox_agents` | warn | Per-agent `exec host=sandbox` fails closed when sandbox is off | `agents.list[].tools.exec.host`, `agents.list[].sandbox.mode` | no |
| `tools.exec.security_full_configured` | warn/critical | Host exec is running with `security="full"` | `tools.exec.security`, `agents.list[].tools.exec.security` | no |
| `tools.exec.fs_tools_disabled_but_exec_enabled` | warn | Filesystem tool policy does not make shell execution read-only | `tools.deny`, `agents.list[].tools.deny`, `agents.*.sandbox.workspaceAccess` | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | host approvals file | no |
| `tools.exec.auto_allow_skills_enabled` | warn | Exec approvals trust skill bins implicitly | `~/.openclaw/exec-approvals.json` | no |
| `tools.exec.allowlist_interpreter_without_strict_inline_eval` | warn | Interpreter allowlists permit inline eval without forced reapproval | `tools.exec.strictInlineEval`, `agents.list[].tools.exec.strictInlineEval`, exec approvals allowlist | no |
| `tools.exec.safe_bins_interpreter_unprofiled` | warn | Interpreter/runtime bins in `safeBins` without explicit profiles broaden exec risk | `tools.exec.safeBins`, `tools.exec.safeBinProfiles`, `agents.list[].tools.exec.*` | no |
| `tools.exec.safe_bins_broad_behavior` | warn | Broad-behavior tools in `safeBins` weaken the low-risk stdin-filter trust model | `tools.exec.safeBins`, `agents.list[].tools.exec.safeBins` | no |

View File

@@ -154,10 +154,6 @@ Do not use broad targets such as `~`, `/`, or a whole synced project folder.
Keep `allowSymlinkTargets` scoped to the real skill root that contains trusted
`SKILL.md` directories.
If Skill Workshop apply should also write through those trusted symlinked
workspace skill paths, enable `skills.workshop.allowSymlinkTargetWrites`. Keep
it disabled for read-only shared skill roots.
Related:
- [Skills config](/tools/skills-config#symlinked-sibling-repos)

View File

@@ -137,7 +137,7 @@ Each entry lists the package, distribution route, and description.
- **[mattermost](/plugins/reference/mattermost)** (`@openclaw/mattermost`) - included in OpenClaw. Adds the Mattermost channel surface for sending and receiving OpenClaw messages.
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds agent-callable tools.
- **[memory-core](/plugins/reference/memory-core)** (`@openclaw/memory-core`) - included in OpenClaw. Adds file-backed memory search tools.
- **[memory-wiki](/plugins/reference/memory-wiki)** (`@openclaw/memory-wiki`) - included in OpenClaw. Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.
@@ -267,9 +267,9 @@ Each entry lists the package, distribution route, and description.
- **[googlechat](/plugins/reference/googlechat)** (`@openclaw/googlechat`) - npm; ClawHub. OpenClaw Google Chat channel plugin for spaces and direct messages.
- **[line](/plugins/reference/line)** (`@openclaw/line`) - npm; ClawHub. OpenClaw LINE channel plugin for LINE Bot API chats.
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. OpenClaw llama.cpp embedding provider plugin.
- **[llama-cpp](/plugins/reference/llama-cpp)** (`@openclaw/llama-cpp-provider`) - npm; ClawHub. Local GGUF embeddings through node-llama-cpp.
- **[line](/plugins/reference/line)** (`@openclaw/line`) - npm; ClawHub. OpenClaw LINE channel plugin for LINE Bot API chats.
- **[lobster](/plugins/reference/lobster)** (`@openclaw/lobster`) - npm; ClawHub. Lobster workflow tool plugin for typed pipelines and resumable approvals.

View File

@@ -18,12 +18,8 @@ OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.
providers: anthropic-vertex
<!-- openclaw-plugin-reference:manual-start -->
## Claude Fable 5
Use `anthropic-vertex/claude-fable-5` where the model is available in your Google Cloud region.
Fable 5 always uses adaptive thinking and defaults to `high` effort. `/think off` and
`/think minimal` use `low` effort because the model does not support disabling thinking.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -1,13 +1,13 @@
---
summary: "Local GGUF embeddings through node-llama-cpp."
summary: "OpenClaw llama.cpp embedding provider plugin."
read_when:
- You are installing, configuring, or auditing the llama-cpp plugin
title: "Llama Cpp plugin"
title: "llama-cpp plugin"
---
# Llama Cpp plugin
# llama-cpp plugin
Local GGUF embeddings through node-llama-cpp.
OpenClaw llama.cpp embedding provider plugin.
## Distribution
@@ -20,4 +20,4 @@ contracts: embeddingProviders
## Related docs
- [llama-cpp](/plugins/llama-cpp)
- [llama.cpp Provider](/plugins/llama-cpp)

View File

@@ -1,5 +1,5 @@
---
summary: "Adds agent-callable tools."
summary: "Adds file-backed memory search tools."
read_when:
- You are installing, configuring, or auditing the memory-core plugin
title: "Memory Core plugin"
@@ -7,7 +7,7 @@ title: "Memory Core plugin"
# Memory Core plugin
Adds agent-callable tools.
Adds file-backed memory search tools.
## Distribution

View File

@@ -1,5 +1,5 @@
---
summary: "Adds Microsoft Foundry model provider support to OpenClaw."
summary: "Use Microsoft Foundry chat and MAI image deployments from OpenClaw."
read_when:
- You are installing, configuring, or auditing the microsoft-foundry plugin
title: "Microsoft Foundry plugin"
@@ -7,7 +7,9 @@ title: "Microsoft Foundry plugin"
# Microsoft Foundry plugin
Adds Microsoft Foundry model provider support to OpenClaw.
Use Microsoft Foundry deployments from OpenClaw with API-key auth or Microsoft
Entra ID through the Azure CLI. The plugin owns Microsoft Foundry model
discovery, runtime token refresh, and MAI image generation.
## Distribution
@@ -16,10 +18,7 @@ Adds Microsoft Foundry model provider support to OpenClaw.
## Surface
providers: microsoft-foundry; contracts: imageGenerationProviders
<!-- openclaw-plugin-reference:manual-start -->
- Model provider: `microsoft-foundry`
- Image-generation provider: `microsoft-foundry`
## Requirements
@@ -109,5 +108,3 @@ MAI image constraints:
Foundry deployment through onboarding or add `models.providers.microsoft-foundry.baseUrl`.
- `supports MAI image deployments only`: the selected image model points at a
non-MAI deployment. Use a deployed MAI image model for `image_generate`.
<!-- openclaw-plugin-reference:manual-end -->

View File

@@ -38,19 +38,6 @@ Choose your preferred auth method and follow the setup steps.
export AWS_REGION="us-west-2"
```
</Step>
<Step title="Opt in to provider data sharing for Claude Fable 5">
Claude Fable 5 and Claude Mythos-class Bedrock models require the Mantle Data Retention API mode `provider_data_share` before invocation. This opt-in allows Bedrock to share prompts and completions with Anthropic and retain them for up to 30 days for trust and safety review.
```bash
AWS_REGION="${AWS_REGION:-us-east-1}"
curl -X PUT "https://bedrock-mantle.${AWS_REGION}.api.aws/v1/data_retention" \
-H "Authorization: Bearer $AWS_BEARER_TOKEN_BEDROCK" \
-H "Content-Type: application/json" \
-d '{ "mode": "provider_data_share" }'
```
Use another Bedrock model in the config if you cannot accept that retention mode.
</Step>
<Step title="Verify models are discovered">
```bash
openclaw models list

View File

@@ -115,7 +115,7 @@ Configuration location:
- `safeBins` comes from config (`tools.exec.safeBins` or per-agent `agents.list[].tools.exec.safeBins`).
- `safeBinTrustedDirs` comes from config (`tools.exec.safeBinTrustedDirs` or per-agent `agents.list[].tools.exec.safeBinTrustedDirs`).
- `safeBinProfiles` comes from config (`tools.exec.safeBinProfiles` or per-agent `agents.list[].tools.exec.safeBinProfiles`). Per-agent profile keys override global keys.
- allowlist entries live in the host-local approvals file under `agents.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- allowlist entries live in host-local `~/.openclaw/exec-approvals.json` under `agents.<id>.allowlist` (or via Control UI / `openclaw approvals allowlist ...`).
- `openclaw security audit` warns with `tools.exec.safe_bins_interpreter_unprofiled` when interpreter/runtime bins appear in `safeBins` without explicit profiles.
- `openclaw doctor --fix` can scaffold missing custom `safeBinProfiles.<bin>` entries as `{}` (review and tighten afterward). Interpreter/runtime bins are not auto-scaffolded.

View File

@@ -23,7 +23,7 @@ Codex Guardian mapping, and ACPX harness permissions, see
Effective policy is the **stricter** of `tools.exec.*` and approvals
defaults; if an approvals field is omitted, the `tools.exec` value is
used. Host exec also uses local approvals state on that machine - a
host-local `ask: "always"` in the execution host approvals file keeps
host-local `ask: "always"` in `~/.openclaw/exec-approvals.json` keeps
prompting even if session or config defaults request `ask: "on-miss"`.
</Note>
@@ -73,20 +73,12 @@ Exec approvals are enforced locally on the execution host:
## Settings and storage
Approvals live in a local JSON file on the execution host. When
`OPENCLAW_STATE_DIR` is set, the file follows that state directory;
otherwise it uses the default OpenClaw state directory:
Approvals live in a local JSON file on the execution host:
```text
$OPENCLAW_STATE_DIR/exec-approvals.json
# otherwise
~/.openclaw/exec-approvals.json
```
The default approval socket follows the same root:
`$OPENCLAW_STATE_DIR/exec-approvals.sock`, or
`~/.openclaw/exec-approvals.sock` when the variable is unset.
Example schema:
```json
@@ -218,7 +210,7 @@ agent under `agents.list[].tools.exec.commandHighlighting`.
If you want host exec to run without approval prompts, you must open
**both** policy layers - requested exec policy in OpenClaw config
(`tools.exec.*`) **and** host-local approvals policy in
the execution host approvals file.
`~/.openclaw/exec-approvals.json`.
OpenClaw defaults omitted `askFallback` to `deny`. Set host
`askFallback` to `full` explicitly when a no-UI approval prompt should
@@ -289,7 +281,8 @@ openclaw exec-policy preset yolo
That local shortcut updates both:
- Local `tools.exec.host/security/ask`.
- Local approvals file defaults, including `askFallback: "full"`.
- Local `~/.openclaw/exec-approvals.json` defaults, including
`askFallback: "full"`.
It is intentionally local-only. To change gateway-host or node-host
approvals remotely, use `openclaw approvals set --gateway` or
@@ -432,7 +425,7 @@ shows last-used metadata per pattern so you can keep the list tidy.
The target selector chooses **Gateway** (local approvals) or a **Node**.
Nodes must advertise `system.execApprovals.get/set` (macOS app or
headless node host). If a node does not advertise exec approvals yet,
edit its local approvals file directly.
edit its local `~/.openclaw/exec-approvals.json` directly.
CLI: `openclaw approvals` supports gateway or node editing - see
[Approvals CLI](/cli/approvals).

View File

@@ -47,7 +47,7 @@ Where to execute. `auto` resolves to `sandbox` when a sandbox runtime is active
<ParamField path="security" type="'deny' | 'allowlist' | 'full'">
Ignored for normal tool calls. `gateway` / `node` security is controlled by
`tools.exec.security` and the host approvals file; elevated mode can
`tools.exec.security` and `~/.openclaw/exec-approvals.json`; elevated mode can
force `security=full` only when the operator explicitly grants elevated access.
</ParamField>
@@ -75,7 +75,7 @@ Notes:
- `tools.exec.mode` is the normalized policy knob. Values are `deny`, `allowlist`, `ask`, `auto`, and `full`. `auto` runs deterministic allowlist/safe-bin matches directly and routes every remaining exec approval case through OpenClaw's native auto reviewer before asking a human. `ask` / `ask=always` still asks a human every time.
- With no extra config, `host=auto` still "just works": no sandbox means it resolves to `gateway`; a live sandbox means it stays in the sandbox.
- `elevated` escapes the sandbox onto the configured host path: `gateway` by default, or `node` when `tools.exec.host=node` (or the session default is `host=node`). It is only available when elevated access is enabled for the current session/provider.
- `gateway`/`node` approvals are controlled by the host approvals file.
- `gateway`/`node` approvals are controlled by `~/.openclaw/exec-approvals.json`.
- `node` requires a paired node (companion app or headless node host).
- If multiple nodes are available, set `exec.node` or `tools.exec.node` to select one.
- `exec host=node` is the only shell-execution path for nodes; the legacy `nodes.run` wrapper has been removed.
@@ -114,7 +114,7 @@ Notes:
- `tools.exec.host` (default: `auto`; resolves to `sandbox` when sandbox runtime is active, `gateway` otherwise)
- `tools.exec.security` (default: `deny` for sandbox, `full` for gateway + node when unset)
- `tools.exec.ask` (default: `off`)
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host approvals file; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval).
- No-approval host exec is the default for gateway + node. If you want approvals/allowlist behavior, tighten both `tools.exec.*` and the host `~/.openclaw/exec-approvals.json`; see [Exec approvals](/tools/exec-approvals#yolo-mode-no-approval).
- YOLO comes from the host-policy defaults (`security=full`, `ask=off`), not from `host=auto`. If you want to force gateway or node routing, set `tools.exec.host` or use `/exec host=...`.
- In `security=full` plus `ask=off` mode, host exec follows the configured policy directly; there is no extra heuristic command-obfuscation prefilter or script-preflight rejection layer.
- `tools.exec.node` (default: unset)

View File

@@ -190,7 +190,6 @@ agent session or the CLI.
autonomous: {
enabled: false,
},
allowSymlinkTargetWrites: false,
approvalPolicy: "pending",
maxPending: 50,
maxSkillBytes: 40000,
@@ -201,9 +200,6 @@ agent session or the CLI.
- `autonomous.enabled`: allows OpenClaw to create pending proposals from durable
conversation signals after successful turns. Default: `false`.
- `allowSymlinkTargetWrites`: allows apply to write through workspace skill
symlinks whose real target is listed in `skills.load.allowSymlinkTargets`.
Default: `false`.
- `approvalPolicy: "pending"`: requires an approval prompt before
agent-initiated `apply`, `reject`, or `quarantine`.
- `approvalPolicy: "auto"`: skips that approval prompt. The agent must still
@@ -269,7 +265,6 @@ Default state directory: `~/.openclaw`.
| `Skill proposal content is too large` | Shorten the proposal body or raise `skills.workshop.maxSkillBytes`. |
| `Target skill changed after proposal creation` | Revise the proposal against the current target, or create a new proposal. |
| `Proposal scan failed` | Inspect scanner findings, then revise or quarantine the proposal. |
| `untrusted symlink target` | Configure `skills.load.allowSymlinkTargets` and enable `skills.workshop.allowSymlinkTargetWrites` only for intentional shared skill roots. |
| `Support file paths must be under one of...` | Move support files under `assets/`, `examples/`, `references/`, `scripts/`, or `templates/`. |
| Proposal does not show in list | Check the selected `--agent` workspace and `OPENCLAW_STATE_DIR`. |
| Agent cannot call `skill_workshop` | Check the active tool policy and run mode. `coding` includes the tool; restrictive `tools.allow` policies must list it explicitly, and sandboxed runs must use a normal host-side agent session or the CLI. |

View File

@@ -29,7 +29,6 @@ Most skills configuration lives under `skills` in
},
workshop: {
autonomous: { enabled: false },
allowSymlinkTargetWrites: false,
approvalPolicy: "pending",
maxPending: 50,
maxSkillBytes: 40000,
@@ -334,13 +333,6 @@ different visible skill set per agent.
quarantine. `auto` allows those actions without approval.
</ParamField>
<ParamField path="skills.workshop.allowSymlinkTargetWrites" type="boolean" default="false">
Allow Skill Workshop apply to write through workspace skill symlinks whose
real target is already trusted by `skills.load.allowSymlinkTargets`. Keep this
disabled unless generated proposal applies should mutate that shared skill
root.
</ParamField>
<ParamField path="skills.workshop.maxPending" type="number" default="50">
Maximum pending and quarantined proposals retained per workspace.
</ParamField>
@@ -373,23 +365,6 @@ With this config, `<workspace>/skills/manager -> ~/Projects/manager/skills` is
accepted after realpath resolution. `extraDirs` scans the sibling repo directly;
`allowSymlinkTargets` preserves the symlinked path for existing layouts.
Skill Workshop apply does not write through those symlinks by default. To let
Workshop apply mutate skills under already-trusted symlink targets, opt in
separately:
```json5
{
skills: {
load: {
allowSymlinkTargets: ["~/Projects/manager/skills"],
},
workshop: {
allowSymlinkTargetWrites: true,
},
},
}
```
Managed `~/.openclaw/skills` and personal `~/.agents/skills` directories
already accept skill-directory symlinks (per-skill `SKILL.md` containment still
applies).

View File

@@ -204,8 +204,6 @@ publish and sync.
Workspace, project-agent, and extra-dir skill discovery only accepts skill
roots whose resolved realpath stays inside the configured root, unless
`skills.load.allowSymlinkTargets` explicitly trusts a target root.
Skill Workshop writes through those trusted targets only when
`skills.workshop.allowSymlinkTargetWrites` is enabled.
Managed `~/.openclaw/skills` and personal `~/.agents/skills` may contain
symlinked skill folders, but every `SKILL.md` realpath must still stay
inside its resolved skill directory.
@@ -535,8 +533,6 @@ aligned.
Use `allowSymlinkTargets` for intentional symlinked layouts where a skill
root symlink points outside the configured root, for example
`<workspace>/skills/manager -> ~/Projects/manager/skills`.
Enable `skills.workshop.allowSymlinkTargetWrites` only when Skill Workshop
should also apply proposals through those trusted symlinked paths.
</Accordion>
<Accordion title="Remote macOS nodes (Linux gateway)">

View File

@@ -74,7 +74,7 @@ The same browser-local pattern applies to the assistant avatar override. Uploade
## Runtime config endpoint
The Control UI fetches its runtime settings from `/control-ui-config.json`, resolved relative to the gateway's Control UI base path (for example `/__openclaw__/control-ui-config.json` when the UI is served under `/__openclaw__/`). That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
The Control UI fetches its runtime settings from `/__openclaw/control-ui-config.json`. That endpoint is gated by the same gateway auth as the rest of the HTTP surface: unauthenticated browsers cannot fetch it, and a successful fetch requires either an already valid gateway token/password, Tailscale Serve identity, or a trusted-proxy identity.
## Language support

View File

@@ -477,21 +477,11 @@ describe("bedrock mantle discovery", () => {
expect(provider?.api).toBe("openai-completions");
expect(provider?.auth).toBe("api-key");
expect(provider?.apiKey).toBe("env:AWS_BEARER_TOKEN_BEDROCK");
expect(provider?.models).toHaveLength(3);
expect(provider?.models).toHaveLength(2);
const opus = provider?.models?.find((model) => model.id === "anthropic.claude-opus-4-7");
expect(opus?.api).toBe("anthropic-messages");
expect(opus?.reasoning).toBe(false);
expect(opus).not.toHaveProperty("baseUrl");
const mythos = provider?.models?.find(
(model) => model.id === "anthropic.claude-mythos-preview",
);
expect(mythos).toMatchObject({
api: "anthropic-messages",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
contextWindow: 1_000_000,
maxTokens: 128_000,
});
});
it("returns null when no auth is available", async () => {

View File

@@ -404,17 +404,6 @@ export async function resolveImplicitMantleProvider(params: {
contextWindow: 1_000_000,
maxTokens: 128_000,
},
{
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
api: "anthropic-messages" as const,
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1_000_000,
maxTokens: 128_000,
},
];
const allModels = [...models, ...claudeModels];

View File

@@ -6,7 +6,7 @@ import {
resolveMantleAnthropicBaseUrl,
} from "./mantle-anthropic.runtime.js";
function createTestModel(overrides: Partial<Model> = {}): Model {
function createTestModel(): Model {
return {
id: "anthropic.claude-opus-4-7",
name: "Claude Opus 4.7",
@@ -21,7 +21,6 @@ function createTestModel(overrides: Partial<Model> = {}): Model {
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 1_000_000,
maxTokens: 128_000,
...overrides,
} as Model;
}
@@ -113,69 +112,6 @@ describe("createMantleAnthropicStreamFn", () => {
expect(streamOptions.thinkingEnabled).toBe(false);
});
it("defaults Mythos Preview to adaptive high effort", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
});
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("high");
});
it("clamps unsupported Mythos Preview max effort to high", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const context = { messages: [] };
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, context, {
apiKey: "bedrock-bearer-token",
reasoning: "max",
});
expectFirstStreamCall(deps, model, context);
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("high");
});
it("maps Mythos Preview minimal reasoning to low effort", () => {
const model = createTestModel({
id: "anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
params: { canonicalModelId: "claude-mythos-preview" },
});
const deps = createTestDeps();
deps.stream.mockReturnValue({ kind: "anthropic-stream" } as never);
void createMantleAnthropicStreamFn(deps)(model, { messages: [] }, {
apiKey: "bedrock-bearer-token",
reasoning: "minimal",
});
const streamOptions = firstStreamOptions(deps);
expect(streamOptions.thinkingEnabled).toBe(true);
expect(streamOptions.effort).toBe("low");
});
it("normalizes Mantle provider URLs to the Anthropic endpoint", () => {
expect(resolveMantleAnthropicBaseUrl("https://bedrock-mantle.us-east-1.api.aws/v1")).toBe(
"https://bedrock-mantle.us-east-1.api.aws/anthropic",

View File

@@ -27,36 +27,6 @@ function requiresDefaultSampling(modelId: string): boolean {
return modelId.includes("claude-opus-4-7");
}
function isClaudeMythosPreviewModel(model: Model): boolean {
return [model.id, model.name, model.params?.canonicalModelId]
.filter((value): value is string => typeof value === "string")
.some((value) =>
/(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
value
.trim()
.toLowerCase()
.replace(/[\s_.:]+/g, "-"),
),
);
}
function resolveMantleReasoning(
model: Model,
options: SimpleStreamOptions | undefined,
): NonNullable<SimpleStreamOptions["reasoning"]> | undefined {
if (requiresDefaultSampling(model.id)) {
return undefined;
}
const reasoning = options?.reasoning ?? (isClaudeMythosPreviewModel(model) ? "high" : undefined);
if (!isClaudeMythosPreviewModel(model)) {
return reasoning;
}
if (reasoning === "minimal") {
return "low";
}
return reasoning === "xhigh" || reasoning === "max" ? "high" : reasoning;
}
function mergeHeaders(
...headerSources: Array<Record<string, string> | undefined>
): Record<string, string> {
@@ -139,8 +109,7 @@ export function createMantleAnthropicStreamFn(deps?: {
// Plugin package deps can give this plugin a distinct physical SDK copy.
// The client API is the same, but the SDK class private field makes types nominal.
const streamClient = client as unknown as AnthropicStreamClient;
const reasoning = resolveMantleReasoning(model, options);
if (!reasoning) {
if (!options?.reasoning || requiresDefaultSampling(model.id)) {
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
@@ -151,15 +120,14 @@ export function createMantleAnthropicStreamFn(deps?: {
const adjusted = adjustMaxTokensForThinking(
base.maxTokens || 0,
model.maxTokens,
reasoning,
options?.thinkingBudgets,
options.reasoning,
options.thinkingBudgets,
);
return streamFn(model as Model<"anthropic-messages">, context, {
...base,
client: streamClient,
maxTokens: adjusted.maxTokens,
thinkingEnabled: true,
...(isClaudeMythosPreviewModel(model) ? { effort: reasoning } : {}),
thinkingBudgetTokens: adjusted.thinkingBudget,
});
};

View File

@@ -166,65 +166,6 @@ describe("bedrock discovery", () => {
});
});
it("marks known Fable inference profile fallbacks as reasoning capable", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-fable-5",
inferenceProfileName: "US Claude Fable 5",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn: "arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-fable-5",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toHaveLength(1);
expectModelFields(models[0], {
id: "us.anthropic.claude-fable-5",
reasoning: true,
contextWindow: 1_000_000,
thinkingLevelMap: { off: "low", minimal: "low", xhigh: "xhigh", max: "max" },
});
});
it("skips Mythos Preview inference profiles because Mantle owns that route", async () => {
sendMock
.mockResolvedValueOnce({
modelSummaries: [],
})
.mockResolvedValueOnce({
inferenceProfileSummaries: [
{
inferenceProfileId: "us.anthropic.claude-mythos-preview",
inferenceProfileName: "US Claude Mythos Preview",
status: "ACTIVE",
type: "SYSTEM_DEFINED",
models: [
{
modelArn:
"arn:aws:bedrock:us-east-1::foundation-model/anthropic.claude-mythos-preview",
},
],
},
],
});
const models = await discoverBedrockModels({ region: "us-east-1", clientFactory });
expect(models).toEqual([]);
});
it("normalizes region-prefixed versioned model ids when resolving context windows", async () => {
sendMock
.mockResolvedValueOnce({

View File

@@ -157,13 +157,6 @@ function resolveKnownContextWindow(modelId: string): number | undefined {
return undefined;
}
function isKnownClaudeMythosPreviewModelId(modelId: string): boolean {
const stripped = modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
return [modelId, stripped].some((candidate) =>
/(?:^|[/.:])anthropic\.claude-mythos-preview(?:$|[-.:/])/i.test(candidate),
);
}
function resolveKnownThinkingLevelMap(
modelId: string,
): ModelDefinitionConfig["thinkingLevelMap"] | undefined {
@@ -329,9 +322,6 @@ function shouldIncludeSummary(summary: BedrockModelSummary, filter: string[]): b
if (summary.responseStreamingSupported !== true) {
return false;
}
if (isKnownClaudeMythosPreviewModelId(summary.modelId)) {
return false;
}
if (!includesTextModalities(summary.outputModalities)) {
return false;
}
@@ -464,9 +454,6 @@ function resolveInferenceProfiles(
// Look up the underlying foundation model to inherit its capabilities.
const baseModelId = resolveBaseModelId(profile);
if (isKnownClaudeMythosPreviewModelId(baseModelId ?? profile.inferenceProfileId)) {
continue;
}
const baseModel = baseModelId
? foundationModels.get(normalizeLowercaseStringOrEmpty(baseModelId))
: undefined;

View File

@@ -835,44 +835,6 @@ describe("amazon-bedrock provider plugin", () => {
expect(payload.inferenceConfig).toEqual({});
});
it("does not re-upgrade Mythos Preview max thinking in the final payload", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);
const wrapped = provider.wrapStreamFn?.({
provider: "amazon-bedrock",
modelId: "us.anthropic.claude-mythos-preview",
streamFn: spyStreamFn,
thinkingLevel: "max",
} as never);
const result = wrapped?.(
{
api: "bedrock-converse-stream",
provider: "amazon-bedrock",
id: "us.anthropic.claude-mythos-preview",
name: "Claude Mythos Preview",
reasoning: true,
} as never,
{ messages: [] } as never,
{ reasoning: "max" } as never,
) as Record<string, unknown> | undefined;
const payload = {
inferenceConfig: { temperature: 0.2 },
additionalModelRequestFields: {
thinking: { type: "adaptive" },
output_config: { effort: "high" },
},
};
await (result?.onPayload as ((p: Record<string, unknown>) => unknown) | undefined)?.(payload);
expect(payload.additionalModelRequestFields).toEqual({
thinking: { type: "adaptive" },
output_config: { effort: "high" },
});
expect(payload.inferenceConfig).toEqual({});
});
it("classifies nested Bedrock deprecated-temperature validation as format failover", async () => {
const provider = await registerSingleProviderPlugin(amazonBedrockPlugin);

View File

@@ -23,7 +23,6 @@ import { mergeImplicitBedrockProvider, resolveBedrockConfigApiKey } from "./disc
import { bedrockMemoryEmbeddingProviderAdapter } from "./memory-embedding-adapter.js";
import { streamBedrock, streamSimpleBedrock } from "./stream.runtime.js";
import {
isLatestAdaptiveBedrockModelRef,
isOpus47OrNewerBedrockModelRef,
resolveBedrockNativeThinkingLevelMap,
resolveBedrockClaudeThinkingProfile,
@@ -597,10 +596,8 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
currentPluginConfig?.discovery?.region;
const mayNeedCacheInjection =
isBedrockAppInferenceProfile(modelId) && !sharedRuntimeWouldInjectCachePoints(modelId);
const shouldOmitTemperature =
opus47OrNewer || fable5 || isLatestAdaptiveBedrockModelRef(modelId, model?.params);
const shouldOmitTemperature = opus47OrNewer || fable5;
const shouldPatchMaxThinking = supportsNativeMax && thinkingLevel === "max";
const shouldPatchPayload = shouldOmitTemperature || shouldPatchMaxThinking;
// For known Anthropic models (heuristic match), enable injection immediately.
// For opaque profile IDs, we'll resolve via GetInferenceProfile on first call.
@@ -630,17 +627,13 @@ export function registerAmazonBedrockPlugin(api: OpenClawPluginApi): void {
context,
withAwsCredentialRefreshOnPayload({
...merged,
...(shouldPatchPayload
...(shouldPatchMaxThinking
? {
onPayload: (payload: unknown, payloadModel: unknown) => {
if (payload && typeof payload === "object") {
const payloadRecord = payload as Record<string, unknown>;
if (shouldPatchMaxThinking) {
patchMaxThinkingEffort(payloadRecord);
}
if (shouldOmitTemperature) {
omitUnsupportedClaudePayloadTemperature(payloadRecord);
}
patchMaxThinkingEffort(payloadRecord);
omitUnsupportedClaudePayloadTemperature(payloadRecord);
}
return originalOnPayload?.(payload, payloadModel);
},

View File

@@ -167,34 +167,7 @@ describe("Bedrock profile endpoint resolution", () => {
});
describe("Bedrock thinking effort mapping", () => {
it("does not force adaptive thinking for optional Claude models when callers omit reasoning", () => {
const model = bedrockModel({
id: "anthropic.claude-sonnet-4-6-v1:0",
name: "Claude Sonnet 4.6",
reasoning: true,
});
const options = testing.resolveSimpleBedrockOptions(model, {});
expect(options.reasoning).toBeUndefined();
expect(testing.buildAdditionalModelRequestFields(model, options)).toBeUndefined();
});
it("forces adaptive thinking for Bedrock Mythos Preview when callers omit reasoning", () => {
const model = bedrockModel({
id: "us.anthropic.claude-mythos-preview",
name: "US Claude Mythos Preview",
reasoning: true,
});
const options = testing.resolveSimpleBedrockOptions(model, {});
expect(options.reasoning).toBe("high");
expect(testing.buildAdditionalModelRequestFields(model, options)).toEqual({
thinking: { type: "adaptive", display: "summarized" },
output_config: { effort: "high" },
});
});
it("clamps max effort for Claude models without native max support", () => {
it("caps max effort at high for Claude Sonnet 4.6", () => {
expect(
testing.mapThinkingLevelToEffort(
bedrockModel({

View File

@@ -351,38 +351,29 @@ export const streamSimpleBedrock: StreamFunction<"bedrock-converse-stream", Simp
model: Model<"bedrock-converse-stream">,
context: Context,
options?: SimpleStreamOptions,
) => streamBedrock(model, context, resolveSimpleBedrockOptions(model, options));
function resolveSimpleBedrockOptions(
model: Model<"bedrock-converse-stream">,
options?: SimpleStreamOptions,
): BedrockOptions {
) => {
const base = buildBaseOptions(model, options, undefined);
if (usesClaudeFable5BedrockContract(model)) {
return {
return streamBedrock(model, context, {
...base,
reasoning: options?.reasoning ?? "high",
thinkingBudgets: options?.thinkingBudgets,
} satisfies BedrockOptions;
} satisfies BedrockOptions);
}
if (!options?.reasoning) {
const reasoning =
isAnthropicClaudeModel(model) && requiresMandatoryAdaptiveThinking(model)
? "high"
: undefined;
return {
return streamBedrock(model, context, {
...base,
reasoning,
} satisfies BedrockOptions;
reasoning: undefined,
} satisfies BedrockOptions);
}
if (isAnthropicClaudeModel(model)) {
if (supportsAdaptiveThinking(model)) {
return {
return streamBedrock(model, context, {
...base,
reasoning: options.reasoning,
thinkingBudgets: options.thinkingBudgets,
} satisfies BedrockOptions;
} satisfies BedrockOptions);
}
// Undefined means the caller did not request an output cap; let the helper use the model cap.
@@ -394,7 +385,7 @@ function resolveSimpleBedrockOptions(
options.thinkingBudgets,
);
return {
return streamBedrock(model, context, {
...base,
maxTokens: adjusted.maxTokens,
reasoning: options.reasoning,
@@ -402,15 +393,15 @@ function resolveSimpleBedrockOptions(
...options.thinkingBudgets,
[clampReasoning(options.reasoning)!]: adjusted.thinkingBudget,
},
} satisfies BedrockOptions;
} satisfies BedrockOptions);
}
return {
return streamBedrock(model, context, {
...base,
reasoning: options.reasoning,
thinkingBudgets: options.thinkingBudgets,
} satisfies BedrockOptions;
}
} satisfies BedrockOptions);
};
function handleContentBlockStart(
event: ContentBlockStartEvent,
@@ -562,37 +553,15 @@ function resolveClaudeProfileNameModelId(modelName?: string): string | undefined
if (!normalized.includes("claude")) {
return undefined;
}
const family = /(?:fable-5|mythos-preview|opus-4-(?:6|7|8)|sonnet-4-6)(?:$|-)/.exec(
normalized,
)?.[0];
const family = /(?:fable-5|opus-4-(?:6|7|8)|sonnet-4-6)(?:$|-)/.exec(normalized)?.[0];
return family ? `claude-${family.replace(/-$/, "")}` : undefined;
}
function isClaudeMythosPreviewModelId(modelId?: string): boolean {
return /(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
modelId
?.trim()
.toLowerCase()
.replace(/[\s_.:]+/g, "-") ?? "",
);
}
/** Check canonical metadata and profile names for adaptive Claude support. */
function supportsAdaptiveThinking(model: Model<"bedrock-converse-stream">): boolean {
const profileModelId = resolveClaudeProfileNameModelId(model.name);
return (
supportsClaudeAdaptiveThinking(model) ||
supportsClaudeAdaptiveThinking({ id: profileModelId }) ||
isClaudeMythosPreviewModelId(resolveClaudeModelIdentity(model)) ||
isClaudeMythosPreviewModelId(profileModelId)
);
}
function requiresMandatoryAdaptiveThinking(model: Model<"bedrock-converse-stream">): boolean {
const profileModelId = resolveClaudeProfileNameModelId(model.name);
return (
isClaudeMythosPreviewModelId(resolveClaudeModelIdentity(model)) ||
isClaudeMythosPreviewModelId(profileModelId)
supportsClaudeAdaptiveThinking(model) || supportsClaudeAdaptiveThinking({ id: profileModelId })
);
}
@@ -1102,11 +1071,9 @@ function createImageBlock(mimeType: string, data: string) {
/** Test-only hooks for Bedrock runtime conversion and endpoint policy. */
export const testing = {
buildAdditionalModelRequestFields,
convertMessages,
getConfiguredBedrockRegion,
hasConfiguredBedrockProfile,
mapThinkingLevelToEffort,
resolveSimpleBedrockOptions,
shouldUseExplicitBedrockEndpoint,
};

View File

@@ -43,28 +43,6 @@ export function isOpus47OrNewerBedrockModelRef(modelRef: string): boolean {
return isOpus47BedrockModelRef(modelRef) || isOpus48BedrockModelRef(modelRef);
}
function isMythosPreviewBedrockModelRef(modelRef: string): boolean {
return /(?:^|[/.:])(?:(?:us|eu|ap|apac|au|jp|global)\.)?(?:anthropic\.)?claude-mythos-preview(?:$|[-.:/])/i.test(
modelRef,
);
}
/** Return whether a Bedrock Claude ref needs latest adaptive-thinking request shaping. */
export function isLatestAdaptiveBedrockModelRef(
modelId: string,
params?: Record<string, unknown>,
): boolean {
const modelRef = { id: modelId, params };
const canonicalModelId = resolveClaudeModelIdentity(modelRef);
return (
resolveClaudeFable5ModelIdentity(modelRef) !== undefined ||
[modelId, canonicalModelId].some(
(candidate) =>
isOpus47OrNewerBedrockModelRef(candidate) || isMythosPreviewBedrockModelRef(candidate),
)
);
}
/** Return whether a Bedrock Claude ref supports max effort. */
export function supportsBedrockNativeMaxEffort(
modelId: string,
@@ -131,12 +109,6 @@ export function resolveBedrockClaudeThinkingProfile(
defaultLevel: "adaptive",
};
}
if (modelRefs.some(isMythosPreviewBedrockModelRef)) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }],
defaultLevel: "adaptive",
};
}
if (modelRefs.some((modelRef) => /claude-sonnet-4(?:\.|-)6(?:$|[-.])/i.test(modelRef))) {
return {
levels: [...BASE_CLAUDE_THINKING_LEVELS, { id: "adaptive" }],

View File

@@ -180,7 +180,7 @@ describe("createAnthropicVertexStreamFn", () => {
expect(streamTransportOptions(streamAnthropicMock).maxTokens).toBe(128000);
});
it.each(["claude-opus-4-8", "claude-opus-4-7", "claude-fable-5", "claude-mythos-5"])(
it.each(["claude-opus-4-8", "claude-opus-4-7"])(
"omits unsupported temperature for %s",
(modelId) => {
const { deps, streamAnthropicMock } = createStreamDeps();
@@ -219,21 +219,6 @@ describe("createAnthropicVertexStreamFn", () => {
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it("uses Mythos 5's mandatory adaptive Vertex contract by default", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);
const model = makeModel({ id: "claude-mythos-5", maxTokens: 128000 });
void streamFn(model, { messages: [] }, { temperature: 0.7 });
expect(streamTransportOptions(streamAnthropicMock)).toMatchObject({
thinkingEnabled: true,
effort: "high",
maxTokens: 128000,
});
expect(streamTransportOptions(streamAnthropicMock)).not.toHaveProperty("temperature");
});
it("uses canonical Claude policy for Vertex deployment aliases", () => {
const { deps, streamAnthropicMock } = createStreamDeps();
const streamFn = createAnthropicVertexStreamFn("vertex-project", "us-east5", undefined, deps);

View File

@@ -58,12 +58,8 @@ function isClaudeFable5Model(modelId: string): boolean {
return resolveClaudeFable5ModelIdentity({ id: modelId }) !== undefined;
}
function isClaudeMythos5Model(modelId: string): boolean {
return /(?:^|-)claude-mythos-5(?=$|[^a-z0-9])/.test(resolveClaudeModelIdentity({ id: modelId }));
}
function supportsAdaptiveThinking(modelId: string): boolean {
return supportsClaudeAdaptiveThinking({ id: modelId }) || isClaudeMythos5Model(modelId);
return supportsClaudeAdaptiveThinking({ id: modelId });
}
function mapAnthropicAdaptiveEffort(
@@ -86,13 +82,10 @@ function mapAnthropicAdaptiveEffort(
high: "high",
xhigh: isClaudeFable5Model(modelId)
? "xhigh"
: isClaudeOpus47OrNewerModel(modelId) || isClaudeMythos5Model(modelId)
: isClaudeOpus47OrNewerModel(modelId)
? "xhigh"
: "high",
max:
supportsClaudeNativeMaxEffort({ id: modelId }) || isClaudeMythos5Model(modelId)
? "max"
: "high",
max: supportsClaudeNativeMaxEffort({ id: modelId }) ? "max" : "high",
};
return effortMap[resolvedReasoning] ?? "high";
}
@@ -180,16 +173,11 @@ export function createAnthropicVertexStreamFn(
});
const contractModelId = resolveClaudeModelIdentity(model);
const fable5 = isClaudeFable5Model(contractModelId);
const mandatoryAdaptiveThinking = fable5 || isClaudeMythos5Model(contractModelId);
const reasoning =
(options?.reasoning as ModelThinkingLevel | undefined) ??
(mandatoryAdaptiveThinking ? "high" : undefined);
const reasoning = options?.reasoning as ModelThinkingLevel | undefined;
const adaptiveThinking =
mandatoryAdaptiveThinking || Boolean(reasoning && supportsAdaptiveThinking(contractModelId));
fable5 || Boolean(reasoning && supportsAdaptiveThinking(contractModelId));
const temperature =
adaptiveThinking ||
isClaudeOpus47OrNewerModel(contractModelId) ||
isClaudeMythos5Model(contractModelId)
adaptiveThinking || isClaudeOpus47OrNewerModel(contractModelId)
? undefined
: options?.temperature;
const opts: AnthropicVertexTransportOptions = {

View File

@@ -710,28 +710,6 @@ describe("anthropic provider replay hooks", () => {
expect(resolved).toBeUndefined();
});
it("normalizes Claude Mythos Preview with native max but no xhigh thinking map", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);
const normalized = provider.normalizeResolvedModel?.({
provider: "anthropic",
modelId: "claude-mythos-preview",
model: {
id: "claude-mythos-preview",
name: "Claude Mythos Preview",
provider: "anthropic",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 200_000,
maxTokens: 64_000,
},
} as never);
expect(normalized?.thinkingLevelMap).toEqual({ max: "max" });
});
it("normalizes stale text-only modern Claude vision rows to image-capable", async () => {
const provider = await registerSingleProviderPlugin(anthropicPlugin);

View File

@@ -34,7 +34,6 @@ import {
resolveClaudeModelIdentity,
resolveClaudeThinkingProfile,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeMaxEffort,
supportsClaudeNativeXhighEffort,
} from "openclaw/plugin-sdk/provider-model-shared";
import { fetchClaudeUsage } from "openclaw/plugin-sdk/provider-usage";
@@ -293,11 +292,6 @@ function buildAnthropicForwardCompatModel(
maxTokens: isAnthropic128kOutputModel(trimmedModelId)
? ANTHROPIC_MODERN_MAX_OUTPUT_TOKENS
: 64_000,
...(supportsClaudeNativeXhighEffort({ id: trimmedModelId })
? { thinkingLevelMap: { xhigh: "xhigh", max: "max" } }
: supportsAnthropicNativeMaxEffort(trimmedModelId)
? { thinkingLevelMap: { max: "max" } }
: {}),
};
}
@@ -367,16 +361,6 @@ function isAnthropicOpus47OrNewerModel(modelId: string): boolean {
return supportsClaudeNativeXhighEffort({ id: modelId }) && !isAnthropicFable5Model(modelId);
}
function isAnthropicMythosPreviewModel(modelId: string): boolean {
return /(?:^|-)claude-mythos-preview(?=$|[^a-z0-9])/.test(
resolveClaudeModelIdentity({ id: modelId }),
);
}
function supportsAnthropicNativeMaxEffort(modelId: string): boolean {
return supportsClaudeNativeMaxEffort({ id: modelId }) || isAnthropicMythosPreviewModel(modelId);
}
function hasConfiguredModelContextOverride(
config: ProviderNormalizeResolvedModelContext["config"],
provider: string,
@@ -471,17 +455,15 @@ function applyAnthropicThinkingLevelMap(params: {
}): ProviderRuntimeModel | undefined {
const fable5 = isAnthropicFable5Model(params.modelId);
const nativeXhigh = fable5 || isAnthropicOpus47OrNewerModel(params.modelId);
if (!supportsAnthropicNativeMaxEffort(params.modelId)) {
if (!matchesAnthropicModernModel(params.modelId)) {
return undefined;
}
const current = params.model.thinkingLevelMap;
const nativeDefaults = isAnthropicMythosPreviewModel(params.modelId)
? { max: "max" as const }
: {
...(fable5 ? { off: "low" as const, minimal: "low" as const } : {}),
xhigh: nativeXhigh ? ("xhigh" as const) : null,
max: "max" as const,
};
const nativeDefaults = {
...(fable5 ? { off: "low" as const, minimal: "low" as const } : {}),
xhigh: nativeXhigh ? ("xhigh" as const) : null,
max: "max" as const,
};
const currentEfforts = current as Record<string, string | null | undefined> | undefined;
if (Object.keys(nativeDefaults).every((level) => currentEfforts?.[level] !== undefined)) {
return undefined;
@@ -496,7 +478,7 @@ function applyAnthropicThinkingLevelMap(params: {
}
function matchesAnthropicModernModel(modelId: string): boolean {
return supportsClaudeAdaptiveThinking({ id: modelId }) || isAnthropicMythosPreviewModel(modelId);
return supportsClaudeAdaptiveThinking({ id: modelId });
}
function hasImageInput(input: unknown): boolean {

View File

@@ -1,13 +1,7 @@
// Discord tests cover command deploy plugin behavior.
/* oxlint-disable typescript/unbound-method -- vitest mocks of RequestClient methods (createRest) intentionally expose vi.fn refs via `restA.get`/`.post`; not unbound class methods. */
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { ApplicationCommandType, type APIApplicationCommand } from "discord-api-types/v10";
import { describe, expect, test, vi } from "vitest";
import { DiscordCommandDeployer, testing } from "./command-deploy.js";
import { BaseCommand } from "./commands.js";
import type { RequestClient } from "./rest.js";
import type { APIApplicationCommand } from "discord-api-types/v10";
import { describe, expect, test } from "vitest";
import { testing } from "./command-deploy.js";
const { commandsEqual } = testing;
@@ -202,332 +196,3 @@ describe("commandsEqual", () => {
expect(commandsEqual(current, desired)).toBe(true);
});
});
/**
* Regression for #77359: when two Discord accounts share the same on-disk
* deploy-cache file (the default in multi-bot setups) the persisted hash key
* must be scoped by application/client id. Otherwise a later account whose
* command set hashes the same as the first account's reuses the first
* account's hash and skips reconciling its own Discord application — leaving
* "This application has no commands" in the secondary bot's Integrations panel.
*/
describe("DiscordCommandDeployer cache scoping (multi-application)", () => {
class StaticCommand extends BaseCommand {
name: string;
override description = "ping the bot";
type = ApplicationCommandType.ChatInput;
constructor(name: string) {
super();
this.name = name;
}
serializeOptions() {
return undefined;
}
}
function createRest(): RequestClient {
return {
get: vi.fn(async () => []),
post: vi.fn(async () => undefined),
patch: vi.fn(async () => undefined),
put: vi.fn(async () => undefined),
delete: vi.fn(async () => undefined),
} as unknown as RequestClient;
}
test("two applications with identical command sets each reconcile their own application", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
const restA = createRest();
const deployerA = new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restA,
});
await deployerA.deploy({ mode: "reconcile" });
const restB = createRest();
const deployerB = new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => restB,
});
await deployerB.deploy({ mode: "reconcile" });
// The first deploy issues a list + create against application "app-default".
expect(restA.get).toHaveBeenCalledTimes(1);
expect(restA.post).toHaveBeenCalledTimes(1);
// The second deploy MUST also list + create against "app-secondary"; before
// the fix it short-circuited on the shared `global:reconcile` hash and
// never touched its own Discord application.
expect(restB.get).toHaveBeenCalledTimes(1);
expect(restB.post).toHaveBeenCalledTimes(1);
});
test("re-deploying the same application still hits the persisted cache", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
const restFirst = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restFirst,
}).deploy({ mode: "reconcile" });
const restSecond = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restSecond,
}).deploy({ mode: "reconcile" });
expect(restFirst.get).toHaveBeenCalledTimes(1);
expect(restFirst.post).toHaveBeenCalledTimes(1);
// Same application, same command set, same hash file => skip reconcile.
expect(restSecond.get).not.toHaveBeenCalled();
expect(restSecond.post).not.toHaveBeenCalled();
});
test("persisted cache keys are namespaced by application id", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
const keys = Object.keys(parsed.hashes);
expect(keys).toContain("app:app-default:global:reconcile");
expect(keys).toContain("app:app-secondary:global:reconcile");
expect(keys).not.toContain("global:reconcile");
});
test("successful deploy repairs a corrupt persisted cache file", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
await fs.writeFile(hashStorePath, "{not json", "utf8");
await new DiscordCommandDeployer({
clientId: "app-default",
commands: [new StaticCommand("ping")],
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
expect(parsed.hashes).toHaveProperty("app:app-default:global:reconcile");
});
test("a deployer that loaded an empty cache before another deployer's write preserves the other deployer's entries on persist", async () => {
// Regression for the codex follow-up on PR #77367: `server-channels.ts`
// can start multiple Discord deployers concurrently. Before the fix, a
// deployer that loaded the (empty) cache file before another deployer's
// first write would later overwrite it on its own `persistHashes()`,
// serializing only its own in-memory `app:<id>:...` entry and dropping
// the other deployer's entry. The current implementation re-reads the
// on-disk hashes inside `persistHashes` and merges them with our
// in-memory entries before the rename.
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
// Deployer B starts first, loads the empty cache. Then deployer A
// completes its full deploy + persist, writing `app:app-default:...` to
// disk. When deployer B finally persists, it must merge in deployer A's
// entry instead of overwriting it with just its own.
const deployerB = new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => createRest(),
});
// Trigger B's load of the (still missing) cache file by starting deploy
// and immediately awaiting just enough to clear the load. The deploy
// call awaits loadPersistedHashes inside putCommandSetIfChanged before
// calling deploy(); to keep the seam minimal here, we just race the load
// by running deployer A's full deploy in between.
const deployerA = new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => createRest(),
});
// Step 1: A runs a full deploy (load -> reconcile -> persist) on the
// initially missing cache file; result: file now has app-default entry.
await deployerA.deploy({ mode: "reconcile" });
// Step 2: B runs its full deploy. Without the fix, B's persistHashes
// would write only `app:app-secondary:...` and drop A's entry. With the
// fix, B re-reads the on-disk file inside persistHashes, sees A's entry,
// and merges it into the write so both keys survive.
await deployerB.deploy({ mode: "reconcile" });
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
const keys = Object.keys(parsed.hashes);
expect(keys).toContain("app:app-default:global:reconcile");
expect(keys).toContain("app:app-secondary:global:reconcile");
// And subsequent restarts must still hit the cache for both apps,
// proving the rate-limit protection survived the concurrent write.
const restA = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restA,
}).deploy({ mode: "reconcile" });
const restB = createRest();
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => restB,
}).deploy({ mode: "reconcile" });
expect(restA.get).not.toHaveBeenCalled();
expect(restA.post).not.toHaveBeenCalled();
expect(restB.get).not.toHaveBeenCalled();
expect(restB.post).not.toHaveBeenCalled();
});
test("truly parallel deployers serialize cache writes via the per-path mutex (codex follow-up on #77367)", async () => {
// Codex follow-up on PR #77367: re-read-before-write alone isn't enough
// when two deployers run `persistHashes` in real parallel — both can read
// the same snapshot before either writes. The in-process per-path mutex
// around the read-merge-write cycle makes the operation atomic.
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const commands = [new StaticCommand("ping")];
// Run BOTH deploys with Promise.all on the SAME process tick — pre-fix,
// both `persistHashes` calls would race on read-then-rename and one
// writer's `app:<id>:...` entry would be lost.
const restA = createRest();
const restB = createRest();
const restC = createRest();
await Promise.all([
new DiscordCommandDeployer({
clientId: "app-default",
commands,
hashStorePath,
rest: () => restA,
}).deploy({ mode: "reconcile" }),
new DiscordCommandDeployer({
clientId: "app-secondary",
commands,
hashStorePath,
rest: () => restB,
}).deploy({ mode: "reconcile" }),
new DiscordCommandDeployer({
clientId: "app-tertiary",
commands,
hashStorePath,
rest: () => restC,
}).deploy({ mode: "reconcile" }),
]);
const raw = await fs.readFile(hashStorePath, "utf8");
const parsed = JSON.parse(raw) as { hashes: Record<string, string> };
const keys = Object.keys(parsed.hashes);
// All three apps' entries must survive — pre-fix, one or two would be
// lost to the race.
expect(keys).toContain("app:app-default:global:reconcile");
expect(keys).toContain("app:app-secondary:global:reconcile");
expect(keys).toContain("app:app-tertiary:global:reconcile");
});
test("parallel changed deploys preserve fresher sibling cache entries", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-discord-multi-app-"));
const hashStorePath = path.join(dir, "command-deploy-cache.json");
const oldCommands = [new StaticCommand("ping")];
const newCommands = [new StaticCommand("status")];
await new DiscordCommandDeployer({
clientId: "app-default",
commands: oldCommands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands: oldCommands,
hashStorePath,
rest: () => createRest(),
}).deploy({ mode: "reconcile" });
let postStarts = 0;
let releasePosts: () => void = () => {};
const bothPostsStarted = new Promise<void>((resolve) => {
releasePosts = resolve;
});
function createWaitingRest(): RequestClient {
const rest = createRest();
rest.post = vi.fn(async () => {
postStarts += 1;
if (postStarts === 2) {
releasePosts();
}
await bothPostsStarted;
}) as RequestClient["post"];
return rest;
}
await Promise.all([
new DiscordCommandDeployer({
clientId: "app-default",
commands: newCommands,
hashStorePath,
rest: () => createWaitingRest(),
}).deploy({ mode: "reconcile" }),
new DiscordCommandDeployer({
clientId: "app-secondary",
commands: newCommands,
hashStorePath,
rest: () => createWaitingRest(),
}).deploy({ mode: "reconcile" }),
]);
const restA = createRest();
await new DiscordCommandDeployer({
clientId: "app-default",
commands: newCommands,
hashStorePath,
rest: () => restA,
}).deploy({ mode: "reconcile" });
const restB = createRest();
await new DiscordCommandDeployer({
clientId: "app-secondary",
commands: newCommands,
hashStorePath,
rest: () => restB,
}).deploy({ mode: "reconcile" });
expect(restA.get).not.toHaveBeenCalled();
expect(restA.post).not.toHaveBeenCalled();
expect(restB.get).not.toHaveBeenCalled();
expect(restB.post).not.toHaveBeenCalled();
});
});

View File

@@ -21,42 +21,8 @@ export type DeployCommandOptions = {
type SerializedCommand = ReturnType<BaseCommand["serialize"]>;
/**
* Per-`command-deploy-cache.json` path async mutex. `server-channels.ts` can
* start several Discord deployers concurrently in the same Node.js process;
* each one shares the same on-disk cache file. Without this lock, two
* deployers can run `persistHashes` in parallel, both read the same on-disk
* snapshot before either writes, and the later `rename` then overwrites the
* earlier writer's entries — defeating the rate-limit cache.
*
* This is an in-process lock; cross-process serialization would need an OS
* file lock. Discord deployers only run inside the gateway process, so an
* in-process mutex is sufficient for the documented concurrency surface.
*/
const cachePersistLocks = new Map<string, Promise<void>>();
async function withCachePersistLock<T>(storePath: string, fn: () => Promise<T>): Promise<T> {
const previous = cachePersistLocks.get(storePath) ?? Promise.resolve();
let release: () => void = () => {};
const next = new Promise<void>((resolve) => {
release = resolve;
});
const chained = previous.then(() => next);
cachePersistLocks.set(storePath, chained);
try {
await previous;
return await fn();
} finally {
release();
if (cachePersistLocks.get(storePath) === chained) {
cachePersistLocks.delete(storePath);
}
}
}
export class DiscordCommandDeployer {
private readonly hashes = new Map<string, string>();
private readonly pendingHashes = new Map<string, string>();
private hashesLoaded = false;
constructor(
@@ -79,7 +45,7 @@ export class DiscordCommandDeployer {
const serializedGlobal = globalCommands.map((command) => command.serialize());
for (const [guildId, entries] of groupGuildCommands(commands)) {
await this.putCommandSetIfChanged(
this.scopedCacheKey(`guild:${guildId}`),
`guild:${guildId}`,
entries,
async () => {
await overwriteGuildApplicationCommands(
@@ -96,7 +62,7 @@ export class DiscordCommandDeployer {
for (const guildId of this.params.devGuilds) {
const entries = commands.map((command) => command.serialize());
await this.putCommandSetIfChanged(
this.scopedCacheKey(`dev-guild:${guildId}`),
`dev-guild:${guildId}`,
entries,
async () => {
await overwriteGuildApplicationCommands(
@@ -113,7 +79,7 @@ export class DiscordCommandDeployer {
}
if (options.mode !== "overwrite") {
await this.putCommandSetIfChanged(
this.scopedCacheKey("global:reconcile"),
"global:reconcile",
serializedGlobal,
async () => {
await this.reconcileGlobalCommands(serializedGlobal);
@@ -123,7 +89,7 @@ export class DiscordCommandDeployer {
return { mode: "reconcile" as const, usedDevGuilds: false };
}
await this.putCommandSetIfChanged(
this.scopedCacheKey("global:overwrite"),
"global:overwrite",
serializedGlobal,
async () => {
await overwriteApplicationCommands(this.rest, this.params.clientId, serializedGlobal);
@@ -133,17 +99,6 @@ export class DiscordCommandDeployer {
return { mode: "overwrite" as const, usedDevGuilds: false };
}
/**
* Scope cache keys by Discord application id so multi-bot setups that share a
* single deploy-cache file still reconcile each application separately. The
* prior unscoped `global:reconcile` / `guild:<id>` keys let a later account
* with an identical command set reuse the first account's hash and skip its
* own application's reconcile entirely (#77359).
*/
private scopedCacheKey(suffix: string): string {
return `app:${this.params.clientId}:${suffix}`;
}
private async reconcileGlobalCommands(desired: SerializedCommand[]) {
const existing = await this.getCommands();
const existingByKey = new Map(existing.map((command) => [stableCommandKey(command), command]));
@@ -180,7 +135,6 @@ export class DiscordCommandDeployer {
}
await deploy();
this.hashes.set(key, hash);
this.pendingHashes.set(key, hash);
await this.persistHashes();
}
@@ -215,62 +169,18 @@ export class DiscordCommandDeployer {
if (!storePath) {
return;
}
// Serialize concurrent persists for the same on-disk path. The earlier
// "re-read inside persistHashes" merge alone is not enough — two
// deployers running `persistHashes` in true parallel would both read the
// same snapshot before either writes, and the later `rename` would still
// overwrite the earlier one's `app:<id>:...` entries. The mutex makes the
// read-merge-write cycle atomic for in-process callers.
await withCachePersistLock(storePath, async () => {
await this.persistHashesLocked(storePath);
});
}
private async persistHashesLocked(storePath: string): Promise<void> {
try {
// Re-read the on-disk hashes immediately before writing and merge only
// keys this deployer changed. Previously loaded hashes can be stale when
// sibling deployers update the same file, so on-disk wins for untouched
// keys while pending keys win because this deployer just produced them.
const storeFile = path.basename(storePath);
const fileStore = privateFileStore(path.dirname(storePath));
const merged = new Map<string, string>();
let onDisk: { hashes?: unknown } | null = null;
try {
onDisk = await fileStore.readJsonIfExists<{
hashes?: unknown;
}>(storeFile);
} catch {
// A corrupt cache should not become permanent. Treat the re-read as
// empty and replace it with the fresh pending hashes after deploy.
}
if (onDisk?.hashes && typeof onDisk.hashes === "object") {
for (const [key, value] of Object.entries(onDisk.hashes)) {
if (typeof value === "string" && key.trim() && value.trim()) {
merged.set(key, value);
}
}
}
for (const [key, value] of this.pendingHashes.entries()) {
merged.set(key, value);
}
await fileStore.writeJson(
storeFile,
await privateFileStore(path.dirname(storePath)).writeJson(
path.basename(storePath),
{
version: 1,
updatedAt: new Date().toISOString(),
hashes: Object.fromEntries(
[...merged.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
[...this.hashes.entries()].toSorted(([left], [right]) => left.localeCompare(right)),
),
},
{ trailingNewline: true },
);
// Refresh in-memory state so future writes from the same deployer also
// see entries that other deployers added concurrently.
for (const [key, value] of merged.entries()) {
this.hashes.set(key, value);
}
this.pendingHashes.clear();
} catch {
// The cache is only an optimization to avoid redundant Discord writes.
}

View File

@@ -148,7 +148,6 @@ type DispatchInboundParams = {
title?: string;
name?: string;
}) => Promise<void> | void;
onVerboseProgressVisibility?: (isActive: () => boolean) => void;
onPlanUpdate?: (payload: {
phase?: string;
explanation?: string;
@@ -2783,50 +2782,6 @@ describe("processDiscordMessage draft streaming", () => {
expect(updates).not.toContain("NO_REPLY");
});
it.each([
["active", true],
["inactive", false],
])(
"renders Discord commentary in the draft exactly when durable verbose progress is %s",
async (_label, durableLaneActive) => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
params?.replyOptions?.onVerboseProgressVisibility?.(() => durableLaneActive);
await params?.replyOptions?.onItemEvent?.({
itemId: "preamble-1",
kind: "preamble",
progressText: "Checking the current weather source before summarizing.",
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: false,
toolProgress: false,
commentary: true,
},
},
},
});
await runProcessDiscordMessage(ctx);
const updates = draftStream.update.mock.calls.map((call) => call[0]).join("\n");
if (durableLaneActive) {
// The durable verbose lane owns commentary: the ephemeral draft must
// not render it a second time.
expect(updates).toBe("");
} else {
expect(updates).toContain("Checking the current weather source");
}
},
);
it("keeps Discord progress drafts usable after the last commentary line becomes silent", async () => {
const draftStream = createMockDraftStreamForTest();

View File

@@ -557,10 +557,6 @@ async function processDiscordMessageInner(
chunkMode,
log: logVerbose,
});
// While the durable verbose commentary lane is active (dispatch reports it
// via onVerboseProgressVisibility), the ephemeral draft yields its commentary
// lines so commentary is not rendered in both lanes.
let verboseProgressActive: () => boolean = () => false;
const finalPreviewFlags =
(discordConfig?.suppressEmbeds ?? true) ? MessageFlags.SuppressEmbeds : undefined;
let finalReplyStartNotified = false;
@@ -1007,9 +1003,6 @@ async function processDiscordMessageInner(
commentaryProgressEnabled: draftPreview.isProgressMode
? draftPreview.commentaryProgressEnabled
: undefined,
onVerboseProgressVisibility: (isActive) => {
verboseProgressActive = isActive;
},
onReasoningStream: async (payload) => {
await statusReactions.setThinking();
await draftPreview.pushReasoningProgress(payload?.text, {
@@ -1038,9 +1031,6 @@ async function processDiscordMessageInner(
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
if (verboseProgressActive()) {
return;
}
if (draftPreview.commentaryProgressEnabled && payload.progressText) {
await draftPreview.pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,

View File

@@ -193,7 +193,7 @@ describe("Discord model picker preference migration", () => {
expect(plan.pluginId).toBe("discord");
expect(plan.namespace).toBe("thread-bindings");
const entries = await plan.readEntries();
expect(entries).toStrictEqual([
expect(entries).toEqual([
{
key: "default:legacy-thread",
value: {

View File

@@ -195,10 +195,7 @@ export const detectDiscordLegacyStateMigrations: BundledChannelLegacyStateMigrat
)) {
const normalized = normalizePersistedBinding(rawKey, rawEntry);
if (normalized) {
out.push({
key: toBindingRecordKey(normalized),
value: normalized,
});
out.push({ key: toBindingRecordKey(normalized), value: normalized });
}
}
return out;

View File

@@ -215,36 +215,23 @@ export function normalizePersistedBinding(
}
}
const record: ThreadBindingRecord = {
return {
accountId,
channelId,
threadId,
targetKind,
targetSessionKey,
agentId,
label,
webhookId,
webhookToken,
boundBy,
boundAt,
lastActivityAt,
idleTimeoutMs: migratedIdleTimeoutMs,
maxAgeMs: migratedMaxAgeMs,
metadata,
};
if (label !== undefined) {
record.label = label;
}
if (webhookId !== undefined) {
record.webhookId = webhookId;
}
if (webhookToken !== undefined) {
record.webhookToken = webhookToken;
}
if (migratedIdleTimeoutMs !== undefined) {
record.idleTimeoutMs = migratedIdleTimeoutMs;
}
if (migratedMaxAgeMs !== undefined) {
record.maxAgeMs = migratedMaxAgeMs;
}
if (metadata !== undefined) {
record.metadata = metadata;
}
return record;
}
export function normalizeThreadBindingDurationMs(raw: unknown, defaultsTo: number): number {

View File

@@ -172,42 +172,6 @@ describe("fal video generation provider", () => {
});
});
it("parses raw fal queue result payloads with top-level video output", async () => {
mockFalProviderRuntime();
fetchGuardMock
.mockResolvedValueOnce(
releasedJson({
request_id: "req-raw",
status_url: "https://queue.fal.run/fal-ai/wan/requests/req-raw/status",
response_url: "https://queue.fal.run/fal-ai/wan/requests/req-raw",
}),
)
.mockResolvedValueOnce(releasedJson({ status: "COMPLETED" }))
.mockResolvedValueOnce(
releasedJson({
video: { url: "https://fal.run/files/raw-output.mp4" },
prompt: "A calm harbor at sunrise",
seed: 443600358,
}),
)
.mockResolvedValueOnce(releasedVideo({ contentType: "video/mp4", bytes: "mp4-bytes" }));
const provider = buildFalVideoGenerationProvider();
const result = await provider.generateVideo({
provider: "fal",
model: "fal-ai/wan/v2.2-a14b/image-to-video",
prompt: "A calm harbor at sunrise",
cfg: {},
});
expect(result.videos[0]?.url).toBe("https://fal.run/files/raw-output.mp4");
expect(result.metadata).toEqual({
requestId: "req-raw",
prompt: "A calm harbor at sunrise",
seed: 443600358,
});
});
it("returns URL-only videos when generated video downloads exceed the configured media cap", async () => {
mockFalProviderRuntime();
mockCompletedFalVideoJob({

View File

@@ -166,21 +166,6 @@ function readFalQueueResponse(payload: unknown): FalQueueResponse {
};
}
function readFalCompletedQueueResult(payload: unknown): FalQueueResponse {
if (!isRecord(payload)) {
throw new Error(FAL_VIDEO_MALFORMED_RESPONSE);
}
if (
payload.response !== undefined ||
(payload.video === undefined && payload.videos === undefined)
) {
return readFalQueueResponse(payload);
}
return {
response: readFalVideoPayload(payload),
};
}
function toDataUrl(buffer: Buffer, mimeType: string): string {
return `data:${mimeType};base64,${buffer.toString("base64")}`;
}
@@ -524,7 +509,7 @@ async function waitForFalQueueResult(params: {
}
lastStatus = status;
if (status === "COMPLETED") {
return readFalCompletedQueueResult(
return readFalQueueResponse(
await fetchFalJson({
url: params.responseUrl,
init: {

View File

@@ -525,40 +525,6 @@ describe("handleFeishuMessage ACP routing", () => {
expect(message.text).toContain("runtime unavailable");
});
it("surfaces configured ACP initialization failures inside P2P direct-message threads", async () => {
mockResolveConfiguredBindingRoute.mockReturnValue(createConfiguredFeishuRoute());
mockEnsureConfiguredBindingRouteReady.mockResolvedValue(
createConfiguredBindingReadiness(false, "runtime unavailable"),
);
await dispatchMessage({
cfg: {
session: { mainKey: "main", scope: "per-sender" },
channels: { feishu: { enabled: true, allowFrom: ["ou_sender_1"], dmPolicy: "open" } },
},
event: {
sender: { sender_id: { open_id: "ou_sender_1" } },
message: {
message_id: "msg-thread-child",
root_id: "msg-thread-root",
thread_id: "omt-acp-dm-thread",
chat_id: "oc_dm",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello" }),
},
},
});
expect(mockSendMessageFeishu).toHaveBeenCalledWith(
expect.objectContaining({
to: "chat:oc_dm",
replyToMessageId: "msg-thread-root",
replyInThread: true,
}),
);
});
it("routes Feishu topic messages through active bound conversations", async () => {
mockResolveBoundConversation.mockReturnValue(createBoundConversation());
@@ -3393,79 +3359,6 @@ describe("handleFeishuMessage command authorization", () => {
expect(dispatcherOptions.rootId).toBe("om_topic_sender_root");
});
it("keeps P2P replies inside a direct-message thread when Feishu supplies thread_id", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-thread-dm" } },
message: {
message_id: "om_dm_thread_child",
root_id: "om_dm_thread_root",
thread_id: "omt_dm_thread",
chat_id: "oc-dm-thread",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "hello inside a DM thread" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_dm_thread_root",
rootId: "om_dm_thread_root",
skipReplyToInMessages: false,
replyInThread: true,
threadReply: true,
}),
);
});
it("keeps root_id-only P2P replies as quote replies outside thread mode", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);
const cfg: ClawdbotConfig = {
channels: {
feishu: {
dmPolicy: "open",
},
},
} as ClawdbotConfig;
const event: FeishuMessageEvent = {
sender: { sender_id: { open_id: "ou-quote-dm" } },
message: {
message_id: "om_dm_quote_reply",
root_id: "om_dm_quote_root",
chat_id: "oc-dm-quote",
chat_type: "p2p",
message_type: "text",
content: JSON.stringify({ text: "quoted DM reply" }),
},
};
await dispatchMessage({ cfg, event });
expect(mockCreateFeishuReplyDispatcher).toHaveBeenCalledWith(
expect.objectContaining({
replyToMessageId: "om_dm_quote_reply",
rootId: "om_dm_quote_root",
skipReplyToInMessages: true,
replyInThread: false,
threadReply: false,
}),
);
});
it("forces thread replies when inbound message contains thread_id", async () => {
mockShouldComputeCommandAuthorized.mockReturnValue(false);

View File

@@ -802,14 +802,7 @@ export async function handleFeishuMessage(params: {
const feishuTo = isGroup ? `chat:${ctx.chatId}` : `user:${ctx.senderOpenId}`;
const peerId = isGroup ? (groupSession?.peerId ?? ctx.chatId) : ctx.senderOpenId;
const parentPeer = isGroup ? (groupSession?.parentPeer ?? null) : null;
const directThreadReply = !isGroup && Boolean(ctx.threadId?.trim());
const defaultReplyTargetMessageId =
ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId);
const directThreadRootId = directThreadReply ? ctx.rootId?.trim() || undefined : undefined;
const directThreadReplyTargetMessageId = directThreadReply
? (directThreadRootId ?? defaultReplyTargetMessageId)
: undefined;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : directThreadReply;
const replyInThread = isGroup ? (groupSession?.replyInThread ?? false) : false;
const feishuAcpConversationSupported =
!isGroup ||
groupSession?.groupSessionScope === "group_topic" ||
@@ -913,13 +906,10 @@ export async function handleFeishuMessage(params: {
bindingResolution: configuredBinding,
});
if (!ensured.ok) {
const acpTopicReply =
const replyTargetMessageId =
isGroup &&
(groupSession?.groupSessionScope === "group_topic" ||
groupSession?.groupSessionScope === "group_topic_sender");
const replyTargetMessageId = directThreadReply
? directThreadReplyTargetMessageId
: acpTopicReply
groupSession?.groupSessionScope === "group_topic_sender")
? (ctx.rootId ?? ctx.messageId)
: ctx.messageId;
await sendMessageFeishu({
@@ -927,7 +917,7 @@ export async function handleFeishuMessage(params: {
to: `chat:${ctx.chatId}`,
text: `⚠️ Failed to initialize the configured ACP session for this Feishu conversation: ${ensured.error}`,
replyToMessageId: replyTargetMessageId,
replyInThread,
replyInThread: isGroup ? (groupSession?.replyInThread ?? false) : false,
accountId: account.accountId,
}).catch((err: unknown) => {
log(`feishu[${account.accountId}]: failed to send ACP init error reply: ${String(err)}`);
@@ -1397,13 +1387,13 @@ export async function handleFeishuMessage(params: {
const configReplyInThread =
isGroup &&
(groupConfig?.replyInThread ?? feishuCfg?.replyInThread ?? "disabled") === "enabled";
const topicReplyTargetMessageId = ctx.rootId ?? defaultReplyTargetMessageId;
const replyTargetMessageId = directThreadReply
? directThreadReplyTargetMessageId
: isTopicSession || configReplyInThread
? topicReplyTargetMessageId
: defaultReplyTargetMessageId;
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : directThreadReply;
const replyTargetMessageId =
isTopicSession || configReplyInThread
? (ctx.rootId ??
ctx.replyTargetMessageId ??
(ctx.suppressReplyTarget ? undefined : ctx.messageId))
: (ctx.replyTargetMessageId ?? (ctx.suppressReplyTarget ? undefined : ctx.messageId));
const threadReply = isGroup ? (groupSession?.threadReply ?? false) : false;
const lastRouteThreadId =
isGroup && (isTopicSession || configReplyInThread || threadReply)
? replyTargetMessageId
@@ -1528,7 +1518,7 @@ export async function handleFeishuMessage(params: {
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup && !directThreadReply,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,
@@ -1704,7 +1694,7 @@ export async function handleFeishuMessage(params: {
chatId: ctx.chatId,
allowReasoningPreview,
replyToMessageId: replyTargetMessageId,
skipReplyToInMessages: !isGroup && !directThreadReply,
skipReplyToInMessages: !isGroup,
replyInThread,
rootId: ctx.rootId,
threadReply,

View File

@@ -23,14 +23,6 @@ const defaultFs: CredentialFs = {
};
let credentialFs: CredentialFs = defaultFs;
const GEMINI_CLI_TREE_SEARCH_DEPTH = 10;
type GeminiCliCredentialExtractDiagnostics = {
searchedPaths: string[];
recursiveSearchRoots: string[];
parseFailures: string[];
readErrors: string[];
};
function resolveEnv(keys: string[]): string | undefined {
for (const key of keys) {
@@ -43,11 +35,9 @@ function resolveEnv(keys: string[]): string | undefined {
}
let cachedGeminiCliCredentials: { clientId: string; clientSecret: string } | null = null;
let geminiCliCredentialExtractError: string | null = null;
export function clearCredentialsCache(): void {
cachedGeminiCliCredentials = null;
geminiCliCredentialExtractError = null;
}
export function setOAuthCredentialsFsForTest(overrides?: Partial<CredentialFs>): void {
@@ -59,19 +49,9 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
return cachedGeminiCliCredentials;
}
geminiCliCredentialExtractError = null;
const diagnostics: GeminiCliCredentialExtractDiagnostics = {
searchedPaths: [],
recursiveSearchRoots: [],
parseFailures: [],
readErrors: [],
};
try {
const geminiPath = findInPath("gemini");
if (!geminiPath) {
geminiCliCredentialExtractError =
"Gemini CLI binary was not found in PATH during OAuth credential extraction.";
return null;
}
@@ -79,84 +59,30 @@ export function extractGeminiCliCredentials(): { clientId: string; clientSecret:
const geminiCliDirs = resolveGeminiCliDirs(geminiPath, resolvedPath);
for (const geminiCliDir of geminiCliDirs) {
const directCredentials = readGeminiCliCredentialsFromKnownPaths(geminiCliDir, diagnostics);
const directCredentials = readGeminiCliCredentialsFromKnownPaths(geminiCliDir);
if (directCredentials) {
cachedGeminiCliCredentials = directCredentials;
return directCredentials;
}
const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir, diagnostics);
const bundledCredentials = readGeminiCliCredentialsFromBundle(geminiCliDir);
if (bundledCredentials) {
cachedGeminiCliCredentials = bundledCredentials;
return bundledCredentials;
}
diagnostics.recursiveSearchRoots.push(geminiCliDir);
const discoveredCredentials = findGeminiCliCredentialsInTree(
geminiCliDir,
GEMINI_CLI_TREE_SEARCH_DEPTH,
diagnostics,
);
const discoveredCredentials = findGeminiCliCredentialsInTree(geminiCliDir, 10);
if (discoveredCredentials) {
cachedGeminiCliCredentials = discoveredCredentials;
return discoveredCredentials;
}
}
geminiCliCredentialExtractError = formatGeminiCliCredentialExtractError({
geminiPath,
resolvedPath,
diagnostics,
});
} catch (error) {
geminiCliCredentialExtractError = `Unexpected error while extracting Gemini CLI OAuth credentials: ${formatError(error)}`;
} catch {
// Gemini CLI not installed or extraction failed
}
return null;
}
function formatGeminiCliCredentialExtractError({
geminiPath,
resolvedPath,
diagnostics,
}: {
geminiPath: string;
resolvedPath: string;
diagnostics: GeminiCliCredentialExtractDiagnostics;
}): string {
const prefix = [
"Found Gemini CLI in PATH, but could not extract OAuth credentials.",
`geminiPath=${geminiPath}`,
`resolvedPath=${resolvedPath}`,
];
if (diagnostics.parseFailures.length > 0) {
return [
...prefix,
"Candidate credential files did not contain a parseable OAuth client id/secret.",
`candidates=${diagnostics.parseFailures.join(", ")}`,
].join(" ");
}
if (diagnostics.readErrors.length > 0) {
return [
...prefix,
"Unexpected errors occurred while reading candidate credential files/directories.",
`errors=${diagnostics.readErrors.join(", ")}`,
].join(" ");
}
return [
...prefix,
"Could not locate oauth2.js or bundled credential source.",
`searched=${diagnostics.searchedPaths.join(", ") || "(none)"}`,
`recursiveSearchRoots=${diagnostics.recursiveSearchRoots.join(", ") || "(none)"}`,
`recursiveSearchDepth=${GEMINI_CLI_TREE_SEARCH_DEPTH}`,
].join(" ");
}
function formatError(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function resolveGeminiCliDirs(geminiPath: string, resolvedPath: string): string[] {
const binDir = dirname(geminiPath);
const candidates = [
@@ -216,16 +142,10 @@ function findInPath(name: string): string | null {
function readGeminiCliCredentialsFile(
path: string,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
try {
const credentials = parseGeminiCliCredentials(credentialFs.readFileSync(path, "utf8"));
if (!credentials) {
diagnostics.parseFailures.push(path);
}
return credentials;
} catch (error) {
diagnostics.readErrors.push(`${path}: ${formatError(error)}`);
return parseGeminiCliCredentials(credentialFs.readFileSync(path, "utf8"));
} catch {
return null;
}
}
@@ -247,7 +167,6 @@ function parseGeminiCliCredentials(
function readGeminiCliCredentialsFromKnownPaths(
geminiCliDir: string,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
const searchPaths = [
join(
@@ -270,13 +189,12 @@ function readGeminiCliCredentialsFromKnownPaths(
"oauth2.js",
),
];
diagnostics.searchedPaths.push(...searchPaths);
for (const path of searchPaths) {
if (!credentialFs.existsSync(path)) {
continue;
}
const credentials = readGeminiCliCredentialsFile(path, diagnostics);
const credentials = readGeminiCliCredentialsFile(path);
if (credentials) {
return credentials;
}
@@ -287,7 +205,6 @@ function readGeminiCliCredentialsFromKnownPaths(
function readGeminiCliCredentialsFromBundle(
geminiCliDir: string,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
const bundleDir = join(geminiCliDir, "bundle");
if (!credentialFs.existsSync(bundleDir)) {
@@ -299,14 +216,13 @@ function readGeminiCliCredentialsFromBundle(
if (!entry.isFile() || !entry.name.endsWith(".js")) {
continue;
}
const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name), diagnostics);
const credentials = readGeminiCliCredentialsFile(join(bundleDir, entry.name));
if (credentials) {
return credentials;
}
}
} catch (error) {
diagnostics.readErrors.push(`${bundleDir}: ${formatError(error)}`);
// Preserve the read error for diagnostics and fall back to the recursive search.
} catch {
// Ignore bundle traversal failures and fall back to the recursive search.
}
return null;
@@ -315,7 +231,6 @@ function readGeminiCliCredentialsFromBundle(
function findGeminiCliCredentialsInTree(
dir: string,
depth: number,
diagnostics: GeminiCliCredentialExtractDiagnostics,
): { clientId: string; clientSecret: string } | null {
if (depth <= 0) {
return null;
@@ -324,22 +239,20 @@ function findGeminiCliCredentialsInTree(
for (const entry of credentialFs.readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isFile() && entry.name === "oauth2.js") {
const credentials = readGeminiCliCredentialsFile(path, diagnostics);
const credentials = readGeminiCliCredentialsFile(path);
if (credentials) {
return credentials;
}
continue;
}
if (entry.isDirectory() && !entry.name.startsWith(".")) {
const found = findGeminiCliCredentialsInTree(path, depth - 1, diagnostics);
const found = findGeminiCliCredentialsInTree(path, depth - 1);
if (found) {
return found;
}
}
}
} catch (error) {
diagnostics.readErrors.push(`${dir}: ${formatError(error)}`);
}
} catch {}
return null;
}
@@ -355,10 +268,7 @@ export function resolveOAuthClientConfig(): { clientId: string; clientSecret?: s
return extracted;
}
const detail = geminiCliCredentialExtractError
? ` Details: ${geminiCliCredentialExtractError}`
: "";
throw new Error(
`Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.${detail}`,
"Gemini CLI not found. Install it first: brew install gemini-cli (or npm install -g @google/gemini-cli), or set GEMINI_CLI_OAUTH_CLIENT_ID.",
);
}

View File

@@ -142,12 +142,6 @@ describe("resolveGeminiCliSelectedAuthType", () => {
});
describe("extractGeminiCliCredentials", () => {
const ENV_KEYS = [
"OPENCLAW_GEMINI_OAUTH_CLIENT_ID",
"OPENCLAW_GEMINI_OAUTH_CLIENT_SECRET",
"GEMINI_CLI_OAUTH_CLIENT_ID",
"GEMINI_CLI_OAUTH_CLIENT_SECRET",
] as const;
const normalizePath = (value: string) =>
value.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
const rootDir = parse(process.cwd()).root || "/";
@@ -159,9 +153,7 @@ describe("extractGeminiCliCredentials", () => {
`;
let originalPath: string | undefined;
let envSnapshot: Partial<Record<(typeof ENV_KEYS)[number], string>>;
let extractGeminiCliCredentials: typeof import("./oauth.credentials.js").extractGeminiCliCredentials;
let resolveOAuthClientConfig: typeof import("./oauth.credentials.js").resolveOAuthClientConfig;
let clearCredentialsCache: typeof import("./oauth.credentials.js").clearCredentialsCache;
let setOAuthCredentialsFsForTest: typeof import("./oauth.credentials.js").setOAuthCredentialsFsForTest;
@@ -454,34 +446,18 @@ describe("extractGeminiCliCredentials", () => {
}
beforeAll(async () => {
({
extractGeminiCliCredentials,
resolveOAuthClientConfig,
clearCredentialsCache,
setOAuthCredentialsFsForTest,
} = await import("./oauth.credentials.js"));
({ extractGeminiCliCredentials, clearCredentialsCache, setOAuthCredentialsFsForTest } =
await import("./oauth.credentials.js"));
});
beforeEach(async () => {
vi.clearAllMocks();
originalPath = process.env.PATH;
envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
for (const key of ENV_KEYS) {
delete process.env[key];
}
await installMockFs();
});
afterEach(async () => {
process.env.PATH = originalPath;
for (const key of ENV_KEYS) {
const value = envSnapshot[key];
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
setOAuthCredentialsFsForTest();
});
@@ -493,16 +469,6 @@ describe("extractGeminiCliCredentials", () => {
expect(extractGeminiCliCredentials()).toBeNull();
});
it("includes missing binary details when resolving OAuth client config", async () => {
process.env.PATH = "/nonexistent";
mockExistsSync.mockReturnValue(false);
clearCredentialsCache();
expect(() => resolveOAuthClientConfig()).toThrow(
/Details: Gemini CLI binary was not found in PATH/,
);
});
it("extracts credentials from oauth2.js in known path", () => {
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });
@@ -551,14 +517,6 @@ describe("extractGeminiCliCredentials", () => {
expect(extractGeminiCliCredentials()).toBeNull();
});
it("includes missing oauth2.js details when resolving OAuth client config", async () => {
installGeminiLayout({ oauth2Exists: false, readdir: [] });
clearCredentialsCache();
expect(() => resolveOAuthClientConfig()).toThrow(/Could not locate oauth2\.js/);
expect(() => resolveOAuthClientConfig()).toThrow(/recursiveSearchDepth=10/);
});
it("returns null when oauth2.js lacks credentials", () => {
installGeminiLayout({ oauth2Exists: true, oauth2Content: "// no credentials here" });
@@ -566,32 +524,6 @@ describe("extractGeminiCliCredentials", () => {
expect(extractGeminiCliCredentials()).toBeNull();
});
it("includes parse failure details when resolving OAuth client config", async () => {
installGeminiLayout({
oauth2Exists: true,
oauth2Content: "// no credentials here",
readdir: [],
});
clearCredentialsCache();
expect(() => resolveOAuthClientConfig()).toThrow(
/Candidate credential files did not contain a parseable OAuth client id\/secret/,
);
});
it("includes unexpected extraction exception details when resolving OAuth client config", async () => {
installGeminiLayout({ oauth2Exists: true, readdir: [] });
mockReadFileSync.mockImplementation(() => {
throw new Error("mock read failure");
});
clearCredentialsCache();
expect(() => resolveOAuthClientConfig()).toThrow(
/Unexpected errors occurred while reading candidate credential files\/directories/,
);
expect(() => resolveOAuthClientConfig()).toThrow(/mock read failure/);
});
it("caches credentials after first extraction", () => {
installGeminiLayout({ oauth2Exists: true, oauth2Content: FAKE_OAUTH2_CONTENT });

View File

@@ -17,8 +17,8 @@ const LITELLM_DEFAULT_MODEL = {
cacheRead: 0,
cacheWrite: 0,
},
contextWindow: 1_000_000,
maxTokens: 128_000,
contextWindow: 128_000,
maxTokens: 8_192,
};
function registerProvider() {

View File

@@ -8,8 +8,8 @@ import {
export const LITELLM_BASE_URL = "http://localhost:4000";
export const LITELLM_DEFAULT_MODEL_ID = "claude-opus-4-6";
export const LITELLM_DEFAULT_MODEL_REF = `litellm/${LITELLM_DEFAULT_MODEL_ID}`;
const LITELLM_DEFAULT_CONTEXT_WINDOW = 1_000_000;
const LITELLM_DEFAULT_MAX_TOKENS = 128_000;
const LITELLM_DEFAULT_CONTEXT_WINDOW = 128_000;
const LITELLM_DEFAULT_MAX_TOKENS = 8_192;
const LITELLM_DEFAULT_COST = {
input: 0,
output: 0,

View File

@@ -73,11 +73,6 @@ function expectLogExcludes(source: MockCallSource, text: string): void {
expect(logIncludes(source, text), `Expected log not to include ${text}`).toBe(false);
}
async function flushNarrativeSettleTimers<T>(operation: Promise<T>): Promise<T> {
await vi.runAllTimersAsync();
return operation;
}
async function expectPathMissing(targetPath: string): Promise<void> {
const accessResult = await fs
.access(targetPath)
@@ -688,88 +683,6 @@ describe("generateAndAppendDreamNarrative", () => {
expect(logger.info).toHaveBeenCalled();
});
it("waits for persisted assistant text before falling back", async () => {
vi.useFakeTimers();
try {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("");
subagent.getSessionMessages
.mockResolvedValueOnce({
messages: [{ role: "user", content: "prompt" }],
})
.mockResolvedValueOnce({
messages: [
{ role: "user", content: "prompt" },
{
role: "assistant",
content: [{ type: "text", text: "The delayed diary text finally settled." }],
},
],
});
const logger = createMockLogger();
const operation = generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: {
phase: "light",
snippets: ["The narrative assistant persisted after the run completed."],
},
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
logger,
});
await flushNarrativeSettleTimers(operation);
expect(subagent.getSessionMessages).toHaveBeenCalledTimes(2);
expect(subagent.getSessionMessages).toHaveBeenNthCalledWith(1, {
sessionKey: expect.stringContaining("dreaming-narrative-light-"),
limit: expect.any(Number),
});
expect(subagent.getSessionMessages).toHaveBeenNthCalledWith(2, {
sessionKey: expect.stringContaining("dreaming-narrative-light-"),
limit: expect.any(Number),
});
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain("The delayed diary text finally settled.");
expect(content).not.toContain("A memory trace surfaced");
expectLogExcludes(logger.warn, "produced no text");
} finally {
vi.useRealTimers();
}
});
it("falls back after settled assistant text never appears", async () => {
vi.useFakeTimers();
try {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("");
const logger = createMockLogger();
const operation = generateAndAppendDreamNarrative({
subagent,
workspaceDir,
data: {
phase: "light",
snippets: ["The narrative assistant never persisted text."],
},
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
logger,
});
await flushNarrativeSettleTimers(operation);
expect(subagent.getSessionMessages).toHaveBeenCalledTimes(5);
const content = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(content).toContain(
"A memory trace surfaced, but details were unavailable in this run.",
);
expectLogIncludes(logger.warn, "produced no text");
} finally {
vi.useRealTimers();
}
});
it("retries with the session default when the configured model cannot start", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const subagent = createMockSubagent("The default model carried the diary home.");
@@ -1107,21 +1020,20 @@ describe("generateAndAppendDreamNarrative", () => {
const storePath = path.join(sessionsDir, "sessions.json");
const orphanPath = path.join(sessionsDir, "orphan.jsonl");
const livePath = path.join(sessionsDir, "still-live.jsonl");
const updatedAt = Date.now();
await sessionStoreRuntimeModule.saveSessionStore(
storePath,
{
"agent:main:dreaming-narrative-light-1": {
sessionId: "missing",
updatedAt,
updatedAt: Date.now(),
},
"agent:main:kept-session": {
sessionId: "still-live",
updatedAt,
updatedAt: Date.now(),
},
"agent:main:telegram:group:dreaming-narrative-room": {
sessionId: "still-missing-non-dreaming",
updatedAt,
updatedAt: Date.now(),
},
},
{ skipMaintenance: true },
@@ -1178,21 +1090,20 @@ describe("generateAndAppendDreamNarrative", () => {
// A second dreaming row whose transcript is fresh (a live/just-started run)
// must be preserved.
const liveTranscript = path.join(sessionsDir, "live-dreaming.jsonl");
const updatedAt = Date.now();
await sessionStoreRuntimeModule.saveSessionStore(
storePath,
{
"agent:main:dreaming-narrative-deep-orphan": {
sessionId: "orphan-dreaming",
updatedAt,
updatedAt: Date.now(),
},
"agent:main:dreaming-narrative-deep-live": {
sessionId: "live-dreaming",
updatedAt,
updatedAt: Date.now(),
},
"agent:main:kept-session": {
sessionId: "still-live",
updatedAt,
updatedAt: Date.now(),
},
},
{ skipMaintenance: true },

View File

@@ -99,10 +99,6 @@ const NARRATIVE_SYSTEM_PROMPT = [
// worst case at one minute, well below the multi-minute stall the original
// comment warned against.
const NARRATIVE_TIMEOUT_MS = 60_000;
const NARRATIVE_MESSAGE_FETCH_LIMIT = 5;
// A completed run can reach the session reader before the final assistant text
// is visible, so retry briefly before falling back to synthetic diary text.
const NARRATIVE_MESSAGE_SETTLE_DELAYS_MS = [50, 150, 300, 750] as const;
const DREAMING_SESSION_KEY_PREFIX = "dreaming-narrative-";
const DREAMING_TRANSCRIPT_RUN_MARKER = '"runId":"dreaming-narrative-';
const DREAMING_ORPHAN_MIN_AGE_MS = 300_000;
@@ -156,7 +152,7 @@ function buildRequestScopedFallbackNarrative(_data: NarrativePhaseData): string
return "A memory trace surfaced, but details were unavailable in this run.";
}
export async function appendFallbackNarrativeEntry(params: {
async function appendFallbackNarrativeEntry(params: {
workspaceDir: string;
data: NarrativePhaseData;
nowMs: number;
@@ -346,42 +342,6 @@ export function extractNarrativeText(messages: unknown[]): string | null {
return null;
}
function waitForNarrativeMessagesToSettle(delayMs: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, delayMs);
});
}
async function readNarrativeText(params: {
subagent: SubagentSurface;
sessionKey: string;
}): Promise<string | null> {
const { messages } = await params.subagent.getSessionMessages({
sessionKey: params.sessionKey,
limit: NARRATIVE_MESSAGE_FETCH_LIMIT,
});
return extractNarrativeText(messages);
}
async function readSettledNarrativeText(params: {
subagent: SubagentSurface;
sessionKey: string;
}): Promise<string | null> {
const immediateNarrative = await readNarrativeText(params);
if (immediateNarrative) {
return immediateNarrative;
}
for (const delayMs of NARRATIVE_MESSAGE_SETTLE_DELAYS_MS) {
await waitForNarrativeMessagesToSettle(delayMs);
const narrative = await readNarrativeText(params);
if (narrative) {
return narrative;
}
}
return null;
}
// ── Date formatting ────────────────────────────────────────────────────
export function formatNarrativeDate(epochMs: number, timezone?: string): string {
@@ -1006,10 +966,12 @@ export async function generateAndAppendDreamNarrative(params: {
return;
}
const narrative = await readSettledNarrativeText({
subagent: params.subagent,
const { messages } = await params.subagent.getSessionMessages({
sessionKey: successfulSessionKey,
limit: 5,
});
const narrative = extractNarrativeText(messages);
if (!narrative) {
params.logger.warn(
`memory-core: narrative generation produced no text for ${params.data.phase} phase; writing fallback diary entry.`,

View File

@@ -6,7 +6,6 @@ import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
import { auditDreamingArtifacts, repairDreamingArtifacts } from "./dreaming-repair.js";
import {
configureMemoryCoreDreamingStateForTests,
DREAMING_DAILY_INGESTION_NAMESPACE,
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
readMemoryCoreWorkspaceEntries,
@@ -234,23 +233,4 @@ describe("dreaming artifact repair", () => {
expect(audit.sessionIngestionExists).toBe(true);
});
it("reports ingestion state present from SQLite daily namespace", async () => {
const workspaceDir = await createWorkspace();
// Only daily ingestion namespace has rows
await writeMemoryCoreWorkspaceEntries({
namespace: DREAMING_DAILY_INGESTION_NAMESPACE,
workspaceDir,
entries: [
{
key: "2026-06-10",
value: { ingestedAt: Date.now() },
},
],
});
const audit = await auditDreamingArtifacts({ workspaceDir });
expect(audit.sessionIngestionExists).toBe(true);
});
});

View File

@@ -5,7 +5,6 @@ import path from "node:path";
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
import {
clearMemoryCoreWorkspaceNamespace,
DREAMING_DAILY_INGESTION_NAMESPACE,
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
readMemoryCoreWorkspaceEntries,
@@ -216,7 +215,6 @@ export async function auditDreamingArtifacts(params: {
const ingestionNamespaces = [
DREAMING_SESSION_INGESTION_FILES_NAMESPACE,
DREAMING_SESSION_INGESTION_SEEN_NAMESPACE,
DREAMING_DAILY_INGESTION_NAMESPACE,
] as const;
for (const namespace of ingestionNamespaces) {
const entries = await readMemoryCoreWorkspaceEntries({

View File

@@ -62,7 +62,6 @@ describe("dreaming shadow trial runner", () => {
verdict: "neutral",
workspaceDir,
nowMs: Date.parse("2026-05-18T18:00:00.000Z"),
timezone: "UTC",
});
expect(report.recommendation).toBe("defer");

View File

@@ -2341,57 +2341,6 @@ describe("short-term dreaming trigger", () => {
expect(memoryText).toContain("Move backups to S3 Glacier.");
});
it("writes fallback dream diary prose when managed cron has no subagent runtime", async () => {
const logger = createLogger();
const workspaceDir = await createTempWorkspace("memory-dreaming-cron-no-subagent-");
await writeDailyMemoryNote(workspaceDir, "2026-04-02", ["Move backups to S3 Glacier."]);
await recordShortTermRecalls({
workspaceDir,
query: "backup policy",
results: [
{
path: "memory/2026-04-02.md",
startLine: 1,
endLine: 1,
score: 0.9,
snippet: "Move backups to S3 Glacier.",
source: "memory",
},
],
});
const result = await runShortTermDreamingPromotionIfTriggered({
cleanedBody: constants.DREAMING_SYSTEM_EVENT_TEXT,
trigger: "cron",
workspaceDir,
config: {
enabled: true,
cron: constants.DEFAULT_DREAMING_CRON_EXPR,
limit: 10,
minScore: 0,
minRecallCount: 0,
minUniqueQueries: 0,
recencyHalfLifeDays: constants.DEFAULT_DREAMING_RECENCY_HALF_LIFE_DAYS,
verboseLogging: false,
},
logger,
});
expect(result?.handled).toBe(true);
const memoryText = await fs.readFile(path.join(workspaceDir, "MEMORY.md"), "utf-8");
expect(memoryText).toContain("Move backups to S3 Glacier.");
const dreamsText = await fs.readFile(path.join(workspaceDir, "DREAMS.md"), "utf-8");
expect(dreamsText).toContain("<!-- openclaw:dreaming:diary:start -->");
expect(dreamsText).toContain(
"A memory trace surfaced, but details were unavailable in this run.",
);
expect(dreamsText).not.toContain("Move backups to S3 Glacier.");
expect(logger.info).toHaveBeenCalledWith(
"memory-core: narrative generation used fallback for deep phase because subagent runtime is unavailable.",
);
});
it("keeps one-off recalls out of long-term memory under default thresholds", async () => {
const logger = createLogger();
const workspaceDir = await createTempWorkspace("memory-dreaming-strict-");

View File

@@ -556,7 +556,7 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
const detachNarratives = params.trigger === "cron";
const [
{ writeDeepDreamingReport },
{ appendFallbackNarrativeEntry, generateAndAppendDreamNarrative, runDetachedDreamNarrative },
{ generateAndAppendDreamNarrative, runDetachedDreamNarrative },
{ runDreamingSweepPhases },
{
applyShortTermPromotions,
@@ -652,22 +652,13 @@ export async function runShortTermDreamingPromotionIfTriggered(params: {
storage: params.config.storage ?? { mode: "separate", separateReports: false },
});
// Generate dream diary narrative from promoted memories.
if (candidates.length > 0 || applied.applied > 0) {
if (params.subagent && (candidates.length > 0 || applied.applied > 0)) {
const data: NarrativePhaseData = {
phase: "deep",
snippets: candidates.map((c) => c.snippet).filter(Boolean),
promotions: applied.appliedCandidates.map((c) => c.snippet).filter(Boolean),
};
if (!params.subagent) {
await appendFallbackNarrativeEntry({
workspaceDir,
data,
nowMs: sweepNowMs,
timezone: params.config.timezone,
logger: params.logger,
reason: "subagent runtime is unavailable",
});
} else if (detachNarratives) {
if (detachNarratives) {
runDetachedDreamNarrative({
subagent: params.subagent,
workspaceDir,

View File

@@ -8,7 +8,6 @@ type SearchImpl = (opts?: {
sessionKey?: string;
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
signal?: AbortSignal;
}) => Promise<unknown[]>;
export type MemoryReadParams = { relPath: string; from?: number; lines?: number };
type MemoryReadResult = {

View File

@@ -51,14 +51,13 @@ async function renameWithRetry(
source: string,
target: string,
options: ResolvedMemoryIndexFileOptions,
optional = false,
): Promise<void> {
for (let attempt = 1; attempt <= options.maxRenameAttempts; attempt++) {
try {
await options.fileOps.rename(source, target);
return;
} catch (err) {
if (optional && (err as NodeJS.ErrnoException).code === "ENOENT") {
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return;
}
if (!isTransientFileError(err) || attempt === options.maxRenameAttempts) {
@@ -80,7 +79,7 @@ export async function moveMemoryIndexFiles(
for (const suffix of suffixes) {
const source = `${sourceBase}${suffix}`;
const target = `${targetBase}${suffix}`;
await renameWithRetry(source, target, resolvedOptions, suffix !== "");
await renameWithRetry(source, target, resolvedOptions);
}
}
@@ -113,91 +112,16 @@ export async function removeMemoryIndexFiles(
}
}
async function removeMemoryIndexSidecars(
basePath: string,
options: ResolvedMemoryIndexFileOptions,
): Promise<void> {
await rmWithRetry(`${basePath}-wal`, options);
await rmWithRetry(`${basePath}-shm`, options);
}
async function moveMemoryIndexSidecars(
sourceBase: string,
targetBase: string,
options: ResolvedMemoryIndexFileOptions,
): Promise<void> {
const suffixes = ["-wal", "-shm"];
for (const suffix of suffixes) {
await renameWithRetry(`${sourceBase}${suffix}`, `${targetBase}${suffix}`, options, true);
}
}
async function moveMemoryIndexSidecarsWithRollback(
sourceBase: string,
targetBase: string,
options: ResolvedMemoryIndexFileOptions,
): Promise<void> {
async function swapMemoryIndexFiles(targetPath: string, tempPath: string): Promise<void> {
const backupPath = `${targetPath}.backup-${randomUUID()}`;
await moveMemoryIndexFiles(targetPath, backupPath);
try {
await moveMemoryIndexSidecars(sourceBase, targetBase, options);
await moveMemoryIndexFiles(tempPath, targetPath);
} catch (err) {
try {
await moveMemoryIndexSidecars(targetBase, sourceBase, options);
} catch (rollbackErr) {
const aggregateErr = new AggregateError(
[err, rollbackErr],
"memory index sidecar backup failed and rollback failed",
{ cause: rollbackErr },
);
throw aggregateErr;
}
await moveMemoryIndexFiles(backupPath, targetPath);
throw err;
}
}
async function swapMemoryIndexFiles(
targetPath: string,
tempPath: string,
options: MemoryIndexFileOptions = {},
): Promise<void> {
// On POSIX (Linux/macOS), rename(2) atomically overwrites the target,
// so there is no absent-window between removing the old index and
// publishing the new one. On Windows, rename fails when the target
// exists, so the three-step backup protocol is retained.
const resolvedOptions = resolveMemoryIndexFileOptions(options);
const backupPath = `${targetPath}.backup-${randomUUID()}`;
// The old and temp DBs are checkpointed and closed before swap. Hide target
// sidecars before publishing the new main DB, but keep them rollbackable
// until the main-file publish succeeds.
await moveMemoryIndexSidecarsWithRollback(targetPath, backupPath, resolvedOptions);
try {
await renameWithRetry(tempPath, targetPath, resolvedOptions);
} catch (err) {
if (
(err as NodeJS.ErrnoException).code === "EPERM" ||
(err as NodeJS.ErrnoException).code === "EEXIST"
) {
// Windows: target exists, use three-step backup protocol with rollback.
try {
await renameWithRetry(targetPath, backupPath, resolvedOptions);
} catch (backupErr) {
await moveMemoryIndexSidecars(backupPath, targetPath, resolvedOptions);
throw backupErr;
}
try {
await renameWithRetry(tempPath, targetPath, resolvedOptions);
} catch (moveErr) {
await moveMemoryIndexFiles(backupPath, targetPath, options);
throw moveErr;
}
} else {
await moveMemoryIndexSidecars(backupPath, targetPath, resolvedOptions);
throw err;
}
}
await removeMemoryIndexFiles(backupPath, options);
// Closed temp databases should not need sidecars after checkpoint; remove
// leftovers at the temp path without touching the published target pair.
await removeMemoryIndexSidecars(tempPath, resolvedOptions);
await removeMemoryIndexFiles(backupPath);
}
export async function runMemoryAtomicReindex<T>(params: {
@@ -209,7 +133,7 @@ export async function runMemoryAtomicReindex<T>(params: {
}): Promise<T> {
try {
const result = await params.build();
await swapMemoryIndexFiles(params.targetPath, params.tempPath, params.fileOptions);
await swapMemoryIndexFiles(params.targetPath, params.tempPath);
return result;
} catch (err) {
try {

View File

@@ -1,65 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { openMemoryDatabaseAtPath } from "./manager-db.js";
describe("openMemoryDatabaseAtPath readOnly probe", () => {
let fixtureRoot = "";
let caseId = 0;
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-db-probe-"));
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("allows opening when the database file exists", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "index.sqlite");
const dir = path.dirname(dbPath);
await fs.mkdir(dir, { recursive: true });
const seed = new DatabaseSync(dbPath);
seed.exec("CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT)");
seed.close();
const db = openMemoryDatabaseAtPath(dbPath, false);
expect(db).toBeDefined();
db.close();
});
it("allows creating a new database when allowCreate is true", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "new-index.sqlite");
const db = openMemoryDatabaseAtPath(dbPath, false, true);
expect(db).toBeDefined();
db.close();
const stat = await fs.stat(dbPath);
expect(stat.size).toBeGreaterThan(0);
});
it("refuses to auto-create an empty database when allowCreate is false", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "absent-index.sqlite");
expect(() => openMemoryDatabaseAtPath(dbPath, false, false)).toThrow(
/Memory database not found.*refusing to auto-create/,
);
await expect(fs.access(dbPath)).rejects.toThrow("ENOENT");
});
it("allows open with allowCreate=true for temp database creation", async () => {
const dbPath = path.join(fixtureRoot, `case-${caseId++}`, "temp-index.sqlite");
const db = openMemoryDatabaseAtPath(dbPath, false, true);
db.exec("CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value TEXT)");
db.close();
const reopen = openMemoryDatabaseAtPath(dbPath, false, false);
expect(reopen).toBeDefined();
reopen.close();
});
});

View File

@@ -8,39 +8,15 @@ import {
requireNodeSqlite,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
export function openMemoryDatabaseAtPath(
dbPath: string,
allowExtension: boolean,
allowCreate = true,
): DatabaseSync {
export function openMemoryDatabaseAtPath(dbPath: string, allowExtension: boolean): DatabaseSync {
const dir = path.dirname(dbPath);
ensureDir(dir);
const { DatabaseSync } = requireNodeSqlite();
// When allowCreate is false, probe with readOnly first.
// DatabaseSync auto-creates the file in read-write mode, which
// produces an empty database with schema but no meta row when the
// file is momentarily absent during an index swap. readOnly: true
// throws SQLITE_CANTOPEN when the file does not exist, preventing
// the auto-create race.
if (!allowCreate) {
try {
const probe = new DatabaseSync(dbPath, { readOnly: true });
probe.close();
} catch (err) {
const msg = (err as Error).message ?? "";
if (
msg.includes("unable to open database file") ||
msg.includes("SQLITE_CANTOPEN")
) {
throw new Error(
`Memory database not found at ${dbPath}; refusing to auto-create an empty database during an index swap window.`,
{ cause: err },
);
}
}
}
const db = new DatabaseSync(dbPath, { allowExtension });
configureMemorySqliteWalMaintenance(db);
// busy_timeout is per-connection and resets to 0 on restart.
// Set it on every open so concurrent processes retry instead of
// failing immediately with SQLITE_BUSY.
db.exec("PRAGMA busy_timeout = 5000");
return db;
}

View File

@@ -163,16 +163,11 @@ export function resolveMemoryIndexConcurrency(params: {
export async function runEmbeddingOperationWithTimeout<T>(params: {
timeoutMs: number;
message: string;
/** Caller-owned cancellation, merged with the per-call watchdog abort. */
signal?: AbortSignal;
run: (signal: AbortSignal) => Promise<T>;
}): Promise<T> {
const controller = new AbortController();
const signal = params.signal
? AbortSignal.any([params.signal, controller.signal])
: controller.signal;
if (!Number.isFinite(params.timeoutMs) || params.timeoutMs <= 0) {
return await params.run(signal);
return await params.run(controller.signal);
}
const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 1);
let timer: NodeJS.Timeout | null = null;
@@ -184,7 +179,7 @@ export async function runEmbeddingOperationWithTimeout<T>(params: {
}, timeoutMs);
});
try {
const operation = params.run(signal);
const operation = params.run(controller.signal);
return (await Promise.race([operation, timeoutPromise])) as T;
} finally {
if (timer) {
@@ -498,7 +493,7 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
});
}
protected async embedQueryWithRetry(text: string, signal?: AbortSignal): Promise<number[]> {
protected async embedQueryWithRetry(text: string): Promise<number[]> {
const provider = this.provider;
if (!provider) {
throw new Error("Cannot embed query in FTS-only mode (no embedding provider)");
@@ -506,17 +501,14 @@ export abstract class MemoryManagerEmbeddingOps extends MemoryManagerSyncOps {
try {
return await runMemoryEmbeddingRetryLoop({
run: async () => {
signal?.throwIfAborted();
const timeoutMs = this.resolveEmbeddingTimeout("query");
log.debug("memory embeddings: query start", { provider: provider.id, timeoutMs });
return await runEmbeddingOperationWithTimeout({
timeoutMs,
message: `memory embeddings query timed out after ${Math.round(timeoutMs / 1000)}s`,
signal,
run: async (opSignal) => await provider.embedQuery(text, { signal: opSignal }),
run: async (signal) => await provider.embedQuery(text, { signal }),
});
},
signal,
isRetryable: isRetryableMemoryEmbeddingError,
waitForRetry: async (delayMs) => {
await this.waitForEmbeddingRetry(delayMs, "retrying query");

View File

@@ -75,30 +75,6 @@ describe("memory embedding policy", () => {
expect(waits).toEqual([500, 1000]);
});
it("stops retrying after the caller signal aborts, even for retryable-looking errors", async () => {
const controller = new AbortController();
const run = vi.fn(async () => {
controller.abort(new Error("memory_search timed out after 15s"));
// "timed out" matches the retryable transport pattern; abort must still win.
throw new Error("memory embeddings query timed out after 60s");
});
const waitForRetry = vi.fn(async () => {});
await expect(
runMemoryEmbeddingRetryLoop({
run,
isRetryable: isRetryableMemoryEmbeddingError,
waitForRetry,
maxAttempts: 3,
baseDelayMs: 500,
signal: controller.signal,
}),
).rejects.toThrow("memory embeddings query timed out after 60s");
expect(run).toHaveBeenCalledTimes(1);
expect(waitForRetry).not.toHaveBeenCalled();
});
it("retries transient socket/network embedding errors", () => {
const splittableMessages = [
"TypeError: fetch failed | other side closed",

View File

@@ -125,8 +125,6 @@ export async function runMemoryEmbeddingRetryLoop<T>(params: {
waitForRetry: (delayMs: number) => Promise<void>;
maxAttempts: number;
baseDelayMs: number;
/** Caller-owned cancellation; an aborted caller stops the retry loop. */
signal?: AbortSignal;
}): Promise<T> {
const attempts = Math.max(1, params.maxAttempts);
for (const attempt of Array.from({ length: attempts }, (_, index) => index + 1)) {
@@ -134,12 +132,6 @@ export async function runMemoryEmbeddingRetryLoop<T>(params: {
try {
return await params.run();
} catch (err) {
// Abort must win over retryable-looking failures: abort reasons often
// carry "timed out" messages that match the retryable transport
// patterns and would otherwise keep retrying for an absent caller.
if (params.signal?.aborted) {
throw err;
}
const message = formatErrorMessage(err);
if (!params.isRetryable(message) || attempt >= params.maxAttempts) {
throw err;

View File

@@ -135,31 +135,6 @@ describe("memory embedding timeout abort", () => {
expect(signalSeen?.aborted).toBe(true);
});
it("aborts the provider operation when the caller signal aborts before the watchdog", async () => {
const external = new AbortController();
let signalSeen: AbortSignal | undefined;
const resultPromise = runEmbeddingOperationWithTimeout({
timeoutMs: 60_000,
message: "memory embeddings query timed out after 60s",
signal: external.signal,
run: async (signal) => {
signalSeen = signal;
return await new Promise<number[]>((_resolve, reject) => {
signal.addEventListener(
"abort",
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
{ once: true },
);
});
},
});
external.abort(new Error("memory_search timed out after 15s"));
await expect(resultPromise).rejects.toThrow("memory_search timed out after 15s");
expect(signalSeen?.aborted).toBe(true);
});
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
vi.useFakeTimers();
const resultPromise = runEmbeddingOperationWithTimeout({

View File

@@ -398,13 +398,6 @@ export abstract class MemoryManagerSyncOps {
return row?.found === 1;
}
protected hasSemanticChunks(): boolean {
const row = this.db
.prepare(`SELECT 1 as found FROM chunks WHERE model != 'fts-only' LIMIT 1`)
.get() as { found?: number } | undefined;
return row?.found === 1;
}
protected resolveCurrentIndexIdentityState(params?: {
meta?: MemoryIndexMeta | null;
provider?: { id: string; model: string } | null;
@@ -2081,24 +2074,9 @@ export abstract class MemoryManagerSyncOps {
const hasIndexedChunks = this.hasIndexedChunks();
const needsInitialIndex = indexIdentity.status !== "valid" && !hasIndexedChunks;
// Missing metadata cannot prove whether existing chunks were semantic.
// Wait for the configured provider before replacing them with a rebuilt index,
// unless every existing chunk is FTS-only — in that case rebuilding as
// FTS-only is safe even without a provider because no semantic data is lost.
// Gate the chunk-model scan: only compute when identity is missing,
// chunks exist, and the provider is unavailable (no target session files
// is already checked by needsMissingIdentityReindex below).
const needsFtsOnlyClassification =
indexIdentity.status === "missing" &&
hasIndexedChunks &&
this.provider === null &&
this.settings.provider &&
this.settings.provider !== "none";
const hasOnlyFtsChunks = needsFtsOnlyClassification && !this.hasSemanticChunks();
// Wait for the configured provider before replacing them with a rebuilt index.
const canRebuildMissingIdentity =
this.provider !== null ||
!this.settings.provider ||
this.settings.provider === "none" ||
hasOnlyFtsChunks;
this.provider !== null || !this.settings.provider || this.settings.provider === "none";
const needsMissingIdentityReindex =
indexIdentity.status === "missing" && !hasTargetSessionFiles && canRebuildMissingIdentity;
const needsExplicitIdentityReindex =
@@ -2322,7 +2300,7 @@ export abstract class MemoryManagerSyncOps {
const restoreOriginalState = () => {
if (originalDbClosed) {
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled, false);
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled);
} else {
this.db = originalDb;
}
@@ -2434,7 +2412,7 @@ export abstract class MemoryManagerSyncOps {
},
});
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled, false);
this.db = openMemoryDatabaseAtPath(dbPath, this.settings.store.vector.enabled);
this.resetVectorState();
this.ensureSchema();
this.vector.dims = nextMeta?.vectorDims;

View File

@@ -24,15 +24,6 @@ async function expectRejectCode(promise: Promise<unknown>, code: string): Promis
throw new Error(`Expected rejection with code ${code}`);
}
function normalizeBackupName(filePath: string): string {
return path
.basename(filePath)
.replace(
/backup-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/,
"backup-<uuid>",
);
}
describe("memory manager atomic reindex", () => {
let fixtureRoot = "";
let caseId = 0;
@@ -141,25 +132,6 @@ describe("memory manager atomic reindex", () => {
expect(wait).not.toHaveBeenCalled();
});
it("requires the main sqlite file during index moves", async () => {
const rename = vi
.fn()
.mockRejectedValue(Object.assign(new Error("missing"), { code: "ENOENT" }));
const wait = vi.fn().mockResolvedValue(undefined);
await expectRejectCode(
moveMemoryIndexFiles("index.sqlite.tmp", "index.sqlite", {
fileOps: { rename, rm: fs.rm, wait },
maxRenameAttempts: 3,
renameRetryDelayMs: 10,
}),
"ENOENT",
);
expect(rename).toHaveBeenCalledTimes(1);
expect(wait).not.toHaveBeenCalled();
});
it("does not retry non-transient rename failures", async () => {
const rename = vi
.fn()
@@ -275,151 +247,6 @@ describe("memory manager atomic reindex", () => {
"rm:index.sqlite.tmp-shm:closed",
]);
});
it("atomic swap on POSIX: target file never goes absent during swap", async () => {
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
const existsChecks: boolean[] = [];
const realRename = fs.rename;
const rename: typeof fs.rename = vi.fn(async (source, target) => {
existsChecks.push(
await fs.access(indexPath).then(
() => true,
() => false,
),
);
return realRename(source, target);
});
await runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
fileOptions: {
fileOps: { rename, rm: fs.rm, wait: vi.fn().mockResolvedValue(undefined) },
},
build: async () => undefined,
});
expect(readChunkMarker(indexPath)).toBe("after");
expect(existsChecks.length).toBeGreaterThan(0);
for (const exists of existsChecks) {
expect(exists).toBe(true);
}
});
it("backs up stale target sidecars before replacing the main index", async () => {
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
await fs.writeFile(`${indexPath}-wal`, "stale wal");
await fs.writeFile(`${indexPath}-shm`, "stale shm");
await fs.writeFile(`${tempIndexPath}-wal`, "closed temp wal");
await fs.writeFile(`${tempIndexPath}-shm`, "closed temp shm");
const events: string[] = [];
const realRename = fs.rename;
const realRm = fs.rm;
const rename: typeof fs.rename = vi.fn(async (source, target) => {
events.push(
`rename:${normalizeBackupName(String(source))}->${normalizeBackupName(String(target))}`,
);
await realRename(source, target);
});
const rm: typeof fs.rm = vi.fn(async (filePath, options) => {
events.push(`rm:${normalizeBackupName(String(filePath))}:${readChunkMarker(indexPath)}`);
await realRm(filePath, options);
});
await runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
fileOptions: {
fileOps: { rename, rm, wait: vi.fn().mockResolvedValue(undefined) },
},
build: async () => undefined,
});
expect(readChunkMarker(indexPath)).toBe("after");
expect(rename).toHaveBeenCalledTimes(3);
expect(events).toEqual([
"rename:index.sqlite-wal->index.sqlite.backup-<uuid>-wal",
"rename:index.sqlite-shm->index.sqlite.backup-<uuid>-shm",
"rename:index.sqlite.tmp->index.sqlite",
"rm:index.sqlite.backup-<uuid>:after",
"rm:index.sqlite.backup-<uuid>-wal:after",
"rm:index.sqlite.backup-<uuid>-shm:after",
"rm:index.sqlite.tmp-wal:after",
"rm:index.sqlite.tmp-shm:after",
]);
await expectPathMissing(`${indexPath}-wal`);
await expectPathMissing(`${indexPath}-shm`);
await expectPathMissing(`${tempIndexPath}-wal`);
await expectPathMissing(`${tempIndexPath}-shm`);
});
it("restores backed-up target sidecars when publishing the main index fails", async () => {
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
await fs.writeFile(`${indexPath}-wal`, "stale wal");
await fs.writeFile(`${indexPath}-shm`, "stale shm");
const realRename = fs.rename;
const rename: typeof fs.rename = vi.fn(async (source, target) => {
if (String(source) === tempIndexPath && String(target) === indexPath) {
throw Object.assign(new Error("locked target"), { code: "EACCES" });
}
await realRename(source, target);
});
await expectRejectCode(
runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
fileOptions: {
fileOps: { rename, rm: fs.rm, wait: vi.fn().mockResolvedValue(undefined) },
},
build: async () => undefined,
}),
"EACCES",
);
await expect(fs.readFile(`${indexPath}-wal`, "utf8")).resolves.toBe("stale wal");
await expect(fs.readFile(`${indexPath}-shm`, "utf8")).resolves.toBe("stale shm");
expect(readChunkMarker(indexPath)).toBe("before");
await expectPathMissing(tempIndexPath);
});
it("restores target sidecars when sidecar backup partially fails", async () => {
writeChunkMarker(indexPath, "before");
writeChunkMarker(tempIndexPath, "after");
await fs.writeFile(`${indexPath}-wal`, "stale wal");
await fs.writeFile(`${indexPath}-shm`, "stale shm");
const realRename = fs.rename;
const rename: typeof fs.rename = vi.fn(async (source, target) => {
if (String(source) === `${indexPath}-shm` && String(target).includes(".backup-")) {
throw Object.assign(new Error("locked shm"), { code: "EACCES" });
}
await realRename(source, target);
});
await expectRejectCode(
runMemoryAtomicReindex({
targetPath: indexPath,
tempPath: tempIndexPath,
fileOptions: {
fileOps: { rename, rm: fs.rm, wait: vi.fn().mockResolvedValue(undefined) },
},
build: async () => undefined,
}),
"EACCES",
);
await expect(fs.readFile(`${indexPath}-wal`, "utf8")).resolves.toBe("stale wal");
await expect(fs.readFile(`${indexPath}-shm`, "utf8")).resolves.toBe("stale shm");
expect(readChunkMarker(indexPath)).toBe("before");
await expectPathMissing(tempIndexPath);
});
});
function writeChunkMarker(dbPath: string, marker: string): void {

View File

@@ -1,156 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { OpenClawConfig } from "openclaw/plugin-sdk/memory-core-host-engine-foundation";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { closeAllMemorySearchManagers, getMemorySearchManager } from "./index.js";
import type { MemoryIndexManager } from "./manager.js";
import "./test-runtime-mocks.js";
const createEmbeddingProviderMock = vi.hoisted(() =>
vi.fn(async () => ({
requestedProvider: "auto",
provider: null,
providerUnavailableReason: "No embeddings provider available.",
})),
);
vi.mock("./embeddings.js", () => ({
createEmbeddingProvider: createEmbeddingProviderMock,
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderFallbackModel: () => "fts-only",
}));
describe("memory manager self-heal missing identity with FTS-only chunks", () => {
let fixtureRoot = "";
let caseId = 0;
let workspaceDir = "";
let indexPath = "";
let manager: MemoryIndexManager | null = null;
function indexIdentityStatus(memoryManager: MemoryIndexManager): string | undefined {
const identity = memoryManager.status().custom?.indexIdentity as
| { status?: string }
| undefined;
return identity?.status;
}
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mem-self-heal-91167-"));
});
beforeEach(async () => {
createEmbeddingProviderMock.mockClear();
workspaceDir = path.join(fixtureRoot, `case-${caseId++}`);
await fs.mkdir(path.join(workspaceDir, "memory"), { recursive: true });
await fs.writeFile(path.join(workspaceDir, "MEMORY.md"), "Alpha topic\n\nKeep this note.");
indexPath = path.join(workspaceDir, "index.sqlite");
});
afterEach(async () => {
if (manager) {
await manager.close();
manager = null;
}
await closeAllMemorySearchManagers();
});
afterAll(async () => {
await closeAllMemorySearchManagers();
if (fixtureRoot) {
await fs.rm(fixtureRoot, { recursive: true, force: true });
}
});
async function createManager(
params: { provider?: string; vectorEnabled?: boolean } = {},
): Promise<MemoryIndexManager> {
const store =
params.vectorEnabled === undefined
? { path: indexPath }
: { path: indexPath, vector: { enabled: params.vectorEnabled } };
const cfg = {
memory: { backend: "builtin" },
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: params.provider ?? "auto",
model: "",
store,
cache: { enabled: false },
sync: { watch: false, onSessionStart: false, onSearch: false },
},
},
list: [{ id: "main", default: true }],
},
} as OpenClawConfig;
const result = await getMemorySearchManager({ cfg, agentId: "main" });
if (!result.manager) {
throw new Error(result.error ?? "manager missing");
}
manager = result.manager as unknown as MemoryIndexManager;
return manager;
}
async function seedChunksWithNoMeta(model = "fts-only"): Promise<void> {
const db = new DatabaseSync(indexPath);
db.exec(`
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS chunks (
id TEXT PRIMARY KEY,
path TEXT NOT NULL,
source TEXT NOT NULL DEFAULT 'memory',
start_line INTEGER NOT NULL,
end_line INTEGER NOT NULL,
hash TEXT NOT NULL,
model TEXT NOT NULL,
text TEXT NOT NULL,
embedding TEXT NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS files (
path TEXT PRIMARY KEY,
source TEXT NOT NULL DEFAULT 'memory',
hash TEXT NOT NULL,
mtime INTEGER NOT NULL,
size INTEGER NOT NULL
);
INSERT INTO chunks (id, path, source, start_line, end_line, hash, model, text, embedding, updated_at)
VALUES ('chunk-1', 'MEMORY.md', 'memory', 1, 3, 'hash-1', '${model}', 'Alpha topic keep note', '[]', ${Date.now()});
INSERT INTO files (path, source, hash, mtime, size)
VALUES ('MEMORY.md', 'memory', 'hash-1', ${Date.now()}, 100);
`);
db.close();
}
it("self-heals missing identity on non-forced gateway sync when all chunks are FTS-only and provider is unavailable", async () => {
await seedChunksWithNoMeta();
const memoryManager = await createManager({ vectorEnabled: false });
expect(indexIdentityStatus(memoryManager)).toBe("missing");
// Non-forced sync simulates the gateway's periodic sync loop
await memoryManager.sync();
const statusAfter = memoryManager.status();
expect(indexIdentityStatus(memoryManager)).toBe("valid");
expect(statusAfter.chunks).toBeGreaterThan(0);
expect(statusAfter.dirty).toBe(false);
});
it("does not rebuild missing-identity semantic chunks when the provider is unavailable", async () => {
await seedChunksWithNoMeta("text-embedding-3-small");
const memoryManager = await createManager({ vectorEnabled: false });
await memoryManager.sync();
const statusAfter = memoryManager.status();
expect(indexIdentityStatus(memoryManager)).toBe("missing");
expect(statusAfter.chunks).toBe(1);
expect(statusAfter.dirty).toBe(true);
});
});

View File

@@ -592,8 +592,6 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
/** When set, only these chunk sources are considered (must be enabled for this manager). */
sources?: MemorySource[];
/** Caller-owned cancellation; aborts in-flight embedding work when the caller stops waiting. */
signal?: AbortSignal;
},
): Promise<MemorySearchResult[]> {
opts?.onDebug?.({ backend: "builtin" });
@@ -760,13 +758,8 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
let queryVec: number[];
try {
queryVec = await this.embedQueryWithRetry(cleaned, opts?.signal);
queryVec = await this.embedQueryWithRetry(cleaned);
} catch (err) {
// An aborted caller already stopped waiting; skip fallback-provider
// activation so the abandoned search stops instead of re-embedding.
if (opts?.signal?.aborted) {
throw err;
}
const message = formatErrorMessage(err);
const activatedFallback = this.shouldFallbackOnError(err)
? await this.activateFallbackProvider(message).catch((fallbackErr: unknown) => {
@@ -785,7 +778,7 @@ export class MemoryIndexManager extends MemoryManagerEmbeddingOps implements Mem
return [];
}
keywordResults = await loadKeywordResults();
queryVec = await this.embedQueryWithRetry(cleaned, opts?.signal);
queryVec = await this.embedQueryWithRetry(cleaned);
} else if (!this.provider && this.fts.enabled && this.fts.available) {
log.warn(`memory search: embeddings unavailable; using keyword-only results: ${message}`);
return this.selectScoredResults(keywordResults, maxResults, minScore, 0);

View File

@@ -339,11 +339,9 @@ describe("getMemorySearchManager caching", () => {
errorMessage: "qmd query failed",
});
const controller = new AbortController();
const fallbackResults = await firstManager.search("hello", { signal: controller.signal });
const fallbackResults = await firstManager.search("hello");
expect(fallbackResults).toHaveLength(1);
expect(fallbackResults[0]?.path).toBe("MEMORY.md");
expect(fallbackSearch).toHaveBeenCalledWith("hello", { signal: controller.signal });
const second = await getMemorySearchManager({ cfg, agentId: retryAgentId });
requireManager(second);

View File

@@ -348,7 +348,6 @@ class BorrowedMemoryManager implements MemorySearchManager {
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
signal?: AbortSignal;
},
) {
return await this.inner.search(query, opts);

View File

@@ -157,10 +157,8 @@ describe("memory_search unavailable payloads", () => {
vi.useFakeTimers();
try {
let searchCalls = 0;
let searchSignal: AbortSignal | undefined;
setMemorySearchImpl(async (opts) => {
setMemorySearchImpl(async () => {
searchCalls += 1;
searchSignal = opts?.signal;
return await new Promise(() => {});
});
const tool = createMemorySearchToolOrThrow();
@@ -174,8 +172,6 @@ describe("memory_search unavailable payloads", () => {
warning: "Memory search is unavailable due to an embedding/provider error.",
action: "Check embedding provider configuration and retry memory_search.",
});
// The deadline must abort the orphaned search, not just race past it.
expect(searchSignal?.aborted).toBe(true);
const cooldownResult = await tool.execute("search-cooldown", { query: "hello again" });
expectUnavailableMemorySearchDetails(cooldownResult.details, {
error: "memory_search timed out after 15s",
@@ -188,35 +184,6 @@ describe("memory_search unavailable payloads", () => {
}
});
it("keeps the timeout result when an abort-aware search rejects on abort", async () => {
vi.useFakeTimers();
try {
setMemorySearchImpl(
async (opts) =>
await new Promise((_resolve, reject) => {
opts?.signal?.addEventListener(
"abort",
() => reject(new Error("openai-compatible embeddings query failed: aborted")),
{ once: true },
);
}),
);
const tool = createMemorySearchToolOrThrow();
const resultPromise = tool.execute("abort-aware-timeout", { query: "hello" });
await vi.advanceTimersByTimeAsync(15_000);
const result = await resultPromise;
expectUnavailableMemorySearchDetails(result.details, {
error: "memory_search timed out after 15s",
warning: "Memory search is unavailable due to an embedding/provider error.",
action: "Check embedding provider configuration and retry memory_search.",
});
} finally {
vi.useRealTimers();
}
});
it("re-resolves the manager once when a cached sqlite handle was closed", async () => {
let searchCalls = 0;
setMemorySearchImpl(async () => {

View File

@@ -83,26 +83,14 @@ export const testing = {
async function runMemorySearchToolWithDeadline<T>(params: {
timeoutMs: number;
run: (signal: AbortSignal) => Promise<T>;
run: () => Promise<T>;
}): Promise<{ status: "ok"; value: T } | { status: "unavailable"; error: string }> {
const timeoutError = () =>
new Error(`memory_search timed out after ${Math.round(params.timeoutMs / 1000)}s`);
// Abort the losing task when the deadline fires so in-flight embedding work
// is cancelled instead of retrying orphaned for minutes after the tool
// already returned "timed out" to the agent.
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
const timeoutPromise = new Promise<"timeout">((resolve) => {
timer = setTimeout(() => {
// Resolve before aborting: abort listeners run synchronously and an
// abort-aware search could reject the task first, replacing the stable
// timeout result with a provider-wrapped abort error.
resolve("timeout");
controller.abort(timeoutError());
}, params.timeoutMs);
timer = setTimeout(() => resolve("timeout"), params.timeoutMs);
timer.unref?.();
});
const task = params.run(controller.signal);
const task = params.run();
task.catch(() => undefined);
try {
@@ -110,7 +98,7 @@ async function runMemorySearchToolWithDeadline<T>(params: {
if (result === "timeout") {
return {
status: "unavailable",
error: timeoutError().message,
error: `memory_search timed out after ${Math.round(params.timeoutMs / 1000)}s`,
};
}
return { status: "ok", value: result as T };
@@ -394,7 +382,7 @@ export function createMemorySearchTool(options: {
const outcome = await runMemorySearchToolWithDeadline({
timeoutMs: MEMORY_SEARCH_TOOL_TIMEOUT_MS,
run: async (deadlineSignal) => {
run: async () => {
const { resolveMemoryBackendConfig } = await loadMemoryToolRuntime();
const shouldQuerySupplements = requestedCorpus === "wiki" || requestedCorpus === "all";
const shouldQueryMemory = requestedCorpus !== "wiki" && !cooldown;
@@ -466,7 +454,6 @@ export function createMemorySearchTool(options: {
minScore,
sessionKey: options.agentSessionKey,
qmdSearchModeOverride,
signal: deadlineSignal,
onDebug: (debug: MemorySearchRuntimeDebug) => {
runtimeDebug.push(debug);
},

View File

@@ -26,7 +26,6 @@ import {
} from "./onboard.js";
import {
buildFoundryAuthResult,
formatFoundryApiLabel,
type FoundryProviderApi,
isFoundryMaiImageModel,
listConfiguredFoundryProfileIds,
@@ -125,7 +124,7 @@ export const entraIdAuthMethod: ProviderAuthMethod = {
| Array<{
name: string;
modelName?: string;
api?: FoundryProviderApi;
api?: "openai-completions" | "openai-responses";
}>
| undefined;
if (selectedSub) {
@@ -155,7 +154,7 @@ export const entraIdAuthMethod: ProviderAuthMethod = {
`Endpoint: ${endpoint}`,
`Deployment: ${modelId}`,
selectedDeployment.modelName ? `Model: ${selectedDeployment.modelName}` : undefined,
`API: ${formatFoundryApiLabel(api)}`,
`API: ${api === "openai-responses" ? "Responses" : "Chat Completions"}`,
]
.filter(Boolean)
.join("\n"),
@@ -252,17 +251,14 @@ export const apiKeyAuthMethod: ProviderAuthMethod = {
throw new Error("Missing Azure OpenAI API key.");
}
const selection = await promptApiKeyEndpointAndModel(ctx);
const existingModelNameHint =
existingMetadata?.modelId === selection.modelId
? (existingMetadata.modelName ?? existingMetadata.modelId)
: undefined;
return buildFoundryAuthResult({
profileId: `${PROVIDER_ID}:default`,
apiKey: capturedSecretInput ?? "",
...(capturedMode ? { secretInputMode: capturedMode } : {}),
endpoint: selection.endpoint,
modelId: selection.modelId,
modelNameHint: selection.modelNameHint ?? existingModelNameHint,
modelNameHint:
selection.modelNameHint ?? existingMetadata?.modelName ?? existingMetadata?.modelId,
api: selection.api,
authMethod: "api-key",
currentProviderProfileIds: listConfiguredFoundryProfileIds(ctx.config),

View File

@@ -113,19 +113,19 @@ function parseAzJson(raw: string, label: string): unknown {
}
type AccessTokenParams = {
scope?: string;
subscriptionId?: string;
tenantId?: string;
};
function buildAccessTokenArgs(params?: AccessTokenParams): string[] {
const args = ["account", "get-access-token"];
if (params?.scope) {
args.push("--scope", params.scope);
} else {
args.push("--resource", COGNITIVE_SERVICES_RESOURCE);
}
args.push("--output", "json");
const args = [
"account",
"get-access-token",
"--resource",
COGNITIVE_SERVICES_RESOURCE,
"--output",
"json",
];
if (params?.subscriptionId) {
args.push("--subscription", params.subscriptionId);
} else if (params?.tenantId) {

View File

@@ -10,20 +10,16 @@ import {
buildFoundryConnectionTest,
isValidTenantIdentifier,
promptApiKeyEndpointAndModel,
promptEndpointAndModelManually,
selectFoundryDeployment,
} from "./onboard.js";
import { resetFoundryRuntimeAuthCaches } from "./runtime.js";
import {
COGNITIVE_SERVICES_RESOURCE,
FOUNDRY_ANTHROPIC_SCOPE,
buildFoundryAuthResult,
formatFoundryApiLabel,
isAnthropicFoundryDeployment,
isFoundryMaiImageModel,
normalizeFoundryEndpoint,
partitionFoundryDeployments,
requiresFoundryMaxCompletionTokens,
requiresFoundryEntraIdClaudeAuth,
supportsFoundryReasoningContent,
supportsFoundryReasoningEffort,
supportsFoundryImageInput,
@@ -91,18 +87,7 @@ function requirePrepareRuntimeAuth(
}
function requireRuntimeAuthResult(
result:
| {
apiKey?: string;
baseUrl?: string;
expiresAt?: number;
request?: {
auth?:
| { mode: "authorization-bearer"; token: string }
| { mode: "header"; headerName: string; value: string };
};
}
| undefined,
result: { apiKey?: string; baseUrl?: string; expiresAt?: number } | undefined,
) {
if (!result) {
throw new Error("expected Microsoft Foundry runtime auth result");
@@ -130,7 +115,7 @@ function buildFoundryModel(
provider: string;
id: string;
name: string;
api: "openai-responses" | "openai-completions" | "anthropic-messages";
api: "openai-responses" | "openai-completions";
baseUrl: string;
reasoning: boolean;
input: Array<"text" | "image">;
@@ -192,7 +177,6 @@ function buildFoundryConfig(params?: {
function buildEntraProfileStore(
overrides: Partial<{
api: "openai-responses" | "openai-completions" | "anthropic-messages";
endpoint: string;
modelId: string;
modelName: string;
@@ -209,7 +193,6 @@ function buildEntraProfileStore(
endpoint: "https://example.services.ai.azure.com",
modelId: "custom-deployment",
modelName: defaultFoundryModelId,
api: "openai-responses",
tenantId: "tenant-id",
...overrides,
},
@@ -390,16 +373,6 @@ describe("microsoft-foundry plugin", () => {
);
});
it("requests scoped Azure CLI tokens for Foundry Anthropic probes", async () => {
mockAzureCliTokenRaw(JSON.stringify({ accessToken: "scoped-token" }));
await getAccessTokenResultAsync({ scope: FOUNDRY_ANTHROPIC_SCOPE });
expect(execFileMock.mock.calls[0]?.[1]).toEqual(
expect.arrayContaining(["--scope", FOUNDRY_ANTHROPIC_SCOPE]),
);
});
it("fails clearly when the selected Azure subscription is not in the enabled list", async () => {
const provider = registerProvider();
execFileSyncMock.mockImplementation((_file: string, args: string[]) => {
@@ -461,124 +434,6 @@ describe("microsoft-foundry plugin", () => {
);
expect(prepared.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1");
expect(prepared.request?.auth).toEqual({
mode: "authorization-bearer",
token: "test-token",
});
expect(execFileMock.mock.calls[0]?.[1]).toEqual(
expect.arrayContaining(["--resource", COGNITIVE_SERVICES_RESOURCE]),
);
});
it.each([
["openai-responses", "api-key"],
["anthropic-messages", "x-api-key"],
] as const)("binds %s API-key auth to the active profile", async (api, headerName) => {
const provider = registerProvider();
const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider);
const prepared = requireRuntimeAuthResult(
await prepareRuntimeAuth(
buildFoundryRuntimeAuthContext({
apiKey: "profile-api-key",
profileId: "microsoft-foundry:default",
model: buildFoundryModel({ api }),
}),
),
);
expect(prepared).toEqual({
apiKey: "profile-api-key",
request: {
auth: { mode: "header", headerName, value: "profile-api-key" },
},
});
expect(execFileMock).not.toHaveBeenCalled();
});
it("uses active model routing when Entra metadata points at another deployment", async () => {
const provider = registerProvider();
const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider);
mockAzureCliToken({ accessToken: "test-token", expiresInMs: 60_000 });
ensureAuthProfileStoreMock.mockReturnValueOnce(
buildEntraProfileStore({
endpoint: "https://example.services.ai.azure.com",
modelId: "deployment-gpt5",
modelName: "gpt-5.4",
api: "openai-responses",
}),
);
const prepared = requireRuntimeAuthResult(
await prepareRuntimeAuth(
buildFoundryRuntimeAuthContext({
modelId: "deployment-fable",
model: buildFoundryModel({
id: "deployment-fable",
name: "claude-fable-5",
api: "anthropic-messages",
baseUrl: "https://example.services.ai.azure.com/anthropic",
}),
}),
),
);
expect(prepared.baseUrl).toBe("https://example.services.ai.azure.com/anthropic");
expect(execFileMock.mock.calls[0]?.[1]).toEqual(
expect.arrayContaining(["--scope", FOUNDRY_ANTHROPIC_SCOPE]),
);
});
it("does not reuse OpenAI Entra tokens for Anthropic Foundry deployments", async () => {
const provider = registerProvider();
const prepareRuntimeAuth = requirePrepareRuntimeAuth(provider);
mockAzureCliToken({ accessToken: "gpt-token", expiresInMs: 60_000 });
mockAzureCliToken({ accessToken: "claude-token", expiresInMs: 60_000 });
ensureAuthProfileStoreMock.mockReturnValue(
buildEntraProfileStore({
endpoint: "https://example.services.ai.azure.com",
modelId: "deployment-gpt5",
modelName: "gpt-5.4",
api: "openai-responses",
}),
);
const gptPrepared = requireRuntimeAuthResult(
await prepareRuntimeAuth(
buildFoundryRuntimeAuthContext({
modelId: "deployment-gpt5",
model: buildFoundryModel({
id: "deployment-gpt5",
name: "gpt-5.4",
api: "openai-responses",
baseUrl: "https://example.services.ai.azure.com/openai/v1",
}),
}),
),
);
const claudePrepared = requireRuntimeAuthResult(
await prepareRuntimeAuth(
buildFoundryRuntimeAuthContext({
modelId: "deployment-fable",
model: buildFoundryModel({
id: "deployment-fable",
name: "claude-fable-5",
api: "anthropic-messages",
baseUrl: "https://example.services.ai.azure.com/anthropic",
}),
}),
),
);
expect(gptPrepared.apiKey).toBe("gpt-token");
expect(claudePrepared.apiKey).toBe("claude-token");
expect(execFileMock).toHaveBeenCalledTimes(2);
expect(execFileMock.mock.calls[0]?.[1]).toEqual(
expect.arrayContaining(["--resource", COGNITIVE_SERVICES_RESOURCE]),
);
expect(execFileMock.mock.calls[1]?.[1]).toEqual(
expect.arrayContaining(["--scope", FOUNDRY_ANTHROPIC_SCOPE]),
);
});
it("retries Entra token refresh after a failed attempt", async () => {
@@ -751,35 +606,6 @@ describe("microsoft-foundry plugin", () => {
]);
});
it("preserves an explicit per-model Foundry endpoint when switching models", async () => {
const provider = registerProvider();
const config = buildFoundryConfig({
models: [
buildFoundryModel({
id: "prod-fable",
name: "claude-fable-5",
api: "anthropic-messages",
baseUrl: "https://claude-resource.services.ai.azure.com/anthropic",
reasoning: true,
input: ["text", "image"],
}),
],
});
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/prod-fable",
prompter: {} as never,
agentDir: "/tmp/test-agent",
});
const providerConfig = config.models?.providers?.["microsoft-foundry"];
expect(providerConfig?.baseUrl).toBe("https://claude-resource.services.ai.azure.com/anthropic");
expect(providerConfig?.models[0]?.baseUrl).toBe(
"https://claude-resource.services.ai.azure.com/anthropic",
);
});
it("marks newly selected Foundry reasoning deployments as reasoning-capable", async () => {
const provider = registerProvider();
const config = buildFoundryConfig({ models: [] });
@@ -797,60 +623,6 @@ describe("microsoft-foundry plugin", () => {
expect(model?.compat?.supportsReasoningEffort).toBe(true);
});
it("preserves Fable limits when adding a newly selected Foundry deployment", async () => {
const provider = registerProvider();
const config = buildFoundryConfig({ models: [] });
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/claude-fable-5",
prompter: {} as never,
agentDir: "/tmp/test-agent",
});
const model = config.models?.providers?.["microsoft-foundry"]?.models[0];
expect(model?.id).toBe("claude-fable-5");
expect(model?.api).toBe("anthropic-messages");
expect(model?.baseUrl).toBe("https://example.services.ai.azure.com/anthropic");
expect(model?.contextWindow).toBe(1_000_000);
expect(model?.maxTokens).toBe(128_000);
expect(config.models?.providers?.["microsoft-foundry"]?.api).toBe("anthropic-messages");
expect(config.models?.providers?.["microsoft-foundry"]?.baseUrl).toBe(
"https://example.services.ai.azure.com/anthropic",
);
});
it("infers OpenAI routing when adding a GPT deployment from a Claude-configured provider", async () => {
const provider = registerProvider();
const config: OpenClawConfig = {
models: {
providers: {
"microsoft-foundry": {
baseUrl: "https://example.services.ai.azure.com/anthropic",
api: "anthropic-messages",
models: [],
},
},
},
};
await provider.onModelSelected?.({
config,
model: "microsoft-foundry/gpt-5.4",
prompter: {} as never,
agentDir: "/tmp/test-agent",
});
const model = config.models?.providers?.["microsoft-foundry"]?.models[0];
expect(model?.id).toBe("gpt-5.4");
expect(model?.api).toBe("openai-responses");
expect(model?.baseUrl).toBe("https://example.services.ai.azure.com/openai/v1");
expect(config.models?.providers?.["microsoft-foundry"]?.api).toBe("openai-responses");
expect(config.models?.providers?.["microsoft-foundry"]?.baseUrl).toBe(
"https://example.services.ai.azure.com/openai/v1",
);
});
it("accepts tenant domains as valid tenant identifiers", () => {
expect(isValidTenantIdentifier("contoso.onmicrosoft.com")).toBe(true);
expect(isValidTenantIdentifier("00000000-0000-0000-0000-000000000000")).toBe(true);
@@ -883,12 +655,6 @@ describe("microsoft-foundry plugin", () => {
expect(isFoundryMaiImageModel("MAI-DS-R1")).toBe(false);
});
it("labels all Foundry API surfaces for onboarding summaries", () => {
expect(formatFoundryApiLabel("openai-completions")).toBe("Chat Completions");
expect(formatFoundryApiLabel("openai-responses")).toBe("Responses");
expect(formatFoundryApiLabel("anthropic-messages")).toBe("Anthropic Messages");
});
it("records MAI chat deployments with reasoning-content token limits", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
@@ -981,150 +747,6 @@ describe("microsoft-foundry plugin", () => {
expect(result.defaultModel).toBeUndefined();
});
it("keeps API-key manual setup defaulted to chat completions for GPT deployments", async () => {
const text = vi
.fn()
.mockResolvedValueOnce("https://example.services.ai.azure.com")
.mockResolvedValueOnce("gpt-4o");
const select = vi
.fn()
.mockImplementationOnce(async (params: { initialValue?: string }) => {
expect(params.initialValue).toBe("other-chat");
return "other-chat";
})
.mockImplementationOnce(async (params: { initialValue?: string }) => {
expect(params.initialValue).toBe("openai-completions");
return "openai-completions";
});
const selection = await promptApiKeyEndpointAndModel({
prompter: {
text,
select,
},
} as never);
expect(selection).toEqual({
endpoint: "https://example.services.ai.azure.com",
modelId: "gpt-4o",
api: "openai-completions",
});
});
it("does not reuse stale API-key model metadata when selecting a different deployment", async () => {
const provider = registerProvider();
ensureAuthProfileStoreMock.mockReturnValueOnce({
profiles: {
"microsoft-foundry:default": {
type: "api_key",
provider: "microsoft-foundry",
metadata: {
authMethod: "api-key",
endpoint: "https://example.services.ai.azure.com",
modelId: "prod-fable",
modelName: "claude-fable-5",
api: "anthropic-messages",
},
},
},
});
const text = vi
.fn()
.mockResolvedValueOnce("https://example.services.ai.azure.com")
.mockResolvedValueOnce("prod-gpt");
const select = vi
.fn()
.mockResolvedValueOnce("other-chat")
.mockResolvedValueOnce("openai-completions");
const apiKeyAuth = provider.auth.find((method: { id: string }) => method.id === "api-key");
const result = await apiKeyAuth?.run({
config: {},
opts: { azureOpenaiApiKey: "test-api-key" },
prompter: { text, select },
agentDir: defaultFoundryAgentDir,
secretInputMode: "plaintext",
} as never);
const model = result?.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0];
expect(model).toMatchObject({
id: "prod-gpt",
name: "prod-gpt",
api: "openai-completions",
reasoning: false,
});
expect(model?.thinkingLevelMap).toBeUndefined();
});
it("rejects Entra-only Claude Mythos deployments during API-key manual setup", async () => {
const text = vi.fn(
async (params: { message: string; validate?: (value: string) => string | undefined }) => {
if (params.message === "Microsoft Foundry endpoint URL") {
return "https://example.services.ai.azure.com";
}
if (params.message === "Default model/deployment name") {
return "prod-mythos";
}
if (params.message === "Claude base model") {
expect(params.validate?.("claude-fable-5")).toBeUndefined();
expect(params.validate?.("claude-mythos-preview")).toContain("Entra ID auth");
return "claude-fable-5";
}
throw new Error(`unexpected prompt: ${params.message}`);
},
);
const select = vi.fn().mockResolvedValueOnce("claude");
const selection = await promptApiKeyEndpointAndModel({
prompter: {
text,
select,
},
} as never);
expect(selection).toEqual({
endpoint: "https://example.services.ai.azure.com",
modelId: "prod-mythos",
modelNameHint: "claude-fable-5",
api: "anthropic-messages",
});
expect(requiresFoundryEntraIdClaudeAuth("claude-mythos-preview")).toBe(true);
expect(requiresFoundryEntraIdClaudeAuth("claude-fable-5")).toBe(false);
});
it("allows Entra-only Claude Mythos deployments during Entra manual setup", async () => {
const text = vi.fn(
async (params: { message: string; validate?: (value: string) => string | undefined }) => {
if (params.message === "Microsoft Foundry endpoint URL") {
return "https://example.services.ai.azure.com";
}
if (params.message === "Default model/deployment name") {
return "prod-mythos";
}
if (params.message === "Claude base model") {
expect(params.validate?.("claude-mythos-preview")).toBeUndefined();
return "claude-mythos-preview";
}
throw new Error(`unexpected prompt: ${params.message}`);
},
);
const select = vi.fn().mockResolvedValueOnce("claude");
const selection = await promptEndpointAndModelManually({
prompter: {
text,
select,
},
} as never);
expect(selection).toEqual({
endpoint: "https://example.services.ai.azure.com",
modelId: "prod-mythos",
modelNameHint: "claude-mythos-preview",
api: "anthropic-messages",
});
});
it("uses discovered deployment metadata for MAI image defaults", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
@@ -1242,7 +864,7 @@ describe("microsoft-foundry plugin", () => {
expect(normalized?.compat?.supportsReasoningEffort).toBe(false);
});
it("deletes legacy provider-level credentials for API-key profiles", () => {
it("writes Azure API key header overrides for API-key auth configs", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:default",
apiKey: "test-api-key",
@@ -1253,12 +875,9 @@ describe("microsoft-foundry plugin", () => {
});
const provider = requireFoundryProviderPatch(result);
expect(provider.apiKey).toBeUndefined();
expect(provider.authHeader).toBeUndefined();
expect(provider.headers).toBeUndefined();
expect(Object.hasOwn(provider, "apiKey")).toBe(true);
expect(Object.hasOwn(provider, "authHeader")).toBe(true);
expect(Object.hasOwn(provider, "headers")).toBe(true);
expect(provider.apiKey).toBe("test-api-key");
expect(provider.authHeader).toBe(false);
expect(provider.headers).toEqual({ "api-key": "test-api-key" });
});
it("uses the minimum supported response token count for GPT-5 connection tests", () => {
@@ -1361,215 +980,6 @@ describe("microsoft-foundry plugin", () => {
expect(provider?.models[0]?.compat?.maxTokensField).toBe("max_tokens");
});
it("routes Claude deployments through Foundry Anthropic Messages", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com/openai/v1",
modelId: "prod-fable",
modelNameHint: "claude-fable-5",
api: "anthropic-messages",
authMethod: "entra-id",
});
const provider = result.configPatch?.models?.providers?.["microsoft-foundry"];
expect(provider?.baseUrl).toBe("https://example.services.ai.azure.com/anthropic");
expect(provider?.api).toBe("anthropic-messages");
expect(provider?.authHeader).toBeUndefined();
expect(provider?.models[0]).toMatchObject({
id: "prod-fable",
name: "claude-fable-5",
api: "anthropic-messages",
reasoning: true,
input: ["text", "image"],
contextWindow: 1_000_000,
maxTokens: 128_000,
thinkingLevelMap: { xhigh: "xhigh", max: "max" },
});
expect(provider?.models[0]?.compat).toBeUndefined();
});
it("deletes legacy provider-level credentials for Entra profiles", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com/openai/v1",
modelId: "prod-fable",
modelNameHint: "claude-fable-5",
api: "anthropic-messages",
authMethod: "entra-id",
});
const provider = result.configPatch?.models?.providers?.["microsoft-foundry"] as
| Record<string, unknown>
| undefined;
expect(provider?.authHeader).toBeUndefined();
expect(Object.hasOwn(provider ?? {}, "apiKey")).toBe(true);
expect(Object.hasOwn(provider ?? {}, "authHeader")).toBe(true);
expect(Object.hasOwn(provider ?? {}, "headers")).toBe(true);
expect(provider?.apiKey).toBeUndefined();
expect(provider?.headers).toBeUndefined();
});
it.each([
["claude-mythos-preview", 128_000],
["claude-fable-5", 128_000],
["claude-opus-4.8", 128_000],
["claude-opus-4.7", 128_000],
["claude-opus-4.6", 128_000],
["claude-sonnet-4.6", 128_000],
["claude-opus-4.5", 64_000],
["claude-sonnet-4.5", 64_000],
["claude-haiku-4.5", 64_000],
["claude-opus-4.1", 32_000],
] as const)("preserves Foundry Claude token limits for %s", (modelNameHint, maxTokens) => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com",
modelId: `prod-${modelNameHint.replaceAll(".", "-")}`,
modelNameHint,
api: "anthropic-messages",
authMethod: "entra-id",
});
expect(result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0]).toMatchObject({
name: modelNameHint,
api: "anthropic-messages",
contextWindow: maxTokens === 128_000 ? 1_000_000 : 200_000,
maxTokens,
});
});
it("keeps older Foundry Claude deployments out of Fable-class thinking limits", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com",
modelId: "prod-claude-35",
modelNameHint: "claude-3.5-sonnet",
api: "anthropic-messages",
authMethod: "entra-id",
});
const model = result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0];
expect(model).toMatchObject({
id: "prod-claude-35",
name: "claude-3.5-sonnet",
api: "anthropic-messages",
reasoning: false,
input: ["text", "image"],
contextWindow: 128_000,
maxTokens: 16_384,
});
expect(model?.thinkingLevelMap).toBeUndefined();
expect(model?.compat).toBeUndefined();
});
it("resolves Claude thinking profiles from configured Foundry model names", () => {
const provider = registerProvider();
expect(
provider.resolveThinkingProfile?.({
provider: "microsoft-foundry",
modelId: "prod-fable",
params: { canonicalModelId: "claude-fable-5" },
}),
).toMatchObject({
defaultLevel: "high",
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "xhigh" },
{ id: "adaptive" },
{ id: "max" },
],
});
for (const modelName of ["claude-opus-4-6", "claude-sonnet-4-6"]) {
expect(
provider.resolveThinkingProfile?.({
provider: "microsoft-foundry",
modelId: `prod-${modelName}`,
params: { canonicalModelId: modelName },
}),
).toMatchObject({
defaultLevel: "adaptive",
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "adaptive" },
{ id: "max" },
],
});
}
for (const modelName of [
"claude-opus-4-1",
"claude-opus-4-5",
"claude-sonnet-4-5",
"claude-haiku-4-5",
]) {
expect(
provider.resolveThinkingProfile?.({
provider: "microsoft-foundry",
modelId: `prod-${modelName}`,
params: { canonicalModelId: modelName },
}),
).toMatchObject({
levels: [
{ id: "off" },
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
],
});
}
expect(
provider.resolveThinkingProfile?.({
provider: "microsoft-foundry",
modelId: "prod-opaque",
}),
).toBeUndefined();
expect(
provider.resolveThinkingProfile?.({
provider: "microsoft-foundry",
modelId: "prod-mythos-preview",
params: { canonicalModelId: "claude-mythos-preview" },
}),
).toMatchObject({
defaultLevel: "adaptive",
levels: [
{ id: "minimal" },
{ id: "low" },
{ id: "medium" },
{ id: "high" },
{ id: "adaptive" },
],
});
});
it("does not record native max thinking maps for Foundry Mythos Preview deployments", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:entra",
apiKey: "__entra_id_dynamic__",
endpoint: "https://example.services.ai.azure.com",
modelId: "prod-mythos-preview",
modelNameHint: "claude-mythos-preview",
api: "anthropic-messages",
authMethod: "entra-id",
});
const model = result.configPatch?.models?.providers?.["microsoft-foundry"]?.models[0];
expect(model?.thinkingLevelMap).toBeUndefined();
expect(model?.params).toMatchObject({ canonicalModelId: "claude-mythos-preview" });
});
it("keeps Foundry chat reasoning_effort enabled for GPT-5 reasoning deployments", () => {
const result = buildFoundryAuthResult({
profileId: "microsoft-foundry:default",
@@ -1730,6 +1140,7 @@ describe("microsoft-foundry plugin", () => {
{
id: "prod-primary",
name: "production alias",
api: "openai-responses",
reasoning: false,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
@@ -1807,30 +1218,13 @@ describe("microsoft-foundry plugin", () => {
expect(testRequest.body.max_tokens).toBe(1);
});
it("builds Anthropic Messages connection tests for Claude deployments", () => {
const testRequest = buildFoundryConnectionTest({
endpoint: "https://example.services.ai.azure.com/openai/v1",
modelId: "prod-fable",
modelNameHint: "claude-fable-5",
api: "anthropic-messages",
});
expect(testRequest.url).toBe("https://example.services.ai.azure.com/anthropic/v1/messages");
expect(testRequest.body).toEqual({
model: "prod-fable",
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
thinking: { type: "adaptive" },
});
});
it("returns actionable Azure CLI login errors", async () => {
mockAzureCliLoginFailure();
await expect(getAccessTokenResultAsync()).rejects.toThrow("Azure CLI is not logged in");
});
it("deletes legacy provider-level secret refs", () => {
it("keeps Azure API key header overrides when API-key auth uses a secret ref", () => {
const secretRef = {
source: "env" as const,
provider: "default",
@@ -1846,12 +1240,9 @@ describe("microsoft-foundry plugin", () => {
});
const provider = requireFoundryProviderPatch(result);
expect(provider.apiKey).toBeUndefined();
expect(provider.authHeader).toBeUndefined();
expect(provider.headers).toBeUndefined();
expect(Object.hasOwn(provider, "apiKey")).toBe(true);
expect(Object.hasOwn(provider, "authHeader")).toBe(true);
expect(Object.hasOwn(provider, "headers")).toBe(true);
expect(provider.apiKey).toBe(secretRef);
expect(provider.authHeader).toBe(false);
expect(provider.headers).toEqual({ "api-key": secretRef });
});
it("moves the selected Foundry auth profile to the front of auth.order", () => {
@@ -1917,7 +1308,6 @@ describe("microsoft-foundry plugin", () => {
deployments: [
{ name: "deployment-gpt5", modelName: "gpt-5.4", api: "openai-responses" },
{ name: "deployment-gpt4o", modelName: "gpt-4o", api: "openai-responses" },
{ name: "deployment-fable", modelName: "claude-fable-5", api: "anthropic-messages" },
],
});
@@ -1925,17 +1315,54 @@ describe("microsoft-foundry plugin", () => {
expect(provider?.models.map((model) => model.id)).toEqual([
"deployment-gpt5",
"deployment-gpt4o",
"deployment-fable",
]);
expect(provider?.models.map((model) => [model.id, model.baseUrl])).toEqual([
["deployment-gpt5", "https://example.services.ai.azure.com/openai/v1"],
["deployment-gpt4o", "https://example.services.ai.azure.com/openai/v1"],
["deployment-fable", "https://example.services.ai.azure.com/anthropic"],
]);
expect(result.defaultModel).toBe("microsoft-foundry/deployment-gpt5");
});
});
describe("partitionFoundryDeployments", () => {
it("keeps OpenAI-compatible deployments and skips Claude in mixed resources", () => {
const { supported, anthropic } = partitionFoundryDeployments([
{ name: "prod-gpt", modelName: "gpt-5.4" },
{ name: "prod-claude", modelName: "claude-opus-4-6" },
{ name: "prod-mini", modelName: "gpt-4o-mini" },
]);
expect(supported.map((deployment) => deployment.name)).toEqual(["prod-gpt", "prod-mini"]);
expect(anthropic.map((deployment) => deployment.name)).toEqual(["prod-claude"]);
});
it("returns no supported deployments when only Anthropic deployments exist", () => {
const { supported, anthropic } = partitionFoundryDeployments([
{ name: "only-claude", modelName: "claude-3.5-sonnet" },
]);
expect(supported).toEqual([]);
expect(anthropic.map((deployment) => deployment.name)).toEqual(["only-claude"]);
});
it("is a no-op for all-OpenAI resources", () => {
const deployments = [
{ name: "prod-gpt", modelName: "gpt-5.4" },
{ name: "prod-mini", modelName: "gpt-4o-mini" },
];
const { supported, anthropic } = partitionFoundryDeployments(deployments);
expect(supported).toEqual(deployments);
expect(anthropic).toEqual([]);
});
it("classifies by deployment name when modelName is missing", () => {
const { supported, anthropic } = partitionFoundryDeployments([
{ name: "claude-opus-4-6" },
{ name: "gpt-5.4-prod" },
]);
expect(supported.map((deployment) => deployment.name)).toEqual(["gpt-5.4-prod"]);
expect(anthropic.map((deployment) => deployment.name)).toEqual(["claude-opus-4-6"]);
});
});
describe("selectFoundryDeployment", () => {
function makeCtx(overrides: { selectValue?: string } = {}) {
const noteCalls: Array<{ message: string; title: string }> = [];
@@ -1963,7 +1390,7 @@ describe("selectFoundryDeployment", () => {
projects: [],
};
it("offers and returns Claude deployments alongside GPT resources", async () => {
it("offers and returns only supported deployments for mixed GPT and Claude resources", async () => {
const { ctx, selectCalls, noteCalls } = makeCtx({ selectValue: "prod-gpt" });
const result = await selectFoundryDeployment(ctx, fakeResource, [
{ name: "prod-gpt", modelName: "gpt-5.4", state: "Succeeded" },
@@ -1973,30 +1400,25 @@ describe("selectFoundryDeployment", () => {
expect(result.supported.map((deployment) => deployment.name)).toEqual([
"prod-gpt",
"prod-claude",
"prod-mini",
]);
expect(result.selected.name).toBe("prod-gpt");
expect(selectCalls[0]?.options.map((option) => option.value)).toEqual([
"prod-gpt",
"prod-claude",
"prod-mini",
]);
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(false);
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(true);
});
it("uses Anthropic-only deployment resources directly", async () => {
it("throws an actionable error when only Anthropic deployments exist", async () => {
const { ctx, noteCalls } = makeCtx();
const result = await selectFoundryDeployment(ctx, fakeResource, [
{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
]);
expect(result).toEqual({
selected: { name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
supported: [{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" }],
});
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(false);
await expect(
selectFoundryDeployment(ctx, fakeResource, [
{ name: "only-claude", modelName: "claude-3.5-sonnet", state: "Succeeded" },
]),
).rejects.toThrow(/Only Anthropic deployments/);
expect(noteCalls.some((call) => call.title === "Unsupported Deployments")).toBe(true);
});
it("leaves all-OpenAI resources unchanged", async () => {

View File

@@ -22,13 +22,10 @@ import {
type FoundrySelection,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
partitionFoundryDeployments,
requiresFoundryMaxCompletionTokens,
requiresFoundryEntraIdClaudeAuth,
requiresFoundryMandatoryAdaptiveClaudeThinking,
ANTHROPIC_MESSAGES_API,
DEFAULT_API,
DEFAULT_GPT5_API,
FOUNDRY_ANTHROPIC_SCOPE,
usesFoundryResponsesByDefault,
} from "./shared.js";
@@ -171,15 +168,34 @@ export async function selectFoundryDeployment(
resource: FoundryResourceOption,
deployments: AzDeploymentSummary[],
): Promise<{ selected: AzDeploymentSummary; supported: AzDeploymentSummary[] }> {
const supported = deployments;
if (supported.length === 0) {
throw new Error(
const { supported, anthropic } = partitionFoundryDeployments(deployments);
const anthropicNames = anthropic.map((deployment) => deployment.name);
if (anthropicNames.length > 0) {
await ctx.prompter.note(
[
`No model deployments were found in ${resource.accountName}.`,
"Deploy a model in Microsoft Foundry or Azure OpenAI, then rerun onboard.",
].join("\n"),
`Skipping ${anthropicNames.length} Anthropic deployment(s) (${anthropicNames.join(", ")}):`,
"the built-in Microsoft Foundry provider only supports OpenAI-compatible APIs.",
"To use Claude on Azure, configure a custom provider with base URL",
"https://<resource>.services.ai.azure.com/anthropic and API: anthropic-messages.",
].join(" "),
"Unsupported Deployments",
);
}
if (supported.length === 0) {
const hint =
anthropicNames.length > 0
? [
`Only Anthropic deployments were found in ${resource.accountName},`,
"which are not supported by this provider.",
"Use a custom provider with the Anthropic Foundry endpoint, or",
"deploy an OpenAI-compatible model and rerun onboard.",
].join(" ")
: [
`No model deployments were found in ${resource.accountName}.`,
"Deploy a model in Azure AI Foundry or Azure OpenAI, then rerun onboard.",
].join("\n");
throw new Error(hint);
}
if (supported.length === 1) {
const only = supported[0];
await ctx.prompter.note(`Using deployment: ${only.name}`, "Model Deployment");
@@ -207,11 +223,6 @@ async function promptFoundryApi(
return await ctx.prompter.select({
message: "Select request API",
options: [
{
value: ANTHROPIC_MESSAGES_API,
label: "Anthropic Messages API",
hint: "Use for Claude deployments through Microsoft Foundry /anthropic",
},
{
value: DEFAULT_GPT5_API,
label: "Responses API",
@@ -227,7 +238,7 @@ async function promptFoundryApi(
});
}
type ManualFoundryModelFamilyChoice = "claude" | "reasoning-family" | "mai-image" | "other-chat";
type ManualFoundryModelFamilyChoice = "reasoning-family" | "mai-image" | "other-chat";
type ManualFoundryMaiImageModel =
| "MAI-Image-2.5-Flash"
| "MAI-Image-2.5"
@@ -236,16 +247,10 @@ type ManualFoundryMaiImageModel =
async function promptFoundryModelFamily(
ctx: ProviderAuthContext,
initialValue: ManualFoundryModelFamilyChoice,
): Promise<ManualFoundryModelFamilyChoice> {
return await ctx.prompter.select({
message: "Model family",
options: [
{
value: "claude",
label: "Claude",
hint: "Use for Anthropic Claude deployments",
},
{
value: "reasoning-family",
label: "GPT-5 series / o-series / Codex",
@@ -262,7 +267,7 @@ async function promptFoundryModelFamily(
hint: "Use for other chat/completions style Foundry models",
},
],
initialValue,
initialValue: "reasoning-family",
});
}
@@ -297,45 +302,17 @@ async function promptFoundryMaiImageModel(
});
}
async function promptFoundryClaudeModel(
ctx: ProviderAuthContext,
options?: { allowEntraOnlyModels?: boolean },
): Promise<string> {
return (
await ctx.prompter.text({
message: "Claude base model",
initialValue: "claude-fable-5",
placeholder: "claude-fable-5",
validate: (v) => {
const val = normalizeStringifiedOptionalString(v) ?? "";
if (!val) {
return "Claude base model is required";
}
if (!val.toLowerCase().startsWith("claude-")) {
return "Use a Claude model name such as claude-fable-5";
}
if (options?.allowEntraOnlyModels === false && requiresFoundryEntraIdClaudeAuth(val)) {
return "Claude Mythos deployments require Microsoft Entra ID auth; choose Entra ID auth or use a Claude model that supports API-key auth.";
}
return undefined;
},
})
).trim();
}
async function promptEndpointAndModelBase(
ctx: ProviderAuthContext,
options?: {
endpointInitialValue?: string;
modelInitialValue?: string;
modelFamilyInitialValue?: ManualFoundryModelFamilyChoice;
allowEntraOnlyClaudeModels?: boolean;
},
): Promise<FoundrySelection> {
const endpoint = (
await ctx.prompter.text({
message: "Microsoft Foundry endpoint URL",
placeholder: "https://xxx.services.ai.azure.com or https://xxx.openai.azure.com",
placeholder: "https://xxx.openai.azure.com or https://xxx.services.ai.azure.com",
...(options?.endpointInitialValue ? { initialValue: options.endpointInitialValue } : {}),
validate: (v) => {
const val = normalizeStringifiedOptionalString(v) ?? "";
@@ -350,7 +327,7 @@ async function promptEndpointAndModelBase(
await ctx.prompter.text({
message: "Default model/deployment name",
...(options?.modelInitialValue ? { initialValue: options.modelInitialValue } : {}),
placeholder: "claude-fable-5",
placeholder: "gpt-4o",
validate: (v) => {
const val = normalizeStringifiedOptionalString(v) ?? "";
if (!val) {
@@ -360,10 +337,7 @@ async function promptEndpointAndModelBase(
},
})
).trim();
const familyChoice = await promptFoundryModelFamily(
ctx,
options?.modelFamilyInitialValue ?? "claude",
);
const familyChoice = await promptFoundryModelFamily(ctx);
if (familyChoice === "mai-image") {
return {
endpoint,
@@ -372,16 +346,6 @@ async function promptEndpointAndModelBase(
api: DEFAULT_API,
};
}
if (familyChoice === "claude") {
return {
endpoint,
modelId,
modelNameHint: await promptFoundryClaudeModel(ctx, {
allowEntraOnlyModels: options?.allowEntraOnlyClaudeModels ?? true,
}),
api: ANTHROPIC_MESSAGES_API,
};
}
const resolvedModelName =
familyChoice === "reasoning-family"
? usesFoundryResponsesByDefault(modelId) || requiresFoundryMaxCompletionTokens(modelId)
@@ -412,8 +376,6 @@ export async function promptApiKeyEndpointAndModel(
return promptEndpointAndModelBase(ctx, {
endpointInitialValue: process.env.AZURE_OPENAI_ENDPOINT,
modelInitialValue: "gpt-4o",
modelFamilyInitialValue: "other-chat",
allowEntraOnlyClaudeModels: false,
});
}
@@ -439,19 +401,6 @@ export function buildFoundryConnectionTest(params: {
},
};
}
if (params.api === ANTHROPIC_MESSAGES_API) {
return {
url: `${baseUrl}/v1/messages`,
body: {
model: params.modelId,
messages: [{ role: "user", content: "hi" }],
max_tokens: 1,
...(requiresFoundryMandatoryAdaptiveClaudeThinking(params.modelNameHint ?? params.modelId)
? { thinking: { type: "adaptive" } }
: {}),
},
};
}
return {
url: `${baseUrl}/chat/completions`,
body: {
@@ -580,7 +529,6 @@ export async function testFoundryConnection(params: {
}): Promise<void> {
try {
const { accessToken } = getAccessTokenResult({
scope: params.api === ANTHROPIC_MESSAGES_API ? FOUNDRY_ANTHROPIC_SCOPE : undefined,
subscriptionId: params.subscriptionId,
tenantId: params.tenantId,
});
@@ -597,7 +545,6 @@ export async function testFoundryConnection(params: {
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
...(params.api === ANTHROPIC_MESSAGES_API ? { "anthropic-version": "2023-06-01" } : {}),
},
body: JSON.stringify(testRequest.body),
},

View File

@@ -1,10 +1,8 @@
// Microsoft Foundry provider module implements model/runtime integration.
import type { ProviderNormalizeResolvedModelContext } from "openclaw/plugin-sdk/core";
import {
resolveClaudeThinkingProfile,
supportsClaudeNativeMaxEffort,
type ModelProviderConfig,
type ProviderPlugin,
import type {
ModelProviderConfig,
ProviderPlugin,
} from "openclaw/plugin-sdk/provider-model-shared";
import { OPENAI_RESPONSES_STREAM_HOOKS } from "openclaw/plugin-sdk/provider-stream-family";
import { apiKeyAuthMethod, entraIdAuthMethod } from "./auth.js";
@@ -15,9 +13,7 @@ import {
applyFoundryProviderConfig,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
isFoundryClaudeMythosPreview,
isFoundryProviderApi,
mergeFoundryCanonicalModelParams,
normalizeFoundryEndpoint,
resolveFoundryModelCapabilities,
resolveFoundryTargetProfileId,
@@ -79,41 +75,27 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
const existingModel = configuredModels.find(
(model: { id: string }) => model.id === selectedModelId,
);
const existingModelApi = isFoundryProviderApi(existingModel?.api)
? existingModel.api
: undefined;
const providerApiForExistingModel =
existingModel && isFoundryProviderApi(providerConfig.api) ? providerConfig.api : undefined;
const selectedModelCapabilities = resolveFoundryModelCapabilities(
selectedModelId,
existingModel?.name,
existingModelApi ?? providerApiForExistingModel,
isFoundryProviderApi(existingModel?.api) ? existingModel.api : providerConfig.api,
existingModel?.input,
);
const providerEndpoint = normalizeFoundryEndpoint(providerConfig.baseUrl ?? "");
const selectedProviderEndpoint =
extractFoundryEndpoint(existingModel?.baseUrl) ?? providerEndpoint;
// Prefer the persisted per-model API choice from onboarding/discovery so arbitrary
// deployment aliases (for example prod-primary) do not fall back to name heuristics.
const selectedModelApi = isFoundryProviderApi(existingModel?.api)
? existingModel.api
: providerConfig.api;
const nextModels = configuredModels.map((model) => {
if (model.id !== selectedModelId) {
return model;
}
const selectedModelEndpoint = extractFoundryEndpoint(model.baseUrl) ?? providerEndpoint;
const selectedModelBaseUrl = buildFoundryProviderBaseUrl(
selectedModelEndpoint,
selectedModelId,
selectedModelCapabilities.modelName,
selectedModelCapabilities.api,
);
const nextModel = Object.assign({}, model, {
name: selectedModelCapabilities.modelName,
api: selectedModelCapabilities.api,
baseUrl: selectedModelBaseUrl,
reasoning: selectedModelCapabilities.reasoning || model.reasoning,
thinkingLevelMap: selectedModelCapabilities.thinkingLevelMap ?? model.thinkingLevelMap,
params: mergeFoundryCanonicalModelParams(
model.params,
selectedModelCapabilities.modelName,
),
input: selectedModelCapabilities.input,
});
if (selectedModelCapabilities.compat) {
@@ -149,31 +131,24 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
id: selectedModelId,
name: selectedModelCapabilities.modelName,
api: selectedModelCapabilities.api,
baseUrl: buildFoundryProviderBaseUrl(
providerEndpoint,
selectedModelId,
selectedModelCapabilities.modelName,
selectedModelCapabilities.api,
),
reasoning: selectedModelCapabilities.reasoning,
...(selectedModelCapabilities.thinkingLevelMap
? { thinkingLevelMap: selectedModelCapabilities.thinkingLevelMap }
: {}),
params: mergeFoundryCanonicalModelParams(undefined, selectedModelCapabilities.modelName),
input: selectedModelCapabilities.input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: selectedModelCapabilities.contextWindow,
maxTokens: selectedModelCapabilities.maxTokens,
contextWindow: 128_000,
maxTokens: 16_384,
...(selectedModelCapabilities.compat ? { compat: selectedModelCapabilities.compat } : {}),
});
}
const nextProviderConfig: ModelProviderConfig = {
...providerConfig,
baseUrl: buildFoundryProviderBaseUrl(
selectedProviderEndpoint,
providerEndpoint,
selectedModelId,
selectedModelCapabilities.modelName,
selectedModelCapabilities.api,
selectedModelApi,
),
api: selectedModelCapabilities.api,
models: nextModels,
@@ -184,28 +159,6 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
}
applyFoundryProviderConfig(ctx.config, nextProviderConfig);
},
resolveThinkingProfile: ({ modelId, params }) => {
const modelName =
typeof params?.canonicalModelId === "string" ? params.canonicalModelId : undefined;
const capabilities = resolveFoundryModelCapabilities(modelId, modelName);
if (!capabilities.reasoning || capabilities.api !== "anthropic-messages") {
return undefined;
}
const profile = resolveClaudeThinkingProfile(capabilities.modelName, undefined, {
includeNativeMax: supportsClaudeNativeMaxEffort({ id: capabilities.modelName }),
});
if (!isFoundryClaudeMythosPreview(capabilities.modelName)) {
return profile;
}
const levels = profile.levels.filter((level) => level.id !== "off");
return {
...profile,
defaultLevel: "adaptive",
levels: levels.some((level) => level.id === "adaptive")
? levels
: [...levels, { id: "adaptive" }],
};
},
normalizeResolvedModel: ({ modelId, model }: ProviderNormalizeResolvedModelContext) => {
const endpoint = extractFoundryEndpoint(model.baseUrl ?? "");
if (!endpoint) {
@@ -246,7 +199,6 @@ export function buildMicrosoftFoundryProvider(): ProviderPlugin {
api: capabilities.api,
reasoning: capabilities.reasoning || model.reasoning,
thinkingLevelMap: capabilities.thinkingLevelMap ?? model.thinkingLevelMap,
params: mergeFoundryCanonicalModelParams(model.params, capabilities.modelName),
input: capabilities.input,
baseUrl: buildFoundryProviderBaseUrl(
endpoint,

View File

@@ -1,8 +1,5 @@
// Microsoft Foundry plugin module implements runtime behavior.
import type {
ProviderPreparedRuntimeAuth,
ProviderPrepareRuntimeAuthContext,
} from "openclaw/plugin-sdk/core";
import type { ProviderPrepareRuntimeAuthContext } from "openclaw/plugin-sdk/core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import {
asDateTimestampMs,
@@ -13,9 +10,7 @@ import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { getAccessTokenResultAsync } from "./cli.js";
import {
ANTHROPIC_MESSAGES_API,
type CachedTokenEntry,
FOUNDRY_ANTHROPIC_SCOPE,
TOKEN_REFRESH_MARGIN_MS,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
@@ -34,7 +29,6 @@ export function resetFoundryRuntimeAuthCaches(): void {
}
async function refreshEntraToken(params?: {
scope?: string;
subscriptionId?: string;
tenantId?: string;
}): Promise<{ apiKey: string; expiresAt: number }> {
@@ -52,20 +46,9 @@ async function refreshEntraToken(params?: {
return { apiKey: result.accessToken, expiresAt };
}
export async function prepareFoundryRuntimeAuth(
ctx: ProviderPrepareRuntimeAuthContext,
): Promise<ProviderPreparedRuntimeAuth> {
export async function prepareFoundryRuntimeAuth(ctx: ProviderPrepareRuntimeAuthContext) {
if (ctx.apiKey !== "__entra_id_dynamic__") {
return {
apiKey: ctx.apiKey,
request: {
auth: {
mode: "header" as const,
headerName: ctx.model.api === ANTHROPIC_MESSAGES_API ? "x-api-key" : "api-key",
value: ctx.apiKey,
},
},
};
return null;
}
try {
const authStore = ensureAuthProfileStore(ctx.agentDir, {
@@ -77,31 +60,24 @@ export async function prepareFoundryRuntimeAuth(
normalizeOptionalString(ctx.modelId) ??
normalizeOptionalString(metadata?.modelId) ??
ctx.modelId;
const requestedModelId = normalizeOptionalString(ctx.modelId);
const metadataModelId = normalizeOptionalString(metadata?.modelId);
const activeModelUsesMetadata = !requestedModelId || requestedModelId === metadataModelId;
const activeModelNameHint = activeModelUsesMetadata ? metadata?.modelName : undefined;
const activeModelNameHint = ctx.modelId === metadata?.modelId ? metadata?.modelName : undefined;
const modelNameHint = resolveConfiguredModelNameHint(
modelId,
ctx.model.name ?? activeModelNameHint,
);
const configuredApi = isFoundryProviderApi(ctx.model.api)
? ctx.model.api
: activeModelUsesMetadata &&
typeof metadata?.api === "string" &&
isFoundryProviderApi(metadata.api)
const configuredApi =
typeof metadata?.api === "string" && isFoundryProviderApi(metadata.api)
? metadata.api
: undefined;
: isFoundryProviderApi(ctx.model.api)
? ctx.model.api
: undefined;
const endpoint =
extractFoundryEndpoint(ctx.model.baseUrl ?? "") ??
normalizeOptionalString(metadata?.endpoint);
const tokenScope =
configuredApi === ANTHROPIC_MESSAGES_API ? FOUNDRY_ANTHROPIC_SCOPE : undefined;
normalizeOptionalString(metadata?.endpoint) ??
extractFoundryEndpoint(ctx.model.baseUrl ?? "");
const baseUrl = endpoint
? buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, configuredApi)
: undefined;
const cacheKey = getFoundryTokenCacheKey({
scope: tokenScope,
subscriptionId: metadata?.subscriptionId,
tenantId: metadata?.tenantId,
});
@@ -116,15 +92,11 @@ export async function prepareFoundryRuntimeAuth(
apiKey: cachedToken.token,
expiresAt: cachedToken.expiresAt,
...(baseUrl ? { baseUrl } : {}),
request: {
auth: { mode: "authorization-bearer" as const, token: cachedToken.token },
},
};
}
let refreshPromise = refreshPromises.get(cacheKey);
if (!refreshPromise) {
refreshPromise = refreshEntraToken({
scope: tokenScope,
subscriptionId: metadata?.subscriptionId,
tenantId: metadata?.tenantId,
}).finally(() => {
@@ -136,9 +108,6 @@ export async function prepareFoundryRuntimeAuth(
return {
...token,
...(baseUrl ? { baseUrl } : {}),
request: {
auth: { mode: "authorization-bearer" as const, token: token.apiKey },
},
};
} catch (err) {
const details = formatErrorMessage(err);

View File

@@ -3,17 +3,14 @@ export {
TOKEN_REFRESH_MARGIN_MS,
buildFoundryProviderBaseUrl,
extractFoundryEndpoint,
FOUNDRY_ANTHROPIC_SCOPE,
isFoundryProviderApi,
resolveConfiguredModelNameHint,
ANTHROPIC_MESSAGES_API,
type CachedTokenEntry,
} from "./shared.js";
export function getFoundryTokenCacheKey(params?: {
scope?: string;
subscriptionId?: string;
tenantId?: string;
}): string {
return `${params?.scope ?? ""}:${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`;
return `${params?.subscriptionId ?? ""}:${params?.tenantId ?? ""}`;
}

View File

@@ -6,13 +6,7 @@ import {
type ProviderAuthResult,
type SecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import {
resolveClaudeFable5ModelIdentity,
supportsClaudeAdaptiveThinking,
supportsClaudeNativeXhighEffort,
type ModelApi,
type ModelProviderConfig,
} from "openclaw/plugin-sdk/provider-model-shared";
import type { ModelApi, ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
@@ -21,9 +15,7 @@ import {
export const PROVIDER_ID = "microsoft-foundry";
export const DEFAULT_API = "openai-completions";
export const DEFAULT_GPT5_API = "openai-responses";
export const ANTHROPIC_MESSAGES_API = "anthropic-messages";
export const COGNITIVE_SERVICES_RESOURCE = "https://cognitiveservices.azure.com";
export const FOUNDRY_ANTHROPIC_SCOPE = "https://ai.azure.com/.default";
export const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000;
export const MAI_IMAGE_MODELS = [
"MAI-Image-2.5-Flash",
@@ -88,10 +80,7 @@ export type CachedTokenEntry = {
expiresAt: number;
};
export type FoundryProviderApi =
| typeof DEFAULT_API
| typeof DEFAULT_GPT5_API
| typeof ANTHROPIC_MESSAGES_API;
export type FoundryProviderApi = typeof DEFAULT_API | typeof DEFAULT_GPT5_API;
type FoundryDeploymentConfigInput = {
name: string;
@@ -110,11 +99,6 @@ type FoundryModelCapabilities = {
compat?: FoundryModelCompat;
};
type FoundryProviderConfigPatch = Omit<ModelProviderConfig, "apiKey" | "headers"> & {
apiKey?: SecretInput | undefined;
headers?: Record<string, SecretInput> | undefined;
};
function normalizeModelInput(input?: unknown): Array<"text" | "image"> {
const normalized = Array.isArray(input)
? input.filter((item): item is "text" | "image" => item === "text" || item === "image")
@@ -156,8 +140,20 @@ export function isAnthropicFoundryDeployment(modelName?: string | null): boolean
return normalized ? normalized.startsWith("claude") : false;
}
export function isFoundryClaudeMythosPreview(value?: string | null): boolean {
return normalizeFoundryModelName(value) === "claude-mythos-preview";
export function partitionFoundryDeployments<T extends { name: string; modelName?: string }>(
deployments: readonly T[],
): { supported: T[]; anthropic: T[] } {
const supported: T[] = [];
const anthropic: T[] = [];
for (const deployment of deployments) {
const classifier = resolveConfiguredModelNameHint(deployment.name, deployment.modelName);
if (isAnthropicFoundryDeployment(classifier)) {
anthropic.push(deployment);
} else {
supported.push(deployment);
}
}
return { supported, anthropic };
}
export function usesFoundryResponsesByDefault(value?: string | null): boolean {
@@ -200,7 +196,6 @@ export function supportsFoundryImageInput(value?: string | null): boolean {
return false;
}
return (
isAnthropicFoundryDeployment(normalized) ||
normalized.startsWith("gpt-") ||
normalized.startsWith("o1") ||
normalized.startsWith("o3") ||
@@ -209,52 +204,11 @@ export function supportsFoundryImageInput(value?: string | null): boolean {
);
}
export function requiresFoundryEntraIdClaudeAuth(value?: string | null): boolean {
const normalized = normalizeFoundryModelName(value);
return normalized
? normalized === "claude-mythos-preview" || normalized.startsWith("claude-mythos-")
: false;
}
export function requiresFoundryMandatoryAdaptiveClaudeThinking(value?: string | null): boolean {
const normalized = normalizeFoundryModelName(value);
return normalized
? resolveClaudeFable5ModelIdentity({ id: normalized }) !== undefined ||
normalized === "claude-mythos-preview" ||
normalized.startsWith("claude-mythos-")
: false;
}
function supportsFoundryManualClaudeThinking(value?: string | null): boolean {
const normalized = normalizeFoundryModelName(value)?.replace(/\./g, "-");
return normalized
? /(?:^|-)claude-(?:opus-4-(?:1|5)|sonnet-4-5|haiku-4-5)(?=$|[^a-z0-9])/.test(normalized)
: false;
}
function resolveFoundryModelTokenLimits(value?: string | null): {
contextWindow: number;
maxTokens: number;
} {
const normalized = normalizeFoundryModelName(value);
const normalizedVersion = normalized?.replace(/\./g, "-");
if (
normalized &&
(supportsClaudeAdaptiveThinking({ id: normalized }) ||
requiresFoundryMandatoryAdaptiveClaudeThinking(normalized))
) {
return { contextWindow: 1_000_000, maxTokens: 128_000 };
}
if (
normalizedVersion === "claude-opus-4-5" ||
normalizedVersion === "claude-sonnet-4-5" ||
normalizedVersion === "claude-haiku-4-5"
) {
return { contextWindow: 200_000, maxTokens: 64_000 };
}
if (normalizedVersion === "claude-opus-4-1") {
return { contextWindow: 200_000, maxTokens: 32_000 };
}
if (normalized === "mai-ds-r1") {
return { contextWindow: 163_840, maxTokens: 163_840 };
}
@@ -336,15 +290,7 @@ function buildFoundryThinkingLevelMap(
}
export function isFoundryProviderApi(value?: string | null): value is FoundryProviderApi {
return value === DEFAULT_API || value === DEFAULT_GPT5_API || value === ANTHROPIC_MESSAGES_API;
}
export function formatFoundryApiLabel(api: FoundryProviderApi): string {
return api === DEFAULT_GPT5_API
? "Responses"
: api === ANTHROPIC_MESSAGES_API
? "Anthropic Messages"
: "Chat Completions";
return value === DEFAULT_API || value === DEFAULT_GPT5_API;
}
export function normalizeFoundryEndpoint(endpoint: string): string {
@@ -356,13 +302,11 @@ export function normalizeFoundryEndpoint(endpoint: string): string {
const parsed = new URL(trimmed);
parsed.search = "";
parsed.hash = "";
const normalizedPath = parsed.pathname
.replace(/\/(?:openai|anthropic)(?:$|\/).*/i, "")
.replace(/\/+$/, "");
const normalizedPath = parsed.pathname.replace(/\/openai(?:$|\/).*/i, "").replace(/\/+$/, "");
return `${parsed.origin}${normalizedPath && normalizedPath !== "/" ? normalizedPath : ""}`;
} catch {
const withoutQuery = trimmed.replace(/[?#].*$/, "").replace(/\/+$/, "");
return withoutQuery.replace(/\/(?:openai|anthropic)(?:$|\/).*/i, "");
return withoutQuery.replace(/\/openai(?:$|\/).*/i, "");
}
}
@@ -371,11 +315,6 @@ function buildFoundryV1BaseUrl(endpoint: string): string {
return base.endsWith("/openai/v1") ? base : `${base}/openai/v1`;
}
function buildFoundryAnthropicBaseUrl(endpoint: string): string {
const base = normalizeFoundryEndpoint(endpoint);
return base.endsWith("/anthropic") ? base : `${base}/anthropic`;
}
export function resolveFoundryApi(
modelId: string,
modelNameHint?: string | null,
@@ -385,22 +324,16 @@ export function resolveFoundryApi(
return configuredApi;
}
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
if (isAnthropicFoundryDeployment(configuredModelName)) {
return ANTHROPIC_MESSAGES_API;
}
return usesFoundryResponsesByDefault(configuredModelName) ? DEFAULT_GPT5_API : DEFAULT_API;
}
export function buildFoundryProviderBaseUrl(
endpoint: string,
modelId: string,
modelNameHint?: string | null,
configuredApi?: ModelApi | null,
_modelId: string,
_modelNameHint?: string | null,
_configuredApi?: ModelApi | null,
): string {
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi);
return resolvedApi === ANTHROPIC_MESSAGES_API
? buildFoundryAnthropicBaseUrl(endpoint)
: buildFoundryV1BaseUrl(endpoint);
return buildFoundryV1BaseUrl(endpoint);
}
export function extractFoundryEndpoint(baseUrl: string | null | undefined): string | undefined {
@@ -420,9 +353,6 @@ function buildFoundryModelCompat(
configuredApi?: ModelApi | null,
): FoundryModelCompat | undefined {
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, configuredApi);
if (resolvedApi === ANTHROPIC_MESSAGES_API) {
return undefined;
}
const configuredModelName = resolveConfiguredModelNameHint(modelId, modelNameHint);
const needsMaxCompletionTokens = requiresFoundryMaxCompletionTokens(configuredModelName);
const supportsReasoningEffort = supportsFoundryReasoningEffort(configuredModelName);
@@ -451,27 +381,15 @@ export function resolveFoundryModelCapabilities(
const api = resolveFoundryApi(modelId, modelName, configuredApi);
const normalizedInput = normalizeModelInput(existingInput);
const supportedReasoningEfforts = resolveFoundryReasoningEfforts(modelName);
const isAnthropic = api === ANTHROPIC_MESSAGES_API || isAnthropicFoundryDeployment(modelName);
const supportsClaudeThinking =
isAnthropic &&
(supportsClaudeAdaptiveThinking({ id: modelName }) ||
supportsFoundryManualClaudeThinking(modelName) ||
requiresFoundryMandatoryAdaptiveClaudeThinking(modelName));
const supportsClaudeXhighThinking =
isAnthropic && supportsClaudeNativeXhighEffort({ id: modelName });
const tokenLimits = resolveFoundryModelTokenLimits(modelName);
return {
modelName,
api,
reasoning:
supportsClaudeThinking ||
supportsFoundryReasoningEffort(modelName) ||
supportsFoundryReasoningContent(modelName),
...(supportsClaudeXhighThinking
? { thinkingLevelMap: { xhigh: "xhigh", max: "max" } }
: supportedReasoningEfforts
? { thinkingLevelMap: buildFoundryThinkingLevelMap(supportedReasoningEfforts) }
: {}),
supportsFoundryReasoningEffort(modelName) || supportsFoundryReasoningContent(modelName),
...(supportedReasoningEfforts
? { thinkingLevelMap: buildFoundryThinkingLevelMap(supportedReasoningEfforts) }
: {}),
input:
normalizedInput.includes("image") || supportsFoundryImageInput(modelName)
? ["text", "image"]
@@ -482,16 +400,6 @@ export function resolveFoundryModelCapabilities(
};
}
export function mergeFoundryCanonicalModelParams(
params: Record<string, unknown> | undefined,
modelName: string,
): Record<string, unknown> {
return {
...params,
canonicalModelId: modelName,
};
}
export function resolveConfiguredModelNameHint(
modelId: string,
modelNameHint?: string | null,
@@ -510,9 +418,13 @@ function buildFoundryProviderConfig(
modelNameHint?: string | null,
options?: {
api?: FoundryProviderApi;
authMethod?: "api-key" | "entra-id";
apiKey?: SecretInput;
deployments?: FoundryDeploymentConfigInput[];
},
): FoundryProviderConfigPatch {
): ModelProviderConfig {
const runtimeApiKey = options?.authMethod === "api-key" ? options.apiKey : undefined;
const isApiKeyAuth = options?.authMethod === "api-key";
const resolvedApi = resolveFoundryApi(modelId, modelNameHint, options?.api);
const deployments = options?.deployments?.length
? options.deployments
@@ -520,32 +432,29 @@ function buildFoundryProviderConfig(
return {
baseUrl: buildFoundryProviderBaseUrl(endpoint, modelId, modelNameHint, resolvedApi),
api: resolvedApi,
authHeader: undefined,
apiKey: undefined,
headers: undefined,
...(isApiKeyAuth
? {
authHeader: false,
...(runtimeApiKey !== undefined
? { apiKey: runtimeApiKey, headers: { "api-key": runtimeApiKey } }
: {}),
}
: {}),
models: deployments.map((deployment) => {
const capabilities = resolveFoundryModelCapabilities(
deployment.name,
deployment.modelName,
deployment.api ?? resolvedApi,
);
const modelBaseUrl = buildFoundryProviderBaseUrl(
endpoint,
deployment.name,
capabilities.modelName,
capabilities.api,
);
return Object.assign(
{
id: deployment.name,
name: capabilities.modelName,
api: capabilities.api,
baseUrl: modelBaseUrl,
reasoning: capabilities.reasoning,
...(capabilities.thinkingLevelMap
? { thinkingLevelMap: capabilities.thinkingLevelMap }
: {}),
params: mergeFoundryCanonicalModelParams(undefined, capabilities.modelName),
input: capabilities.input,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: capabilities.contextWindow,
@@ -730,9 +639,11 @@ export function buildFoundryAuthResult(params: {
params.modelNameHint,
{
api: params.api,
authMethod: params.authMethod,
apiKey: params.apiKey,
deployments: params.deployments,
},
) as unknown as ModelProviderConfig,
),
},
},
...buildPluginsAllowPatch(params.currentPluginsAllow),

View File

@@ -167,16 +167,6 @@ describe("buildOpenAIProvider", () => {
expect(manifest.modelCatalog.discovery.openai).toBe("runtime");
});
it("does not hardcode chatgpt-responses transport on gpt-5.3-codex catalog entry (#91710)", () => {
const openaiModels = manifest.modelCatalog.providers.openai.models as Array<
Record<string, unknown>
>;
const codexEntry = openaiModels.find((m) => m.id === "gpt-5.3-codex");
expect(codexEntry).toBeDefined();
expect(codexEntry?.api).toBeUndefined();
expect(codexEntry?.baseUrl).toBeUndefined();
});
it("keeps a network-free OpenAI static catalog", async () => {
const provider = buildOpenAIProvider();
@@ -329,6 +319,8 @@ describe("buildOpenAIProvider", () => {
expect(provider.apiKey).toBe("sk-custom-openai-compatible");
const apiModel = provider.models.find((model) => model.api !== "openai-chatgpt-responses");
expect(apiModel?.baseUrl).toBe(customBaseUrl);
const codexModel = provider.models.find((model) => model.api === "openai-chatgpt-responses");
expect(codexModel?.baseUrl).toBe("https://chatgpt.com/backend-api");
});
it("uses the Codex backend catalog for OpenAI OAuth discovery", async () => {

View File

@@ -47,6 +47,8 @@
{
"id": "gpt-5.3-codex",
"name": "GPT-5.3 Codex",
"api": "openai-chatgpt-responses",
"baseUrl": "https://chatgpt.com/backend-api",
"reasoning": true,
"input": ["text", "image"],
"contextWindow": 400000,

View File

@@ -182,12 +182,6 @@ describe("opencode-go provider plugin", () => {
expect(qwen37Plus.reasoning).toBe(true);
expect(qwen37Plus.contextWindow).toBe(1_000_000);
expect(qwen37Plus.maxTokens).toBe(65_536);
expect(qwen37Plus.cost).toMatchObject({
input: 0.4,
output: 1.6,
cacheRead: 0.04,
cacheWrite: 0.5,
});
const dynamicModel = requireRecord(
provider.resolveDynamicModel?.({

View File

@@ -109,19 +109,6 @@ function makeInboundRuntime(): GatewayPluginRuntime["channel"]["inbound"] {
function makeRuntime(params: {
onFinalize?: (ctx: Record<string, unknown>) => void;
isControlCommandMessage?: (text?: string, cfg?: unknown) => boolean;
skipFreshSettledDelivery?: boolean;
onDispatch?: (dispatcherOptions: {
deliver: (
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
info: { kind: string },
) => Promise<void>;
onSkip?: (
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
info: { kind: string; reason: "empty" | "silent" | "heartbeat" },
) => void;
onSettled?: () => unknown;
onFreshSettledDelivery?: () => unknown;
}) => Promise<void>;
onDeliver?: (
deliver: (
payload: { text?: string; mediaUrl?: string; mediaUrls?: string[]; audioAsVoice?: boolean },
@@ -140,7 +127,7 @@ function makeRuntime(params: {
},
reply: {
dispatchReplyWithBufferedBlockDispatcher: vi.fn(async (rawParams: unknown) => {
const dispatcherOptions = (
const deliver = (
rawParams as {
dispatcherOptions: {
deliver: (
@@ -152,29 +139,10 @@ function makeRuntime(params: {
},
info: { kind: string },
) => Promise<void>;
onSkip?: (
payload: {
text?: string;
mediaUrl?: string;
mediaUrls?: string[];
audioAsVoice?: boolean;
},
info: { kind: string; reason: "empty" | "silent" | "heartbeat" },
) => void;
onSettled?: () => unknown;
onFreshSettledDelivery?: () => unknown;
};
}
).dispatcherOptions;
if (params.onDispatch) {
await params.onDispatch(dispatcherOptions);
} else {
await params.onDeliver?.(dispatcherOptions.deliver);
}
await dispatcherOptions.onSettled?.();
if (!params.skipFreshSettledDelivery) {
await dispatcherOptions.onFreshSettledDelivery?.();
}
).dispatcherOptions.deliver;
await params.onDeliver?.(deliver);
}),
finalizeInboundContext: vi.fn((rawCtx: Record<string, unknown>) => {
params.onFinalize?.(rawCtx);
@@ -408,199 +376,6 @@ describe("dispatchOutbound", () => {
expect(sendMediaMock).not.toHaveBeenCalled();
});
it("flushes buffered tool text when non-streaming final block is silent", async () => {
const runtime = makeRuntime({
onDispatch: async ({ deliver, onSkip }) => {
await deliver({ text: "first visible tool message" }, { kind: "tool" });
await deliver({ text: "second visible tool message" }, { kind: "tool" });
onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
},
});
await dispatchOutbound(
makeInbound({
event: {
type: "group",
senderId: "member-openid",
messageId: "msg-group-tool-final-silent",
content: "<@BOT> do it",
timestamp: "2026-04-25T00:00:00.000Z",
groupOpenid: "group-openid",
},
route: { sessionKey: "qqbot:group:group-openid", accountId: "qq-main" },
isGroupChat: true,
peerId: "group-openid",
qualifiedTarget: "qqbot:group:group-openid",
fromAddress: "qqbot:group:group-openid",
agentBody: "do it",
body: "[member-openid] do it (@you)",
}),
{ runtime, cfg: {}, account: { ...account, config: { streaming: false } } },
);
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual([
"first visible tool message",
"second visible tool message",
]);
expect(sendMediaMock).not.toHaveBeenCalled();
});
it("keeps buffered tool text suppressed when a visible block precedes a silent final skip", async () => {
const runtime = makeRuntime({
onDispatch: async ({ deliver, onSkip }) => {
await deliver({ text: "Working: checking logs" }, { kind: "tool" });
onSkip?.({ text: "NO_REPLY" }, { kind: "final", reason: "silent" });
await deliver({ text: "final answer" }, { kind: "block" });
},
});
await dispatchOutbound(makeInbound(), {
runtime,
cfg: {},
account: { ...account, config: { streaming: false } },
});
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["final answer"]);
expect(sendMediaMock).not.toHaveBeenCalled();
});
it("does not re-send tool fallback after timeout when non-streaming final block is silent", async () => {
vi.useFakeTimers();
const runtime = makeRuntime({
onDispatch: async ({ deliver, onSkip }) => {
await deliver({ text: "visible tool message" }, { kind: "tool" });
await vi.advanceTimersByTimeAsync(60_000);
onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
},
});
await dispatchOutbound(makeInbound(), {
runtime,
cfg: {},
account: { ...account, config: { streaming: false } },
});
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]);
expect(sendMediaMock).not.toHaveBeenCalled();
});
it("waits for fresh settled delivery after a skipped silent block", async () => {
vi.useFakeTimers();
const runtime = makeRuntime({
onDispatch: async ({ deliver, onSkip }) => {
await deliver({ text: "visible tool message" }, { kind: "tool" });
onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
await vi.advanceTimersByTimeAsync(60_000);
expect(sendTextMock).not.toHaveBeenCalled();
},
});
await dispatchOutbound(makeInbound(), {
runtime,
cfg: {},
account: { ...account, config: { streaming: false } },
});
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]);
expect(sendMediaMock).not.toHaveBeenCalled();
});
it("does not send stale tool fallback when fresh settled delivery is suppressed", async () => {
vi.useFakeTimers();
const runtime = makeRuntime({
skipFreshSettledDelivery: true,
onDispatch: async ({ deliver, onSkip }) => {
await deliver({ text: "stale visible tool message" }, { kind: "tool" });
onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
},
});
await dispatchOutbound(makeInbound(), {
runtime,
cfg: {},
account: { ...account, config: { streaming: false } },
});
expect(sendTextMock).not.toHaveBeenCalled();
expect(sendMediaMock).not.toHaveBeenCalled();
expect(vi.getTimerCount()).toBe(0);
});
it("bounds tool media flushes without racing the fallback timer", async () => {
vi.useFakeTimers();
sendMediaMock.mockImplementationOnce(() => new Promise(() => {}));
sendMediaMock.mockImplementationOnce(() => new Promise(() => {}));
const firstMediaUrl = "https://example.com/progress-1.png";
const secondMediaUrl = "https://example.com/progress-2.png";
const runtime = makeRuntime({
onDispatch: async ({ deliver, onSkip }) => {
await deliver({ mediaUrl: firstMediaUrl }, { kind: "tool" });
await deliver({ mediaUrl: secondMediaUrl }, { kind: "tool" });
await deliver({ text: "visible tool message" }, { kind: "tool" });
onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
},
});
const dispatchPromise = dispatchOutbound(makeInbound(), {
runtime,
cfg: {},
account: { ...account, config: { streaming: false } },
});
await vi.advanceTimersByTimeAsync(90_000);
await dispatchPromise;
expect(sendMediaMock).toHaveBeenCalledTimes(2);
expect(sendTextMock.mock.calls.map((call) => call[1])).toEqual(["visible tool message"]);
});
it("clears the media timeout after a successful silent-final flush", async () => {
vi.useFakeTimers();
const mediaUrl = "https://example.com/progress.png";
const runtime = makeRuntime({
onDispatch: async ({ deliver, onSkip }) => {
await deliver({ mediaUrl }, { kind: "tool" });
onSkip?.({ text: "NO_REPLY" }, { kind: "block", reason: "silent" });
},
});
await dispatchOutbound(makeInbound(), {
runtime,
cfg: {},
account: { ...account, config: { streaming: false } },
});
expect(sendMediaMock).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it.each([
{ name: "empty text", payload: {} },
{ name: "silent token", payload: { text: "NO_REPLY" } },
])("delivers media-only non-streaming final block replies with $name", async ({ payload }) => {
const mediaUrl = "https://example.com/final.png";
const runtime = makeRuntime({
onDeliver: async (deliver) => {
await deliver({ ...payload, mediaUrl }, { kind: "block" });
},
});
await dispatchOutbound(makeInbound(), {
runtime,
cfg: {},
account: { ...account, config: { streaming: false } },
});
expect(sendTextMock).not.toHaveBeenCalled();
expect(sendMediaMock).toHaveBeenCalledWith({
creds: { appId: "app", clientSecret: "secret" },
kind: "image",
msgId: "msg-1",
source: { url: mediaUrl },
target: { id: "user-openid", type: "c2c" },
});
});
it("renews pending tool-media fallback when partial progress is delivered", async () => {
vi.useFakeTimers();
const mediaUrl = "https://example.com/progress.png";

View File

@@ -11,7 +11,6 @@
*/
import { buildChannelInboundEventContext } from "openclaw/plugin-sdk/channel-inbound";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "openclaw/plugin-sdk/reply-chunking";
import type { FinalizedMsgContext } from "openclaw/plugin-sdk/reply-runtime";
import {
parseAndSendMediaTags,
@@ -98,23 +97,6 @@ function immediateToolProgressText(payload: ReplyDeliverPayload): string | undef
return text;
}
function hasReplyMedia(payload: ReplyDeliverPayload): boolean {
return Boolean(payload.mediaUrl || payload.mediaUrls?.length);
}
function isSilentBlockReplyText(text: string): boolean {
return !text || text === "[SKIP]" || isSilentReplyPayloadText(text, SILENT_REPLY_TOKEN);
}
function blockReplyTextForDelivery(payload: ReplyDeliverPayload): string {
const text = payload.text ?? "";
return isSilentBlockReplyText(text.trim()) ? "" : text;
}
function isSilentBlockReply(payload: ReplyDeliverPayload): boolean {
return !hasReplyMedia(payload) && isSilentBlockReplyText((payload.text ?? "").trim());
}
// ============ dispatchOutbound ============
/**
@@ -151,77 +133,47 @@ export async function dispatchOutbound(
// ---- Deliver state ----
let hasResponse = false;
let hasBlockResponse = false;
let hasVisibleBlockResponse = false;
let toolDeliverCount = 0;
const toolTexts: string[] = [];
const toolMediaUrls: string[] = [];
let toolFallbackSent = false;
let toolRenewalCount = 0;
let skippedSilentBlockResponse = false;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let toolOnlyTimeoutId: ReturnType<typeof setTimeout> | null = null;
const markBlockResponse = (): void => {
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
};
// ---- Tool fallback ----
const sendToolMediaWithTimeout = async (
mediaUrl: string,
labels: { resultError: string; thrownError: string },
): Promise<void> => {
const ac = new AbortController();
let mediaTimeoutId: ReturnType<typeof setTimeout> | null = null;
try {
const result = await Promise.race([
sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
}).then((r) => {
if (ac.signal.aborted) {
return { channel: "qqbot", error: "suppressed" } as OutboundResult;
}
return r;
}),
new Promise<OutboundResult>((resolve) => {
mediaTimeoutId = setTimeout(() => {
ac.abort();
resolve({ channel: "qqbot", error: "timeout" });
}, TOOL_MEDIA_SEND_TIMEOUT);
}),
]);
if (result.error) {
log?.error(`${labels.resultError}: ${result.error}`);
}
} catch (err) {
log?.error(`${labels.thrownError}: ${String(err)}`);
} finally {
if (mediaTimeoutId) {
clearTimeout(mediaTimeoutId);
}
}
};
const sendToolFallback = async (): Promise<void> => {
if (toolMediaUrls.length > 0) {
for (const mediaUrl of toolMediaUrls) {
await sendToolMediaWithTimeout(mediaUrl, {
resultError: "Tool fallback error",
thrownError: "Tool fallback failed",
});
const ac = new AbortController();
try {
const result = await Promise.race([
sendMedia({
to: qualifiedTarget,
text: "",
mediaUrl,
accountId: account.accountId,
replyToId: event.messageId,
account,
}).then((r) => {
if (ac.signal.aborted) {
return { channel: "qqbot", error: "suppressed" } as OutboundResult;
}
return r;
}),
new Promise<OutboundResult>((resolve) => {
setTimeout(() => {
ac.abort();
resolve({ channel: "qqbot", error: "timeout" });
}, TOOL_MEDIA_SEND_TIMEOUT);
}),
]);
if (result.error) {
log?.error(`Tool fallback error: ${result.error}`);
}
} catch (err) {
log?.error(`Tool fallback failed: ${String(err)}`);
}
}
return;
}
@@ -233,16 +185,6 @@ export async function dispatchOutbound(
const hasPendingToolFallbackPayload = (): boolean =>
toolTexts.length > 0 || toolMediaUrls.length > 0;
const flushPendingToolDeliveriesOnce = async (): Promise<boolean> => {
if (toolFallbackSent || !hasPendingToolFallbackPayload()) {
return false;
}
await flushPendingToolDeliveries();
toolFallbackSent = true;
recordOutbound();
return true;
};
const renewToolOnlyFallback = (): boolean => {
if (toolFallbackSent) {
return false;
@@ -255,7 +197,7 @@ export async function dispatchOutbound(
toolRenewalCount++;
}
toolOnlyTimeoutId = setTimeout(() => {
if (!hasBlockResponse && !toolFallbackSent && !skippedSilentBlockResponse) {
if (!hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
void sendToolFallback().catch(() => {});
}
@@ -296,41 +238,6 @@ export async function dispatchOutbound(
},
};
const flushPendingToolDeliveries = async (): Promise<void> => {
if (toolMediaUrls.length > 0) {
const urlsToSend = [...toolMediaUrls];
toolMediaUrls.length = 0;
for (const mediaUrl of urlsToSend) {
await sendToolMediaWithTimeout(mediaUrl, {
resultError: "Tool media forward error",
thrownError: "Tool media forward failed",
});
}
}
if (toolTexts.length > 0) {
const textsToSend = [...toolTexts];
toolTexts.length = 0;
for (const text of textsToSend) {
await sendTextOnlyReply(
text,
{
type: event.type,
senderId: event.senderId,
messageId: event.messageId,
channelId: event.channelId,
groupOpenid: event.groupOpenid,
msgIdx: event.msgIdx,
},
{ account, qualifiedTarget, log },
sendWithRetry,
() => undefined,
deliverDeps,
);
}
}
};
const recordOutbound = () =>
runtime.channel.activity.record({
channel: "qqbot",
@@ -480,17 +387,16 @@ export async function dispatchOutbound(
}
// ---- Block deliver ----
markBlockResponse();
if (!streamingController && isSilentBlockReply(payload)) {
if (!(await flushPendingToolDeliveriesOnce()) && event.type === "group") {
log?.info(
`Model decided to skip group message (${(payload.text ?? "").trim() || "empty reply"}) from ${event.senderId}`,
);
}
return;
hasBlockResponse = true;
inbound.typing.keepAlive?.stop();
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
hasVisibleBlockResponse = true;
if (streamingController && !streamingController.isTerminalPhase) {
try {
@@ -530,7 +436,7 @@ export async function dispatchOutbound(
return undefined;
};
let replyText = blockReplyTextForDelivery(payload);
let replyText = payload.text ?? "";
const deliverEvent = {
type: event.type,
senderId: event.senderId,
@@ -614,27 +520,6 @@ export async function dispatchOutbound(
timeoutId = null;
}
},
onSkip: (
_payload: ReplyDeliverPayload,
info: { kind: string; reason: "empty" | "silent" | "heartbeat" },
) => {
if (
!streamingController &&
(info.kind === "block" || info.kind === "final") &&
(info.reason === "silent" || info.reason === "empty")
) {
skippedSilentBlockResponse = true;
}
},
onFreshSettledDelivery: async () => {
if (skippedSilentBlockResponse && !hasVisibleBlockResponse) {
markBlockResponse();
if (await flushPendingToolDeliveriesOnce()) {
return { visibleReplySent: true };
}
}
return undefined;
},
},
replyOptions: {
disableBlockStreaming: useOfficialC2cStream
@@ -670,23 +555,13 @@ export async function dispatchOutbound(
} catch {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
} finally {
if (timeoutId) {
clearTimeout(timeoutId);
timeoutId = null;
}
if (toolOnlyTimeoutId) {
clearTimeout(toolOnlyTimeoutId);
toolOnlyTimeoutId = null;
}
if (
toolDeliverCount > 0 &&
!hasBlockResponse &&
!toolFallbackSent &&
!skippedSilentBlockResponse
) {
if (toolDeliverCount > 0 && !hasBlockResponse && !toolFallbackSent) {
toolFallbackSent = true;
await sendToolFallback();
}

View File

@@ -1028,10 +1028,6 @@ export const dispatchTelegramMessage = async ({
});
let finalAnswerDeliveryStarted = false;
let finalAnswerDelivered = false;
// While the durable verbose lane is active, the ephemeral draft yields its
// commentary lines so they render once. Tool/plan status lines keep the
// draft: they have no durable counterpart in streamed runs.
let verboseProgressActive: () => boolean = () => false;
const pushStreamToolProgress = async (
line?: string | ChannelProgressDraftLine,
options?: { toolName?: string; startImmediately?: boolean },
@@ -2039,18 +2035,6 @@ export const dispatchTelegramMessage = async ({
reasoningStepState.noteReasoningHint();
}
if (segment.lane === "answer" && info.kind === "tool") {
if (verboseProgressActive()) {
// Durable lane owns tool payloads: send standalone instead
// of diverting into the draft, which is discarded at final.
if (
await sendPayload(
applyTextToPayload(effectivePayload, segment.update.text),
)
) {
blockDelivered = true;
}
continue;
}
const canRepresentAsTransientProgress = canUseNativeToolProgressDraft({
payload: effectivePayload,
reply,
@@ -2341,9 +2325,6 @@ export const dispatchTelegramMessage = async ({
!streamDeliveryEnabled || Boolean(answerLane.stream),
allowProgressCallbacksWhenSourceDeliverySuppressed:
!isRoomEvent && Boolean(answerLane.stream),
onVerboseProgressVisibility: (isActive) => {
verboseProgressActive = isActive;
},
commentaryProgressEnabled:
streamMode === "progress" ? progressDraft.commentaryProgressEnabled : undefined,
onToolStart: async (payload) => {
@@ -2368,9 +2349,6 @@ export const dispatchTelegramMessage = async ({
},
onItemEvent: async (payload) => {
if (payload.kind === "preamble") {
if (verboseProgressActive()) {
return;
}
await progressDraft.pushCommentaryProgress(payload.progressText, {
itemId: payload.itemId,
});

View File

@@ -20,10 +20,7 @@ export function createXSearchToolDefinition(
description:
"Search X (formerly Twitter) using xAI, including targeted post or thread lookups. For per-post stats like reposts, replies, bookmarks, or views, prefer the exact post URL or status ID.",
parameters: Type.Object({
query: Type.String({
description:
"Natural-language instruction sent to the Grok X-search agent. Must be meaningful and non-empty.",
}),
query: Type.String({ description: "X search query string." }),
allowed_x_handles: Type.Optional(
Type.Array(Type.String({ minLength: 1 }), {
description: "Only include posts from these X handles.",

View File

@@ -73,34 +73,6 @@ afterEach(() => {
});
describe("xai x_search tool", () => {
it("describes query as the required instruction for the Grok X-search agent", () => {
const tool = createXSearchTool({
config: {
plugins: {
entries: {
xai: {
config: {
webSearch: {
apiKey: "xai-plugin-key", // pragma: allowlist secret
},
},
},
},
},
},
});
const parameters = tool?.parameters as
| { properties?: { query?: { description?: string } } }
| undefined;
const queryDescription = parameters?.properties?.query?.description;
expect(queryDescription).toContain("Natural-language instruction");
expect(queryDescription).toContain("Grok X-search agent");
expect(queryDescription).toContain("meaningful and non-empty");
expect(queryDescription).not.toContain("allowed_x_handles");
});
it("enables x_search when runtime config carries the shared xAI key", () => {
const tool = createXSearchTool({
config: {},

View File

@@ -106,27 +106,4 @@ describe("exec approvals protocol validators", () => {
}),
).toBe(false);
});
it("accepts only optional unavailable approval decisions", () => {
expect(
validateExecApprovalRequestParams({
command: "echo hi",
unavailableDecisions: ["allow-always"],
}),
).toBe(true);
for (const unavailableDecisions of [
[],
["allow-always", "allow-always"],
["allow-once"],
["deny"],
]) {
expect(
validateExecApprovalRequestParams({
command: "echo hi",
unavailableDecisions,
}),
).toBe(false);
}
});
});

Some files were not shown because too many files have changed in this diff Show More