mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
3 Commits
jesse/cand
...
fix/memory
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc15253f59 | ||
|
|
3bc770664c | ||
|
|
57c8d487eb |
@@ -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
|
||||
|
||||
99
.github/workflows/openclaw-release-publish.yml
vendored
99
.github/workflows/openclaw-release-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -775,7 +775,6 @@ extension GatewayConnection {
|
||||
struct CronSchedulerStatus: Decodable {
|
||||
let enabled: Bool
|
||||
let storePath: String
|
||||
let sqlitePath: String?
|
||||
let jobs: Int
|
||||
let nextWakeAtMs: Int?
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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)">
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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];
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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" }],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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-");
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 ?? ""}`;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?.({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user