mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 12:56:01 -06:00
Merge remote-tracking branch 'origin/main' into fix/control-ui-session-dedupe
* origin/main: (25 commits) fix(qqbot): use Gateway timezone for reminders (#116294) test(openai): cover out-of-order named mark acks docs(openai): clarify cumulative playback marks test(openai): cover sustained unnamed mark acknowledgements fix(openai): preserve playback acknowledgement order test(openai): verify bounded playback acknowledgements fix(openai): bound realtime playback marks test(agents): mock incremental registry persistence (#116307) fix(memory-core): MEMORY.md compaction deletes user notes written under a promotion-style heading (#116180) feat(reply): annotate recent history images (#100866) chore(ios): refresh native i18n inventory fix(ios): isolate capability router handlers docs(ios): cut 2026.7.22 release notes docs(hooks): document validation responses test(qa): assert hook account rejection reasons fix(gateway): validate hook delivery accounts perf(gateway): bound prepared runtime startup work (#116261) fix(agents): prevent registry stalls during large fan-outs (#116286) fix(sqlite): preserve replaced snapshot targets on Windows (#116284) fix(ci): load frozen shard runner without repository fetch (#116273) ...
This commit is contained in:
+31
-27
@@ -2060,11 +2060,24 @@ jobs:
|
||||
echo "detected cores=$cores plan_concurrency=${SHARD_PLAN_CONCURRENCY:-default} -> workers=$workers"
|
||||
echo "OPENCLAW_VITEST_MAX_WORKERS=$workers" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Checkout trusted Node shard runner
|
||||
# Frozen release targets can predate the workflow-owned shard runner.
|
||||
# Keep its implementation pinned to this workflow revision, while tests
|
||||
# continue to run against the checked-out candidate.
|
||||
if: ${{ hashFiles('scripts/ci-run-node-test-shard.mjs') == '' }}
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
with:
|
||||
ref: ${{ github.workflow_sha }}
|
||||
path: .ci-workflow
|
||||
sparse-checkout: |
|
||||
scripts/ci-run-node-test-shard.mjs
|
||||
scripts/lib/direct-run.mjs
|
||||
scripts/lib/local-heavy-check-runtime.mjs
|
||||
sparse-checkout-cone-mode: false
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run Node test shard
|
||||
# actionlint 1.7.11 lacks GitHub's current job.workflow_* fields. Serialize the
|
||||
# non-secret job context, then validate the defining workflow identity below.
|
||||
env:
|
||||
JOB_CONTEXT_JSON: ${{ toJSON(job) }}
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
|
||||
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
|
||||
@@ -2084,30 +2097,8 @@ jobs:
|
||||
set -euo pipefail
|
||||
runner="scripts/ci-run-node-test-shard.mjs"
|
||||
if [[ ! -f "$runner" ]]; then
|
||||
# Frozen release targets can predate the workflow-owned shard runner.
|
||||
# Load only that runner from the exact trusted workflow commit while
|
||||
# keeping its child tests rooted in the checked-out candidate.
|
||||
job_workflow_repository=$(jq -r '.workflow_repository // empty' <<<"$JOB_CONTEXT_JSON")
|
||||
job_workflow_sha=$(jq -r '.workflow_sha // empty' <<<"$JOB_CONTEXT_JSON")
|
||||
if [[ ! "$job_workflow_repository" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
|
||||
echo "invalid job workflow repository: $job_workflow_repository" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$job_workflow_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "invalid job workflow SHA: $job_workflow_sha" >&2
|
||||
exit 1
|
||||
fi
|
||||
harness_root="${RUNNER_TEMP}/openclaw-ci-shard-runner"
|
||||
workflow_remote="https://github.com/${job_workflow_repository}.git"
|
||||
timeout --signal=TERM --kill-after=10s 120s git fetch --no-tags --depth=1 "$workflow_remote" "$job_workflow_sha"
|
||||
for file in \
|
||||
scripts/ci-run-node-test-shard.mjs \
|
||||
scripts/lib/direct-run.mjs \
|
||||
scripts/lib/local-heavy-check-runtime.mjs; do
|
||||
mkdir -p "${harness_root}/$(dirname "$file")"
|
||||
git show "${job_workflow_sha}:${file}" > "${harness_root}/${file}"
|
||||
done
|
||||
runner="${harness_root}/${runner}"
|
||||
runner=".ci-workflow/${runner}"
|
||||
[[ -f "$runner" ]]
|
||||
fi
|
||||
node "$runner"
|
||||
|
||||
@@ -3295,6 +3286,19 @@ jobs:
|
||||
- name: Build iOS app
|
||||
run: pnpm ios:build
|
||||
|
||||
# Debug's incremental compilation can miss actor-isolation diagnostics
|
||||
# that Swift's optimized whole-module Release build enforces.
|
||||
- name: Build iOS app (Release)
|
||||
if: env.HISTORICAL_TARGET != 'true'
|
||||
run: |
|
||||
xcodebuild \
|
||||
-project apps/ios/OpenClaw.xcodeproj \
|
||||
-scheme OpenClaw \
|
||||
-configuration Release \
|
||||
-destination "generic/platform=iOS" \
|
||||
CODE_SIGNING_ALLOWED=NO \
|
||||
build
|
||||
|
||||
# App compilation and screenshots do not execute approval or notification
|
||||
# lifecycles. Exercise both owners on the already provisioned simulator.
|
||||
- name: Run focused iOS lifecycle simulator tests
|
||||
|
||||
@@ -185,6 +185,17 @@ jobs:
|
||||
id: preflight_cache_key
|
||||
run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# setup-node-env may restore a broader build-all snapshot. Clear every
|
||||
# preflight cache output first so actions/cache cannot overlay stale or
|
||||
# private-QA artifacts that are absent from this release build.
|
||||
- name: Clean preflight build outputs before cache restore
|
||||
run: |
|
||||
set -euo pipefail
|
||||
rm -rf -- dist dist-runtime packages/*/dist
|
||||
find extensions -type f -path '*/src/host/*' \
|
||||
\( -name '.bundle.hash' -o -name '*.bundle.js' \) \
|
||||
-delete
|
||||
|
||||
- name: Restore preflight build outputs
|
||||
id: dist_build_cache
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
|
||||
@@ -25947,7 +25947,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4174,
|
||||
"line": 4175,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval needed",
|
||||
"surface": "apple",
|
||||
@@ -25955,7 +25955,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4175,
|
||||
"line": 4176,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Action required",
|
||||
"surface": "apple",
|
||||
@@ -25963,7 +25963,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4890,
|
||||
"line": 4891,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting...",
|
||||
"surface": "apple",
|
||||
@@ -25971,7 +25971,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "ui-localized-call",
|
||||
"line": 4891,
|
||||
"line": 4892,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting...",
|
||||
"surface": "apple",
|
||||
@@ -25979,7 +25979,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 5246,
|
||||
"line": 5247,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connected",
|
||||
"surface": "apple",
|
||||
@@ -25987,7 +25987,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 5246,
|
||||
"line": 5247,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Offline",
|
||||
"surface": "apple",
|
||||
@@ -25995,7 +25995,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 6416,
|
||||
"line": 6417,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "No chat messages yet",
|
||||
"surface": "apple",
|
||||
@@ -26003,7 +26003,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 6478,
|
||||
"line": 6479,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Connecting…",
|
||||
"surface": "apple",
|
||||
@@ -26011,7 +26011,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 6478,
|
||||
"line": 6479,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Reconnecting…",
|
||||
"surface": "apple",
|
||||
@@ -26019,7 +26019,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 8837,
|
||||
"line": 8838,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval",
|
||||
"surface": "apple",
|
||||
@@ -26027,7 +26027,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 8837,
|
||||
"line": 8838,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "This approval was already",
|
||||
"surface": "apple",
|
||||
@@ -26035,7 +26035,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 8843,
|
||||
"line": 8844,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "This approval was already set to Always Allow.",
|
||||
"surface": "apple",
|
||||
@@ -26043,7 +26043,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 8844,
|
||||
"line": 8845,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "Approval set to Always Allow.",
|
||||
"surface": "apple",
|
||||
@@ -26051,7 +26051,7 @@
|
||||
},
|
||||
{
|
||||
"kind": "conditional-branch",
|
||||
"line": 10214,
|
||||
"line": 10215,
|
||||
"path": "apps/ios/Sources/Model/NodeAppModel.swift",
|
||||
"source": "\\(urlText.prefix(500))…",
|
||||
"surface": "apple",
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
## 2026.7.22
|
||||
|
||||
- Prevented stale Watch reconnect recovery from disconnecting a newly selected Gateway, kept delivered Watch messages from reappearing after a crash, and preserved attachments when retrying uncertain offline sends.
|
||||
|
||||
## 2026.7.21
|
||||
|
||||
@@ -8,7 +8,7 @@ final class NodeCapabilityRouter {
|
||||
case handlerUnavailable
|
||||
}
|
||||
|
||||
typealias Handler = (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
|
||||
typealias Handler = @MainActor @Sendable (BridgeInvokeRequest) async throws -> BridgeInvokeResponse
|
||||
|
||||
private let handlers: [String: Handler]
|
||||
|
||||
|
||||
@@ -3267,7 +3267,8 @@ extension NodeAppModel {
|
||||
|
||||
func register(
|
||||
_ commands: [String],
|
||||
handler: @escaping (NodeAppModel, BridgeInvokeRequest) async throws -> BridgeInvokeResponse)
|
||||
handler: @escaping @MainActor @Sendable (NodeAppModel, BridgeInvokeRequest) async throws
|
||||
-> BridgeInvokeResponse)
|
||||
{
|
||||
let invoke: NodeCapabilityRouter.Handler = { [weak self] request in
|
||||
guard let self else { throw NodeCapabilityRouter.RouterError.handlerUnavailable }
|
||||
|
||||
@@ -36,8 +36,15 @@ final class NotifyOverlayController {
|
||||
|
||||
if autoDismissAfter > 0 {
|
||||
self.dismissTask = Task { [weak self] in
|
||||
try? await Task.sleep(nanoseconds: UInt64(autoDismissAfter * 1_000_000_000))
|
||||
await MainActor.run { self?.dismiss() }
|
||||
do {
|
||||
try await Task.sleep(nanoseconds: UInt64(autoDismissAfter * 1_000_000_000))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await MainActor.run {
|
||||
guard !Task.isCancelled else { return }
|
||||
self?.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,12 +52,15 @@ struct LowCoverageViewSmokeTests {
|
||||
AgentEventStore.shared.clear()
|
||||
}
|
||||
|
||||
@Test func `notify overlay presents and dismisses`() async {
|
||||
@Test func `notify overlay keeps replacement visible`() async {
|
||||
let controller = NotifyOverlayController()
|
||||
controller.present(title: "Hello", body: "World", autoDismissAfter: 0)
|
||||
controller.present(title: "Hello", body: "World", autoDismissAfter: 0.05)
|
||||
controller.present(title: "Updated", body: "Again", autoDismissAfter: 0)
|
||||
controller.dismiss()
|
||||
try? await Task.sleep(nanoseconds: 250_000_000)
|
||||
#expect(controller.model.isVisible)
|
||||
#expect(controller.model.title == "Updated")
|
||||
|
||||
controller.dismiss()
|
||||
}
|
||||
|
||||
@Test func `talk overlay presents twice and dismisses`() async {
|
||||
|
||||
@@ -34,8 +34,8 @@ d645d24bcb7a5f68cc46c692ad0d1fbd19be0a99996f31e9479ce9cffce301c1 module/channel
|
||||
4a7ada095f0f483525dcbd848fbccab26473749eba87e6a6c6e5074fd04ee1d1 module/channel-ingress-runtime
|
||||
c97dd36cdf8f83c2893c33e9430a93cd131a03d855725783ca5b545de0cf84f8 module/channel-lifecycle
|
||||
159d034b431d113f3a6dc41ec0bcadba2d6664051f158330b0e3dd3da8b5d42f module/channel-logging
|
||||
34868a4532173efb2ae17426bdedb618c56914d0919ba439d5b7c5a7d085fc9d module/channel-message
|
||||
d80682f483c876dbfdcec023f9a78381de340bef0aae36e0360389d8552e0db5 module/channel-outbound
|
||||
83297cb5672b5923ad7b288a27c29e265e71584e9a4828a6fa3641d847ce8179 module/channel-message
|
||||
cb95167f43ad2ebc272ee675df416c2870fd3ff1876a48c5dbeadb5a186422d1 module/channel-outbound
|
||||
930beff13ed42a138f65164013c82f4f4c96422292c5fc634a5edee1dce71367 module/channel-pairing
|
||||
ee4292b069d4d48cce4fc2dc26df5b5c87eb1fa4769f1f6be9a10c3e1221e1a9 module/channel-plugin-common
|
||||
94ef57c8f6087fcaa56e59e493c391a04377ed03f23c681edd8d8f6e2d64e0da module/channel-policy
|
||||
@@ -46,7 +46,7 @@ bba5540be7cf9613a163663decdb2affe2af9bbd3ad7914989ab186f9c2abec1 module/channel
|
||||
7c90157a95bc0523fc66b1f78ce140f7ec7dbf809dfa3a480244efc01f972754 module/channel-send-result
|
||||
fb123c1b557ed2527e335f13c3d6de41ab0c3305151001cf2b1075d1da8034c5 module/channel-setup
|
||||
8de651a14a46014dc86fb8ccaff819e0854cf6bb2e3b59e642f1e55b37ef43c2 module/channel-status
|
||||
8269386e8f25e090c7837677b447582cfe301eb7debfb6ac8a4fc83b599ff13f module/channel-streaming
|
||||
d904e33114f056022ef9fec184edcfe37c30b539b268e28b0ff485f0a02b9c59 module/channel-streaming
|
||||
14f0103adb14627b662fbe9fdd5ed08596ed702a250853e8beb8bb8dd1361588 module/cli-argv
|
||||
c89ec1b194b76f67a6f4dd108dccf460da6065646cba31374c8aa748f23a39e4 module/collection-runtime
|
||||
391e6f0c77da2e17a058fc5aea87fd193830b2a14e12fae77c7f63b0226f0bb5 module/command-auth
|
||||
|
||||
@@ -545,17 +545,18 @@ Query-string tokens are rejected.
|
||||
- Announce delivery requires a concrete channel; webhook hooks never inherit the main session's `last` channel or recipient.
|
||||
- Setting `deliver: false` keeps the run completion-only and ignores any delivery destination.
|
||||
- Supplying both a concrete `channel` and `to` enables direct announce delivery.
|
||||
- Set `accountId` with `channel` and `to` to select a configured account on multi-account channels.
|
||||
- Set `accountId` with `channel` and `to` to select a configured, enabled account on multi-account channels. Unknown, disabled, or invalid account IDs return `400` and schedule no run.
|
||||
|
||||
The HTTP response waits only for runner admission, not for the agent turn to finish. A `200` may take up to 15 seconds and means the run entered its agent runner. Pre-run failures return `{ ok: false, error, runId }` with:
|
||||
|
||||
- `400` when delivery coordinates or account selection are invalid; correct the request before retrying.
|
||||
- `409` when the target session changed or otherwise rejects new work; retry after resolving the session conflict.
|
||||
- `502` when Gateway or cron preparation fails before runner entry.
|
||||
- `503` when runner admission does not complete within 15 seconds. Timed-out queued work is canceled and does not start later.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Mapped hooks (POST /hooks/<name>)">
|
||||
Custom hook names resolve via `hooks.mappings` in config. Mappings can transform arbitrary payloads into `wake` or `agent` actions with templates or code transforms. Mapped `agent` actions use the same 15-second admission and `200`/`409`/`502`/`503` response contract as `POST /hooks/agent`.
|
||||
Custom hook names resolve via `hooks.mappings` in config. Mappings can transform arbitrary payloads into `wake` or `agent` actions with templates or code transforms. Mapped `agent` actions use the same 15-second admission and `200`/`400`/`409`/`502`/`503` response contract as `POST /hooks/agent`.
|
||||
|
||||
Persistent mapped hooks require a stable mapping `sessionKey` or `hooks.defaultSessionKey`. Template-derived keys retain the request-key opt-in and prefix policy above.
|
||||
|
||||
|
||||
@@ -934,13 +934,13 @@ Validation and safety notes:
|
||||
- `sessionKey` from request payload is accepted only when `hooks.allowRequestSessionKey=true` (default: `false`).
|
||||
- `sessionMode` is `"isolated"` by default. `"persistent"` reuses the resolved session and requires an explicit request `sessionKey`, `hooks.allowRequestSessionKey=true`, and non-empty `hooks.allowedSessionKeyPrefixes`.
|
||||
- Direct announce delivery requires both a concrete `channel` and `to`; supplying only one fails before the run is scheduled.
|
||||
- `accountId` selects a configured account for direct announce delivery and requires both `channel` and `to`.
|
||||
- `accountId` selects a configured, enabled account for direct announce delivery and requires both `channel` and `to`; invalid selections return `400` before a run starts.
|
||||
- Omit both delivery fields for completion-only hooks, or set `deliver: false` to ignore supplied destination data.
|
||||
- The request waits up to 15 seconds for runner admission, not run completion. `200` means the agent runner was entered.
|
||||
- Pre-run failures return `{ ok: false, error, runId }`: `409` for session admission conflicts, `502` for other preparation failures, and `503` when the 15-second admission deadline expires. Timed-out queued work is canceled and will not start later.
|
||||
- Pre-run failures return `{ ok: false, error, runId }`: `400` for invalid delivery coordinates or account selection, `409` for session admission conflicts, `502` for other preparation failures, and `503` when the 15-second admission deadline expires. Timed-out queued work is canceled and will not start later.
|
||||
- `POST /hooks/<name>` → resolved via `hooks.mappings`
|
||||
- Template-rendered mapping `sessionKey` values are treated as externally supplied and also require `hooks.allowRequestSessionKey=true`.
|
||||
- Mapped `agent` actions use the same admission wait and `200`/`409`/`502`/`503` outcomes.
|
||||
- Mapped `agent` actions use the same admission wait and `200`/`400`/`409`/`502`/`503` outcomes.
|
||||
|
||||
<Accordion title="Mapping details">
|
||||
|
||||
|
||||
@@ -230,6 +230,20 @@ describe("createMatrixDraftStream", () => {
|
||||
expect(stream.eventId()).toBe("$evt1");
|
||||
});
|
||||
|
||||
it("tracks the provider-visible prepared draft content", async () => {
|
||||
convertMarkdownTablesMock.mockImplementation((text: string) => `prepared:${text}`);
|
||||
const stream = createMatrixDraftStream({
|
||||
roomId: "!room:test",
|
||||
client,
|
||||
cfg: {} as import("../types.js").CoreConfig,
|
||||
});
|
||||
|
||||
stream.update("raw table");
|
||||
await stream.flush();
|
||||
|
||||
expect(stream.content()).toBe("prepared:raw table");
|
||||
});
|
||||
|
||||
it("sends quiet preview notices when quiet mode is enabled", async () => {
|
||||
const stream = createMatrixDraftStream({
|
||||
roomId: "!room:test",
|
||||
|
||||
@@ -41,6 +41,8 @@ type MatrixDraftStream = {
|
||||
reset: () => void;
|
||||
/** The event ID of the current draft message, if any. */
|
||||
eventId: () => string | undefined;
|
||||
/** The last content accepted for the current draft event, if any. */
|
||||
content: () => string | undefined;
|
||||
/** True when the provided text matches the last rendered draft payload. */
|
||||
matchesPreparedText: (text: string) => boolean;
|
||||
/** True when preview streaming must fall back to normal final delivery. */
|
||||
@@ -68,6 +70,7 @@ export function createMatrixDraftStream(params: {
|
||||
|
||||
let currentEventId: string | undefined;
|
||||
let lastSentText = "";
|
||||
let lastSentContent = "";
|
||||
let stopped = false;
|
||||
let sendFailed = false;
|
||||
let finalizeInPlaceBlocked = false;
|
||||
@@ -111,6 +114,7 @@ export function createMatrixDraftStream(params: {
|
||||
});
|
||||
currentEventId = result.messageId;
|
||||
lastSentText = preparedText.trimmedText;
|
||||
lastSentContent = preparedText.convertedText;
|
||||
log?.(`draft-stream: created message ${currentEventId}${useLive ? " (MSC4357 live)" : ""}`);
|
||||
} else {
|
||||
await editMessageMatrix(roomId, currentEventId, preparedText.trimmedText, {
|
||||
@@ -123,6 +127,7 @@ export function createMatrixDraftStream(params: {
|
||||
live: useLive,
|
||||
});
|
||||
lastSentText = preparedText.trimmedText;
|
||||
lastSentContent = preparedText.convertedText;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
@@ -198,6 +203,7 @@ export function createMatrixDraftStream(params: {
|
||||
replyToId = params.preserveReplyId ? params.replyToId : undefined;
|
||||
currentEventId = undefined;
|
||||
lastSentText = "";
|
||||
lastSentContent = "";
|
||||
stopped = false;
|
||||
sendFailed = false;
|
||||
finalizeInPlaceBlocked = false;
|
||||
@@ -219,6 +225,7 @@ export function createMatrixDraftStream(params: {
|
||||
finalizeLive,
|
||||
reset,
|
||||
eventId: () => currentEventId,
|
||||
content: () => lastSentContent || undefined,
|
||||
matchesPreparedText: (text: string) =>
|
||||
prepareMatrixSingleText(text, {
|
||||
cfg,
|
||||
|
||||
@@ -19,7 +19,12 @@ import {
|
||||
redactMatrixDraftEvent,
|
||||
type MatrixDraftStreamHandle,
|
||||
} from "./handler-runtime.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
import {
|
||||
deliverMatrixReplies,
|
||||
mergeMatrixReplyDeliveryResults,
|
||||
toMatrixPartialDeliveryError,
|
||||
type MatrixReplyDeliveryResult,
|
||||
} from "./replies.js";
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
createTypingCallbacks,
|
||||
@@ -81,6 +86,54 @@ export function createMatrixReplyDispatcher(config: {
|
||||
...prefixOptions,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload, info: { kind: string }) => {
|
||||
const completeDelivery = async (
|
||||
result: MatrixReplyDeliveryResult,
|
||||
): Promise<MatrixReplyDeliveryResult> => {
|
||||
if (info.kind === "block") {
|
||||
draftController.clearDraftConsumed();
|
||||
draftController.advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
|
||||
draftStream?.reset();
|
||||
draftController.resetReplyToIdForNextBlock();
|
||||
draftController.updateDraftFromLatestFullText();
|
||||
|
||||
// Re-assert typing so the user still sees the indicator while
|
||||
// the next block generates.
|
||||
const { sendTypingMatrix } = await loadMatrixSendModule();
|
||||
await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const createDraftReceipt = (id: string): MessageReceipt =>
|
||||
createPreviewMessageReceipt({
|
||||
id,
|
||||
...(threadTarget ? { threadId: threadTarget } : {}),
|
||||
...(draftController.currentReplyToId()
|
||||
? { replyToId: draftController.currentReplyToId() }
|
||||
: {}),
|
||||
});
|
||||
const createDraftDeliveryResult = (
|
||||
id: string,
|
||||
content: string,
|
||||
): MatrixReplyDeliveryResult => {
|
||||
const receipt = createDraftReceipt(id);
|
||||
return {
|
||||
messageIds: receipt.platformMessageIds,
|
||||
receipt,
|
||||
visibleReplySent: true,
|
||||
content,
|
||||
};
|
||||
};
|
||||
const createSurvivingDraftDelivery = (
|
||||
id: string,
|
||||
redacted: boolean,
|
||||
): MatrixReplyDeliveryResult => {
|
||||
const content = redacted ? undefined : draftStream?.content();
|
||||
return content
|
||||
? // Failed redaction leaves an accepted provider event visible. Preserve it so
|
||||
// settlement and retries cannot mistake a partial delivery for total failure.
|
||||
createDraftDeliveryResult(id, content)
|
||||
: mergeMatrixReplyDeliveryResults([]);
|
||||
};
|
||||
if (draftStream && info.kind !== "tool" && !payload.isCompactionNotice) {
|
||||
const hasMedia = Boolean(payload.mediaUrl) || (payload.mediaUrls?.length ?? 0) > 0;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(payload);
|
||||
@@ -93,22 +146,23 @@ export function createMatrixReplyDispatcher(config: {
|
||||
|
||||
if (draftController.isDraftConsumed()) {
|
||||
await draftStream.discardPending();
|
||||
await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [fallbackPayload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
hasRepliedRef,
|
||||
threadId: threadTarget,
|
||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
return;
|
||||
return await completeDelivery(
|
||||
await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [fallbackPayload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
hasRepliedRef,
|
||||
threadId: threadTarget,
|
||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const payloadReplyToId = normalizeOptionalString(payload.replyToId);
|
||||
@@ -149,7 +203,15 @@ export function createMatrixReplyDispatcher(config: {
|
||||
!draftFinalTextNeedsNormalMentionDelivery
|
||||
) {
|
||||
const finalPreviewText = payload.text;
|
||||
await deliverWithFinalizableLivePreviewAdapter<
|
||||
const { prepareMatrixSingleText } = await loadMatrixSendModule();
|
||||
const preparedFinalPreviewContent = prepareMatrixSingleText(finalPreviewText, {
|
||||
cfg,
|
||||
accountId,
|
||||
preserveWhitespace: true,
|
||||
}).convertedText;
|
||||
let finalizedDraftContent = draftStream.content() ?? preparedFinalPreviewContent;
|
||||
let fallbackResult: MatrixReplyDeliveryResult | undefined;
|
||||
const previewResult = await deliverWithFinalizableLivePreviewAdapter<
|
||||
ReplyPayload,
|
||||
string,
|
||||
{
|
||||
@@ -181,6 +243,7 @@ export function createMatrixReplyDispatcher(config: {
|
||||
if (!(await draftStream.finalizeLive())) {
|
||||
throw new Error("Matrix draft live finalize failed");
|
||||
}
|
||||
finalizedDraftContent = draftStream.content() ?? preparedFinalPreviewContent;
|
||||
return;
|
||||
}
|
||||
const { editMessageMatrix } = await loadMatrixSendModule();
|
||||
@@ -191,42 +254,65 @@ export function createMatrixReplyDispatcher(config: {
|
||||
accountId,
|
||||
extraContent: edit.extraContent,
|
||||
});
|
||||
finalizedDraftContent = prepareMatrixSingleText(edit.text, {
|
||||
cfg,
|
||||
accountId,
|
||||
preserveWhitespace: true,
|
||||
}).convertedText;
|
||||
},
|
||||
createPreviewReceipt: (id): MessageReceipt =>
|
||||
createPreviewMessageReceipt({
|
||||
id,
|
||||
...(threadTarget ? { threadId: threadTarget } : {}),
|
||||
...(draftController.currentReplyToId()
|
||||
? { replyToId: draftController.currentReplyToId() }
|
||||
: {}),
|
||||
}),
|
||||
createPreviewReceipt: createDraftReceipt,
|
||||
logPreviewEditFailure: (err) => {
|
||||
logVerboseMessage(`matrix: preview final edit failed: ${String(err)}`);
|
||||
},
|
||||
}),
|
||||
deliverNormally: async () => {
|
||||
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||
await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [fallbackPayload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
hasRepliedRef,
|
||||
threadId: threadTarget,
|
||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
const draftRedacted = await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||
const survivingDraft = createSurvivingDraftDelivery(draftEventId, draftRedacted);
|
||||
let deliveredFallback: MatrixReplyDeliveryResult;
|
||||
try {
|
||||
deliveredFallback = await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [fallbackPayload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
hasRepliedRef,
|
||||
threadId: threadTarget,
|
||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw toMatrixPartialDeliveryError(error, [survivingDraft]);
|
||||
}
|
||||
fallbackResult = mergeMatrixReplyDeliveryResults([survivingDraft, deliveredFallback]);
|
||||
return fallbackResult.visibleReplySent;
|
||||
},
|
||||
});
|
||||
draftController.markDraftConsumed();
|
||||
const settledResult =
|
||||
previewResult.kind === "preview-finalized" && previewResult.liveState?.receipt
|
||||
? createDraftDeliveryResult(
|
||||
draftEventId,
|
||||
finalizedDraftContent ?? preparedFinalPreviewContent,
|
||||
)
|
||||
: (fallbackResult ?? mergeMatrixReplyDeliveryResults([]));
|
||||
return await completeDelivery(settledResult);
|
||||
} else if (draftEventId && hasMedia && !payloadReplyMismatch) {
|
||||
let textEditOk = !mustDeliverFinalNormally;
|
||||
const payloadText = payload.text ?? ttsSupplement?.spokenText;
|
||||
const preparedPayloadContent =
|
||||
typeof payloadText === "string"
|
||||
? (await loadMatrixSendModule()).prepareMatrixSingleText(payloadText, {
|
||||
cfg,
|
||||
accountId,
|
||||
preserveWhitespace: true,
|
||||
}).convertedText
|
||||
: undefined;
|
||||
let finalizedDraftContent = draftStream.content() ?? preparedPayloadContent;
|
||||
const payloadTextMatchesDraft =
|
||||
typeof payloadText === "string" && draftStream.matchesPreparedText(payloadText);
|
||||
const reusesDraftTextUnchanged =
|
||||
@@ -242,7 +328,7 @@ export function createMatrixReplyDispatcher(config: {
|
||||
if (textEditOk && mediaTextNeedsNormalMentionDelivery) {
|
||||
textEditOk = false;
|
||||
} else if (textEditOk && payloadText && requiresFinalTextEdit) {
|
||||
const { editMessageMatrix } = await loadMatrixSendModule();
|
||||
const { editMessageMatrix, prepareMatrixSingleText } = await loadMatrixSendModule();
|
||||
textEditOk = await editMessageMatrix(roomId, draftEventId, payloadText, {
|
||||
client,
|
||||
cfg,
|
||||
@@ -250,16 +336,25 @@ export function createMatrixReplyDispatcher(config: {
|
||||
accountId,
|
||||
extraContent: quietDraftStreaming ? buildMatrixFinalizedPreviewContent() : undefined,
|
||||
}).then(
|
||||
() => true,
|
||||
() => {
|
||||
finalizedDraftContent = prepareMatrixSingleText(payloadText, {
|
||||
cfg,
|
||||
accountId,
|
||||
preserveWhitespace: true,
|
||||
}).convertedText;
|
||||
return true;
|
||||
},
|
||||
() => false,
|
||||
);
|
||||
} else if (textEditOk && reusesDraftTextUnchanged) {
|
||||
textEditOk = await draftStream.finalizeLive();
|
||||
finalizedDraftContent = draftStream.content();
|
||||
}
|
||||
const reusesDraftAsFinalText = Boolean(payloadText?.trim()) && textEditOk;
|
||||
if (!reusesDraftAsFinalText) {
|
||||
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||
}
|
||||
const draftContent = draftStream.content();
|
||||
const draftRedacted = reusesDraftAsFinalText
|
||||
? false
|
||||
: await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||
const mediaPayload =
|
||||
ttsSupplement && reusesDraftAsFinalText
|
||||
? buildTtsSupplementMediaPayload(payload)
|
||||
@@ -272,33 +367,55 @@ export function createMatrixReplyDispatcher(config: {
|
||||
? undefined
|
||||
: ttsSupplement?.spokenText)),
|
||||
};
|
||||
await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [mediaPayload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
hasRepliedRef,
|
||||
threadId: threadTarget,
|
||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
draftController.markDraftConsumed();
|
||||
} else {
|
||||
const draftRedacted =
|
||||
Boolean(draftEventId) &&
|
||||
(payload.isError ||
|
||||
payloadReplyMismatch ||
|
||||
mustDeliverFinalNormally ||
|
||||
draftFinalTextNeedsNormalMentionDelivery);
|
||||
if (draftRedacted && draftEventId) {
|
||||
await redactMatrixDraftEvent(client, roomId, draftEventId);
|
||||
const providerDraftContent = finalizedDraftContent ?? preparedPayloadContent;
|
||||
const previewDelivery =
|
||||
reusesDraftAsFinalText && providerDraftContent
|
||||
? createDraftDeliveryResult(draftEventId, providerDraftContent)
|
||||
: !draftRedacted && draftContent
|
||||
? createDraftDeliveryResult(draftEventId, draftContent)
|
||||
: mergeMatrixReplyDeliveryResults([]);
|
||||
let mediaDelivery: MatrixReplyDeliveryResult;
|
||||
try {
|
||||
mediaDelivery = await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [mediaPayload],
|
||||
roomId,
|
||||
client,
|
||||
runtime,
|
||||
textLimit,
|
||||
replyToMode,
|
||||
hasRepliedRef,
|
||||
threadId: threadTarget,
|
||||
replyToId: threadTarget ?? replyToEventId ?? undefined,
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
} catch (error: unknown) {
|
||||
throw toMatrixPartialDeliveryError(error, [previewDelivery]);
|
||||
}
|
||||
const deliveredFallback = await deliverMatrixReplies({
|
||||
draftController.markDraftConsumed();
|
||||
return await completeDelivery(
|
||||
mergeMatrixReplyDeliveryResults([previewDelivery, mediaDelivery]),
|
||||
);
|
||||
}
|
||||
const shouldRedactDraft =
|
||||
Boolean(draftEventId) &&
|
||||
(payload.isError ||
|
||||
payloadReplyMismatch ||
|
||||
mustDeliverFinalNormally ||
|
||||
draftFinalTextNeedsNormalMentionDelivery);
|
||||
const draftRedacted =
|
||||
shouldRedactDraft && draftEventId
|
||||
? await redactMatrixDraftEvent(client, roomId, draftEventId)
|
||||
: false;
|
||||
const survivingDraft =
|
||||
shouldRedactDraft && draftEventId
|
||||
? createSurvivingDraftDelivery(draftEventId, draftRedacted)
|
||||
: mergeMatrixReplyDeliveryResults([]);
|
||||
let deliveredFallback: MatrixReplyDeliveryResult;
|
||||
try {
|
||||
deliveredFallback = await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [fallbackPayload],
|
||||
roomId,
|
||||
@@ -313,24 +430,17 @@ export function createMatrixReplyDispatcher(config: {
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
if (draftRedacted || deliveredFallback) {
|
||||
draftController.markDraftConsumed();
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
throw toMatrixPartialDeliveryError(error, [survivingDraft]);
|
||||
}
|
||||
|
||||
if (info.kind === "block") {
|
||||
draftController.clearDraftConsumed();
|
||||
draftController.advanceDraftBlockBoundary({ fallbackToLatestEnd: true });
|
||||
draftStream.reset();
|
||||
draftController.resetReplyToIdForNextBlock();
|
||||
draftController.updateDraftFromLatestFullText();
|
||||
|
||||
// Re-assert typing so the user still sees the indicator while
|
||||
// the next block generates.
|
||||
const { sendTypingMatrix } = await loadMatrixSendModule();
|
||||
await sendTypingMatrix(roomId, true, undefined, client).catch(() => {});
|
||||
if (shouldRedactDraft || deliveredFallback.visibleReplySent) {
|
||||
draftController.markDraftConsumed();
|
||||
}
|
||||
} else {
|
||||
return await completeDelivery(
|
||||
mergeMatrixReplyDeliveryResults([survivingDraft, deliveredFallback]),
|
||||
);
|
||||
}
|
||||
return await completeDelivery(
|
||||
await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [payload],
|
||||
@@ -345,8 +455,8 @@ export function createMatrixReplyDispatcher(config: {
|
||||
accountId,
|
||||
mediaLocalRoots,
|
||||
tableMode,
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
},
|
||||
onError: (err: unknown, info: { kind: "tool" | "block" | "final" }) => {
|
||||
if (info.kind === "final") {
|
||||
|
||||
@@ -7,6 +7,7 @@ export type MatrixDraftStreamHandle = {
|
||||
stop: () => Promise<string | undefined>;
|
||||
discardPending: () => Promise<void>;
|
||||
eventId: () => string | undefined;
|
||||
content: () => string | undefined;
|
||||
mustDeliverFinalNormally: () => boolean;
|
||||
matchesPreparedText: (text: string) => boolean;
|
||||
finalizeLive: () => Promise<boolean>;
|
||||
@@ -17,8 +18,11 @@ export async function redactMatrixDraftEvent(
|
||||
client: MatrixClient,
|
||||
roomId: string,
|
||||
draftEventId: string,
|
||||
): Promise<void> {
|
||||
await client.redactEvent(roomId, draftEventId).catch(() => {});
|
||||
): Promise<boolean> {
|
||||
return await client.redactEvent(roomId, draftEventId).then(
|
||||
() => true,
|
||||
() => false,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildMatrixFinalizedPreviewContent(): Record<string, unknown> {
|
||||
|
||||
@@ -78,9 +78,10 @@ vi.mock("../send.js", () => ({
|
||||
sendTypingMatrix: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const deliverMatrixRepliesMock = vi.hoisted(() => vi.fn(async () => true));
|
||||
const deliverMatrixRepliesMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./replies.js", () => ({
|
||||
vi.mock("./replies.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("./replies.js")>()),
|
||||
deliverMatrixReplies: deliverMatrixRepliesMock,
|
||||
}));
|
||||
|
||||
@@ -140,6 +141,7 @@ beforeEach(() => {
|
||||
};
|
||||
});
|
||||
resolveMatrixMentionsForBodyMock.mockClear();
|
||||
deliverMatrixRepliesMock.mockReset().mockResolvedValue(createMockMatrixDeliveryResult());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -290,6 +292,20 @@ function expectDeliveredMediaReply() {
|
||||
expect(reply.text).toBeUndefined();
|
||||
}
|
||||
|
||||
function createMockMatrixDeliveryResult(messageId = "$reply1", content = "delivered") {
|
||||
return {
|
||||
messageIds: [messageId],
|
||||
receipt: {
|
||||
primaryPlatformMessageId: messageId,
|
||||
platformMessageIds: [messageId],
|
||||
parts: [{ platformMessageId: messageId, kind: "text" as const, index: 0 }],
|
||||
sentAt: 1,
|
||||
},
|
||||
visibleReplySent: true,
|
||||
content,
|
||||
};
|
||||
}
|
||||
|
||||
describe("matrix monitor handler pairing account scope", () => {
|
||||
it("keeps inbound log previews UTF-16 well-formed at the limit", async () => {
|
||||
const logVerboseMessage = vi.fn();
|
||||
@@ -2837,7 +2853,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
replyToId?: string;
|
||||
},
|
||||
info: { kind: string },
|
||||
) => Promise<void>;
|
||||
) => Promise<unknown>;
|
||||
type ReplyOpts = {
|
||||
onReplyStart?: () => Promise<void> | void;
|
||||
onPartialReply?: (payload: { text: string }) => void;
|
||||
@@ -2928,7 +2944,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
.mockReset()
|
||||
.mockResolvedValue({ messageId: "$draft1", roomId: "!room" });
|
||||
editMessageMatrixMock.mockReset().mockResolvedValue("$edited");
|
||||
deliverMatrixRepliesMock.mockReset().mockResolvedValue(true);
|
||||
deliverMatrixRepliesMock.mockReset().mockResolvedValue(createMockMatrixDeliveryResult());
|
||||
|
||||
const redactEventMock = vi.fn(async () => "$redacted");
|
||||
|
||||
@@ -3015,12 +3031,69 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
});
|
||||
|
||||
deliverMatrixRepliesMock.mockClear();
|
||||
await deliver({ text: "Single block" }, { kind: "final" });
|
||||
const result = await deliver({ text: "Single block" }, { kind: "final" });
|
||||
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
expectFinalizedPreviewEdit("$draft1", "Single block");
|
||||
expect(deliverMatrixRepliesMock).not.toHaveBeenCalled();
|
||||
expect(redactEventMock).not.toHaveBeenCalled();
|
||||
expect(result).toMatchObject({
|
||||
messageIds: ["$draft1"],
|
||||
visibleReplySent: true,
|
||||
content: "Single block",
|
||||
receipt: { primaryPlatformMessageId: "$draft1" },
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("settles finalized previews with provider-prepared content", async () => {
|
||||
prepareMatrixSingleTextMock.mockImplementation((text: string) => ({
|
||||
trimmedText: text.trim(),
|
||||
convertedText: `prepared:${text.trim()}`,
|
||||
singleEventLimit: 4000,
|
||||
fitsInSingleEvent: true,
|
||||
}));
|
||||
const { dispatch } = createStreamingHarness({ streaming: "quiet" });
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
opts.onPartialReply?.({ text: "Raw preview" });
|
||||
await waitForMatrixState(() => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const result = await deliver({ text: "Raw final" }, { kind: "final" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
messageIds: ["$draft1"],
|
||||
content: "prepared:Raw final",
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("settles reused media previews with provider-prepared content", async () => {
|
||||
prepareMatrixSingleTextMock.mockImplementation((text: string) => ({
|
||||
trimmedText: text.trim(),
|
||||
convertedText: `prepared:${text.trim()}`,
|
||||
singleEventLimit: 4000,
|
||||
fitsInSingleEvent: true,
|
||||
}));
|
||||
const { dispatch } = createStreamingHarness({ streaming: "partial" });
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
opts.onPartialReply?.({ text: "Raw caption" });
|
||||
await waitForMatrixState(() => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
const result = await deliver(
|
||||
{ text: "Raw caption", mediaUrl: "https://example.com/image.png" },
|
||||
{ kind: "final" },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
messageIds: ["$draft1", "$reply1"],
|
||||
content: "prepared:Raw caption\ndelivered",
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
@@ -3520,7 +3593,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
await deliver(
|
||||
const result = await deliver(
|
||||
{
|
||||
mediaUrl: "https://example.com/tts.mp3",
|
||||
audioAsVoice: true,
|
||||
@@ -3546,6 +3619,47 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
ttsSupplement: { spokenText: "Spoken answer" },
|
||||
},
|
||||
]);
|
||||
expect(result).toMatchObject({
|
||||
messageIds: ["$draft1", "$reply1"],
|
||||
visibleReplySent: true,
|
||||
content: "Spoken answer\ndelivered",
|
||||
receipt: { primaryPlatformMessageId: "$draft1" },
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("preserves a finalized draft receipt when the following media send fails", async () => {
|
||||
const { dispatch } = createStreamingHarness({
|
||||
blockStreamingEnabled: true,
|
||||
streaming: "partial",
|
||||
});
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
opts.onPartialReply?.({ text: "Spoken answer" });
|
||||
await waitForMatrixState(() => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
deliverMatrixRepliesMock.mockRejectedValueOnce(new Error("media send failed"));
|
||||
|
||||
const error = await deliver(
|
||||
{
|
||||
mediaUrl: "https://example.com/tts.mp3",
|
||||
audioAsVoice: true,
|
||||
spokenText: "Spoken answer",
|
||||
ttsSupplement: { spokenText: "Spoken answer" },
|
||||
},
|
||||
{ kind: "final" },
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "CHANNEL_PARTIAL_DELIVERY",
|
||||
deliveryResult: {
|
||||
messageIds: ["$draft1"],
|
||||
visibleReplySent: true,
|
||||
content: "Spoken answer",
|
||||
receipt: { primaryPlatformMessageId: "$draft1" },
|
||||
},
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
@@ -3591,6 +3705,89 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("preserves a surviving draft receipt when redaction and media delivery fail", async () => {
|
||||
const { dispatch, redactEventMock } = createStreamingHarness({ streaming: "partial" });
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
opts.onPartialReply?.({ text: "Visible preview" });
|
||||
await waitForMatrixState(() => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
redactEventMock.mockRejectedValueOnce(new Error("redaction failed"));
|
||||
deliverMatrixRepliesMock.mockRejectedValueOnce(new Error("media send failed"));
|
||||
const error = await deliver(
|
||||
{ mediaUrl: "https://example.com/image.png" },
|
||||
{ kind: "final" },
|
||||
).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "CHANNEL_PARTIAL_DELIVERY",
|
||||
deliveryResult: {
|
||||
messageIds: ["$draft1"],
|
||||
visibleReplySent: true,
|
||||
content: "Visible preview",
|
||||
receipt: { primaryPlatformMessageId: "$draft1" },
|
||||
},
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("preserves a surviving draft receipt when final-edit fallback also fails", async () => {
|
||||
const { dispatch, redactEventMock } = createStreamingHarness({ streaming: "partial" });
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
opts.onPartialReply?.({ text: "Visible preview" });
|
||||
await waitForMatrixState(() => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
editMessageMatrixMock.mockRejectedValueOnce(new Error("final edit failed"));
|
||||
redactEventMock.mockRejectedValueOnce(new Error("redaction failed"));
|
||||
deliverMatrixRepliesMock.mockRejectedValueOnce(new Error("fallback send failed"));
|
||||
const error = await deliver({ text: "Final text" }, { kind: "final" }).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "CHANNEL_PARTIAL_DELIVERY",
|
||||
deliveryResult: {
|
||||
messageIds: ["$draft1"],
|
||||
visibleReplySent: true,
|
||||
content: "Visible preview",
|
||||
receipt: { primaryPlatformMessageId: "$draft1" },
|
||||
},
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("preserves a surviving draft receipt when generic fallback delivery fails", async () => {
|
||||
const { dispatch, redactEventMock } = createStreamingHarness({ streaming: "partial" });
|
||||
const { deliver, opts, finish } = await dispatch();
|
||||
|
||||
opts.onPartialReply?.({ text: "Visible preview" });
|
||||
await waitForMatrixState(() => {
|
||||
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
redactEventMock.mockRejectedValueOnce(new Error("redaction failed"));
|
||||
deliverMatrixRepliesMock.mockRejectedValueOnce(new Error("fallback send failed"));
|
||||
const error = await deliver({ text: "Something failed", isError: true } as never, {
|
||||
kind: "final",
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "CHANNEL_PARTIAL_DELIVERY",
|
||||
deliveryResult: {
|
||||
messageIds: ["$draft1"],
|
||||
visibleReplySent: true,
|
||||
content: "Visible preview",
|
||||
receipt: { primaryPlatformMessageId: "$draft1" },
|
||||
},
|
||||
});
|
||||
await finish();
|
||||
});
|
||||
|
||||
it("falls back with visible text when TTS supplement preview has no event id", async () => {
|
||||
const { dispatch, redactEventMock } = createStreamingHarness({
|
||||
blockStreamingEnabled: true,
|
||||
@@ -4073,7 +4270,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
.mockReset()
|
||||
.mockResolvedValue({ messageId: "$draft1", roomId: "!room" });
|
||||
editMessageMatrixMock.mockReset().mockResolvedValue("$edited");
|
||||
deliverMatrixRepliesMock.mockReset().mockResolvedValue(true);
|
||||
deliverMatrixRepliesMock.mockReset().mockResolvedValue(createMockMatrixDeliveryResult());
|
||||
const redactEventMock = vi.fn(async () => "$redacted");
|
||||
|
||||
let capturedReplyOpts: ReplyOpts | undefined;
|
||||
@@ -4122,7 +4319,7 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
.mockReset()
|
||||
.mockResolvedValue({ messageId: "$draft1", roomId: "!room" });
|
||||
editMessageMatrixMock.mockReset().mockResolvedValue("$edited");
|
||||
deliverMatrixRepliesMock.mockReset().mockResolvedValue(true);
|
||||
deliverMatrixRepliesMock.mockReset().mockResolvedValue(createMockMatrixDeliveryResult());
|
||||
|
||||
const redactEventMock = vi.fn(async () => "$redacted");
|
||||
let capturedReplyOpts: ReplyOpts | undefined;
|
||||
@@ -4164,7 +4361,10 @@ describe("matrix monitor handler draft streaming", () => {
|
||||
});
|
||||
|
||||
deliverMatrixRepliesMock.mockClear();
|
||||
deliverMatrixRepliesMock.mockResolvedValue(false);
|
||||
deliverMatrixRepliesMock.mockResolvedValue({
|
||||
visibleReplySent: false,
|
||||
suppression: { reason: "no_visible_result" },
|
||||
});
|
||||
await deliver({}, { kind: "final" });
|
||||
|
||||
expect(deliverMatrixRepliesMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -520,6 +520,7 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
|
||||
}
|
||||
},
|
||||
delivery: {
|
||||
observeMessageSent: true,
|
||||
deliver: deliverReply,
|
||||
onError: (err, info) => onReplyError(err, info as Parameters<typeof onReplyError>[1]),
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime, RuntimeEnv } from "../../../runtime-api.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
|
||||
const sendMessageMatrixMock = vi.hoisted(() => vi.fn().mockResolvedValue({ messageId: "mx-1" }));
|
||||
const sendMessageMatrixMock = vi.hoisted(() => vi.fn());
|
||||
const chunkMatrixTextMock = vi.hoisted(() =>
|
||||
vi.fn((text: string, _opts?: unknown) => ({
|
||||
trimmedText: text.trim(),
|
||||
@@ -23,6 +23,32 @@ vi.mock("../send.js", () => ({
|
||||
import { setMatrixRuntime } from "../../runtime.js";
|
||||
import { deliverMatrixReplies } from "./replies.js";
|
||||
|
||||
let nextMessageId = 0;
|
||||
|
||||
async function resolveMockMatrixSend(_to: string, message: string, opts?: Record<string, unknown>) {
|
||||
nextMessageId += 1;
|
||||
const messageId = `mx-${nextMessageId}`;
|
||||
const mediaUrl = typeof opts?.mediaUrl === "string" ? opts.mediaUrl : "unknown";
|
||||
const content = message || `media:${mediaUrl}`;
|
||||
const result = {
|
||||
messageId,
|
||||
roomId: "room:1",
|
||||
primaryMessageId: messageId,
|
||||
receipt: {
|
||||
primaryPlatformMessageId: messageId,
|
||||
platformMessageIds: [messageId],
|
||||
parts: [{ platformMessageId: messageId, kind: "text" as const, index: 0 }],
|
||||
sentAt: 1,
|
||||
},
|
||||
content,
|
||||
};
|
||||
const onDeliveryResult = opts?.onDeliveryResult;
|
||||
if (typeof onDeliveryResult === "function") {
|
||||
await onDeliveryResult(result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function sendCall(index: number) {
|
||||
const call = sendMessageMatrixMock.mock.calls.at(index);
|
||||
if (!call) {
|
||||
@@ -74,6 +100,8 @@ describe("deliverMatrixReplies", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
nextMessageId = 0;
|
||||
sendMessageMatrixMock.mockReset().mockImplementation(resolveMockMatrixSend);
|
||||
setMatrixRuntime(runtimeStub);
|
||||
chunkMatrixTextMock.mockReset().mockImplementation((text: string) => ({
|
||||
trimmedText: text.trim(),
|
||||
@@ -163,12 +191,100 @@ describe("deliverMatrixReplies", () => {
|
||||
await expect(deliverMatrixReplies(delivery)).rejects.toThrow("Matrix unavailable");
|
||||
expect(hasRepliedRef.value).toBe(false);
|
||||
|
||||
await expect(deliverMatrixReplies(delivery)).resolves.toBe(true);
|
||||
await expect(deliverMatrixReplies(delivery)).resolves.toMatchObject({
|
||||
visibleReplySent: true,
|
||||
});
|
||||
expect(sendOptions(0).replyToId).toBe("reply-1");
|
||||
expect(sendOptions(1).replyToId).toBe("reply-1");
|
||||
expect(hasRepliedRef.value).toBe(true);
|
||||
});
|
||||
|
||||
it("returns ordered provider receipts and visible content for a chunked reply", async () => {
|
||||
chunkMatrixTextMock.mockImplementation((text: string) => ({
|
||||
trimmedText: text.trim(),
|
||||
convertedText: text,
|
||||
singleEventLimit: 4000,
|
||||
fitsInSingleEvent: true,
|
||||
chunks: text.split("|"),
|
||||
}));
|
||||
|
||||
const result = await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [{ text: "first|second" }],
|
||||
roomId: "room:1",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "off",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
messageIds: ["mx-1", "mx-2"],
|
||||
visibleReplySent: true,
|
||||
content: "first\nsecond",
|
||||
});
|
||||
expect(result.receipt?.primaryPlatformMessageId).toBe("mx-1");
|
||||
});
|
||||
|
||||
it("preserves the accepted prefix when a later Matrix event fails", async () => {
|
||||
chunkMatrixTextMock.mockImplementation((text: string) => ({
|
||||
trimmedText: text.trim(),
|
||||
convertedText: text,
|
||||
singleEventLimit: 4000,
|
||||
fitsInSingleEvent: true,
|
||||
chunks: text.split("|"),
|
||||
}));
|
||||
let sendCount = 0;
|
||||
sendMessageMatrixMock.mockImplementation(async (...args: unknown[]) => {
|
||||
sendCount += 1;
|
||||
if (sendCount === 2) {
|
||||
throw new Error("second event failed");
|
||||
}
|
||||
return await resolveMockMatrixSend(
|
||||
String(args[0]),
|
||||
String(args[1]),
|
||||
args[2] as Record<string, unknown> | undefined,
|
||||
);
|
||||
});
|
||||
|
||||
const error = await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [{ text: "first|second", replyToId: "reply-1" }],
|
||||
roomId: "room:1",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "first",
|
||||
}).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "CHANNEL_PARTIAL_DELIVERY",
|
||||
deliveryResult: {
|
||||
messageIds: ["mx-1"],
|
||||
visibleReplySent: true,
|
||||
content: "first",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("returns an explicit non-visible result when every reply is suppressed", async () => {
|
||||
const result = await deliverMatrixReplies({
|
||||
cfg,
|
||||
replies: [{ text: "<think>hidden</think>" }],
|
||||
roomId: "room:1",
|
||||
client: {} as MatrixClient,
|
||||
runtime: runtimeEnv,
|
||||
textLimit: 4000,
|
||||
replyToMode: "off",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
visibleReplySent: false,
|
||||
suppression: { reason: "no_visible_result" },
|
||||
});
|
||||
expect(sendMessageMatrixMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves native thread fallback after the first reply has been consumed", async () => {
|
||||
const hasRepliedRef = { value: true };
|
||||
|
||||
|
||||
@@ -1,11 +1,97 @@
|
||||
// Matrix plugin module implements replies behavior.
|
||||
import {
|
||||
createChannelPartialDeliveryError,
|
||||
isChannelPartialDeliveryError,
|
||||
} from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
listMessageReceiptPlatformIds,
|
||||
type MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { stripReasoningTagsFromText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { getMatrixRuntime } from "../../runtime.js";
|
||||
import type { MatrixClient } from "../sdk.js";
|
||||
import { chunkMatrixText, sendMessageMatrix } from "../send.js";
|
||||
import type { MatrixSendResult } from "../send/types.js";
|
||||
import type { MarkdownTableMode, OpenClawConfig, ReplyPayload, RuntimeEnv } from "./runtime-api.js";
|
||||
|
||||
export type MatrixReplyDeliveryResult = {
|
||||
messageIds?: string[];
|
||||
receipt?: MessageReceipt;
|
||||
visibleReplySent: boolean;
|
||||
content?: string;
|
||||
suppression?: { reason: "no_visible_result" };
|
||||
};
|
||||
|
||||
function joinMatrixVisibleContent(contents: readonly (string | undefined)[]): string {
|
||||
return contents.filter((content): content is string => Boolean(content)).join("\n");
|
||||
}
|
||||
|
||||
export function mergeMatrixReplyDeliveryResults(
|
||||
results: readonly MatrixReplyDeliveryResult[],
|
||||
): MatrixReplyDeliveryResult {
|
||||
const visibleResults = results.filter((result) => result.visibleReplySent);
|
||||
if (visibleResults.length === 0) {
|
||||
return {
|
||||
visibleReplySent: false,
|
||||
suppression: { reason: "no_visible_result" },
|
||||
};
|
||||
}
|
||||
const receiptInputs: Array<{ receipt: MessageReceipt } | { messageId: string }> = [];
|
||||
for (const result of visibleResults) {
|
||||
if (result.receipt) {
|
||||
receiptInputs.push({ receipt: result.receipt });
|
||||
continue;
|
||||
}
|
||||
for (const messageId of result.messageIds ?? []) {
|
||||
receiptInputs.push({ messageId });
|
||||
}
|
||||
}
|
||||
const receipt =
|
||||
receiptInputs.length > 0
|
||||
? createMessageReceiptFromOutboundResults({ results: receiptInputs })
|
||||
: undefined;
|
||||
return {
|
||||
...(receipt ? { messageIds: listMessageReceiptPlatformIds(receipt), receipt } : {}),
|
||||
visibleReplySent: true,
|
||||
content: joinMatrixVisibleContent(visibleResults.map((result) => result.content)),
|
||||
};
|
||||
}
|
||||
|
||||
export function toMatrixPartialDeliveryError(
|
||||
error: unknown,
|
||||
settled: readonly MatrixReplyDeliveryResult[],
|
||||
): unknown {
|
||||
const failedPartial = isChannelPartialDeliveryError(error)
|
||||
? (error.deliveryResult as MatrixReplyDeliveryResult)
|
||||
: undefined;
|
||||
const merged = mergeMatrixReplyDeliveryResults([
|
||||
...settled,
|
||||
...(failedPartial ? [failedPartial] : []),
|
||||
]);
|
||||
return merged.visibleReplySent
|
||||
? createChannelPartialDeliveryError(error, { ...merged, visibleReplySent: true })
|
||||
: error;
|
||||
}
|
||||
|
||||
function createMatrixReplyDeliveryResult(
|
||||
results: readonly MatrixSendResult[],
|
||||
): MatrixReplyDeliveryResult {
|
||||
if (results.length === 0) {
|
||||
return mergeMatrixReplyDeliveryResults([]);
|
||||
}
|
||||
const receipt = createMessageReceiptFromOutboundResults({
|
||||
results: results.map((result) => ({ receipt: result.receipt })),
|
||||
});
|
||||
return {
|
||||
messageIds: listMessageReceiptPlatformIds(receipt),
|
||||
receipt,
|
||||
visibleReplySent: true,
|
||||
content: joinMatrixVisibleContent(results.map((result) => result.content)),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveVisibleMatrixReplyText(text?: string): string | undefined {
|
||||
if (typeof text !== "string") {
|
||||
return undefined;
|
||||
@@ -35,7 +121,7 @@ export async function deliverMatrixReplies(params: {
|
||||
accountId?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
tableMode?: MarkdownTableMode;
|
||||
}): Promise<boolean> {
|
||||
}): Promise<MatrixReplyDeliveryResult> {
|
||||
const core = getMatrixRuntime();
|
||||
const tableMode =
|
||||
params.tableMode ??
|
||||
@@ -50,84 +136,89 @@ export async function deliverMatrixReplies(params: {
|
||||
}
|
||||
};
|
||||
const hasRepliedRef = params.hasRepliedRef ?? { value: false };
|
||||
let deliveredAny = false;
|
||||
for (const reply of params.replies) {
|
||||
const visibleText = resolveVisibleMatrixReplyText(reply.text);
|
||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||
if (reply.isReasoning === true || (!hasMedia && reply.text && visibleText === undefined)) {
|
||||
logVerbose("matrix reply suppressed as reasoning-only");
|
||||
continue;
|
||||
}
|
||||
if (!reply?.text && !hasMedia) {
|
||||
if (reply?.audioAsVoice) {
|
||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||
const acceptedResults: MatrixSendResult[] = [];
|
||||
try {
|
||||
for (const reply of params.replies) {
|
||||
const visibleText = resolveVisibleMatrixReplyText(reply.text);
|
||||
const hasMedia = Boolean(reply?.mediaUrl) || (reply?.mediaUrls?.length ?? 0) > 0;
|
||||
if (reply.isReasoning === true || (!hasMedia && reply.text && visibleText === undefined)) {
|
||||
logVerbose("matrix reply suppressed as reasoning-only");
|
||||
continue;
|
||||
}
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = (reply.replyToId ?? params.replyToId)?.trim();
|
||||
const replyToId = params.threadId
|
||||
? replyToIdRaw
|
||||
: params.replyToMode === "off"
|
||||
? undefined
|
||||
: replyToIdRaw;
|
||||
const rawText = visibleText ?? "";
|
||||
const mediaList = reply.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
|
||||
const shouldIncludeReply = (id?: string) =>
|
||||
Boolean(id) && (params.threadId || params.replyToMode === "all" || !hasRepliedRef.value);
|
||||
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
|
||||
|
||||
if (mediaList.length === 0) {
|
||||
const { chunks } = chunkMatrixText(rawText, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
tableMode,
|
||||
});
|
||||
for (const chunk of chunks) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
if (!reply?.text && !hasMedia) {
|
||||
if (reply?.audioAsVoice) {
|
||||
logVerbose("matrix reply has audioAsVoice without media/text; skipping");
|
||||
continue;
|
||||
}
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
client: params.client,
|
||||
cfg: params.cfg,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
deliveredAny = true;
|
||||
params.runtime.error?.("matrix reply missing text/media");
|
||||
continue;
|
||||
}
|
||||
const replyToIdRaw = (reply.replyToId ?? params.replyToId)?.trim();
|
||||
const replyToId = params.threadId
|
||||
? replyToIdRaw
|
||||
: params.replyToMode === "off"
|
||||
? undefined
|
||||
: replyToIdRaw;
|
||||
const rawText = visibleText ?? "";
|
||||
const mediaList = reply.mediaUrls?.length
|
||||
? reply.mediaUrls
|
||||
: reply.mediaUrl
|
||||
? [reply.mediaUrl]
|
||||
: [];
|
||||
|
||||
const shouldIncludeReply = (id?: string) =>
|
||||
Boolean(id) && (params.threadId || params.replyToMode === "all" || !hasRepliedRef.value);
|
||||
const replyToIdForReply = shouldIncludeReply(replyToId) ? replyToId : undefined;
|
||||
const onDeliveryResult = (result: MatrixSendResult) => {
|
||||
// A concrete event consumes the first-reply slot even when a later event fails.
|
||||
acceptedResults.push(result);
|
||||
if (replyToIdForReply) {
|
||||
hasRepliedRef.value = true;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? rawText : "";
|
||||
await sendMessageMatrix(params.roomId, caption, {
|
||||
client: params.client,
|
||||
cfg: params.cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
audioAsVoice: reply.audioAsVoice,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
deliveredAny = true;
|
||||
if (replyToIdForReply) {
|
||||
hasRepliedRef.value = true;
|
||||
if (mediaList.length === 0) {
|
||||
const { chunks } = chunkMatrixText(rawText, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
tableMode,
|
||||
});
|
||||
for (const chunk of chunks) {
|
||||
const trimmed = chunk.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
await sendMessageMatrix(params.roomId, trimmed, {
|
||||
client: params.client,
|
||||
cfg: params.cfg,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
accountId: params.accountId,
|
||||
onDeliveryResult,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let first = true;
|
||||
for (const mediaUrl of mediaList) {
|
||||
const caption = first ? rawText : "";
|
||||
await sendMessageMatrix(params.roomId, caption, {
|
||||
client: params.client,
|
||||
cfg: params.cfg,
|
||||
mediaUrl,
|
||||
mediaLocalRoots: params.mediaLocalRoots,
|
||||
replyToId: replyToIdForReply,
|
||||
threadId: params.threadId,
|
||||
audioAsVoice: reply.audioAsVoice,
|
||||
accountId: params.accountId,
|
||||
onDeliveryResult,
|
||||
});
|
||||
first = false;
|
||||
}
|
||||
first = false;
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
throw toMatrixPartialDeliveryError(error, [createMatrixReplyDeliveryResult(acceptedResults)]);
|
||||
}
|
||||
return deliveredAny;
|
||||
return createMatrixReplyDeliveryResult(acceptedResults);
|
||||
}
|
||||
|
||||
@@ -870,6 +870,7 @@ describe("sendMessageMatrix threads", () => {
|
||||
expect(result.roomId).toBe("!room:example");
|
||||
expect(result.primaryMessageId).toBe("$m1");
|
||||
expect(result.messageId).toBe("$m3");
|
||||
expect(result.content).toBe("part1\npart2\npart3");
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("$m1");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["$m1", "$m2", "$m3"]);
|
||||
const parts = requireArray(result.receipt.parts, "receipt parts");
|
||||
@@ -897,6 +898,7 @@ describe("sendMessageMatrix threads", () => {
|
||||
).rejects.toThrow("second event failed");
|
||||
|
||||
expect(onDeliveryResult.mock.calls.map((call) => call[0]?.messageId)).toEqual(["$m1"]);
|
||||
expect(onDeliveryResult.mock.calls.map((call) => call[0]?.content)).toEqual(["part1"]);
|
||||
});
|
||||
|
||||
it("merges extra content into only the first chunked text event", async () => {
|
||||
@@ -1066,6 +1068,7 @@ describe("sendSingleTextMessageMatrix", () => {
|
||||
expect(result.receipt.primaryPlatformMessageId).toBe("evt1");
|
||||
expect(result.receipt.platformMessageIds).toEqual(["evt1"]);
|
||||
expectTextReceiptPart(result.receipt.parts[0], "evt1");
|
||||
expect(result.content).toBe("done");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -197,7 +197,9 @@ export async function sendMessageMatrix(
|
||||
const contentWithExtra = withMatrixExtraContentFields(content, pendingExtraContent);
|
||||
pendingExtraContent = undefined;
|
||||
const eventId = await client.sendMessage(roomId, contentWithExtra);
|
||||
const visibleContent = contentWithExtra.body ?? "";
|
||||
if (eventId) {
|
||||
acceptedContents.push(visibleContent);
|
||||
await opts.onDeliveryResult?.({
|
||||
messageId: eventId,
|
||||
roomId,
|
||||
@@ -209,12 +211,14 @@ export async function sendMessageMatrix(
|
||||
replyToId: opts.replyToId,
|
||||
threadId,
|
||||
}),
|
||||
content: visibleContent,
|
||||
});
|
||||
}
|
||||
return eventId;
|
||||
};
|
||||
|
||||
const platformMessageIds: string[] = [];
|
||||
const acceptedContents: string[] = [];
|
||||
let lastMessageId = "";
|
||||
let receiptKind: MessageReceiptPartKind = "text";
|
||||
if (opts.mediaUrl) {
|
||||
@@ -332,6 +336,7 @@ export async function sendMessageMatrix(
|
||||
replyToId: opts.replyToId,
|
||||
threadId,
|
||||
}),
|
||||
content: acceptedContents.join("\n"),
|
||||
};
|
||||
},
|
||||
);
|
||||
@@ -499,6 +504,7 @@ export async function sendSingleTextMessageMatrix(
|
||||
replyToId: opts.replyToId,
|
||||
threadId: normalizedThreadId,
|
||||
}),
|
||||
content: content.body,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
@@ -82,6 +82,8 @@ export type MatrixSendResult = {
|
||||
roomId: string;
|
||||
primaryMessageId?: string;
|
||||
receipt: MessageReceipt;
|
||||
/** Provider-accepted visible bodies in event order for this send operation. */
|
||||
content: string;
|
||||
};
|
||||
|
||||
export type MatrixSendOpts = {
|
||||
|
||||
@@ -2,10 +2,20 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { compactMemoryForBudget, DEFAULT_MEMORY_FILE_MAX_CHARS } from "./memory-budget.js";
|
||||
|
||||
const PROMOTION_MARKER_LINE = "<!-- openclaw-memory-promotion:memory/short-term.md#entry -->";
|
||||
|
||||
function promotionSection(date: string, sizeChars: number): string {
|
||||
const heading = `## Promoted From Short-Term Memory (${date})\n`;
|
||||
const padding = "x".repeat(Math.max(0, sizeChars - heading.length));
|
||||
return `${heading}${padding}`;
|
||||
const marker = `${PROMOTION_MARKER_LINE}\n`;
|
||||
const entryPrefix = "- ";
|
||||
const padding = "x".repeat(
|
||||
Math.max(0, sizeChars - heading.length - marker.length - entryPrefix.length),
|
||||
);
|
||||
return `${heading}${marker}${entryPrefix}${padding}`;
|
||||
}
|
||||
|
||||
function markerFreeSection(date: string, body: string): string {
|
||||
return `## Promoted From Short-Term Memory (${date})\n${body}\n`;
|
||||
}
|
||||
|
||||
function projectGroupedSection(date: string): string {
|
||||
@@ -207,10 +217,10 @@ describe("compactMemoryForBudget — bounded MEMORY.md compaction (regression fo
|
||||
},
|
||||
);
|
||||
|
||||
it("does not treat a four-space-indented hash line as an ATX heading", () => {
|
||||
it("preserves a generated-looking block with ambiguous indented content", () => {
|
||||
const existing =
|
||||
`${promotionSection("2026-04-10", 400)}\n` +
|
||||
" ### Indented code\n keep this inside the generated block\n\n" +
|
||||
" ### Indented user note\n keep this durable content\n\n" +
|
||||
promotionSection("2026-04-20", 400);
|
||||
const newSection = `\n${promotionSection("2026-04-29", 400)}`;
|
||||
const result = compactMemoryForBudget({
|
||||
@@ -218,9 +228,9 @@ describe("compactMemoryForBudget — bounded MEMORY.md compaction (regression fo
|
||||
newSection,
|
||||
budgetChars: 900,
|
||||
});
|
||||
expect(result.droppedDates).toContain("2026-04-10");
|
||||
expect(result.compacted).not.toContain("### Indented code");
|
||||
expect(result.compacted).not.toContain("keep this inside the generated block");
|
||||
expect(result.droppedDates).not.toContain("2026-04-10");
|
||||
expect(result.compacted).toContain("### Indented user note");
|
||||
expect(result.compacted).toContain("keep this durable content");
|
||||
});
|
||||
|
||||
it("preserves a user `#` section written after the only promotion section", () => {
|
||||
@@ -335,6 +345,90 @@ describe("compactMemoryForBudget — bounded MEMORY.md compaction (regression fo
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves a marker-free section whose heading matches the promotion pattern", () => {
|
||||
const existing = `# Long-Term Memory\n\n${markerFreeSection(
|
||||
"2026-04-10",
|
||||
"USER-AUTHORED: recovery key is paper-copy-17",
|
||||
)}`;
|
||||
const newSection = `\n${promotionSection("2026-04-29", 600)}`;
|
||||
const result = compactMemoryForBudget({
|
||||
existingMemory: existing,
|
||||
newSection,
|
||||
budgetChars: 400,
|
||||
});
|
||||
expect(result.droppedDates).toEqual([]);
|
||||
expect(result.compacted).toBe(existing);
|
||||
});
|
||||
|
||||
it("drops only a clean generated section beside a marker-free lookalike", () => {
|
||||
const userSection = markerFreeSection(
|
||||
"2026-04-10",
|
||||
"USER-AUTHORED: recovery key is paper-copy-17",
|
||||
);
|
||||
const existing = `# Long-Term Memory\n\n${userSection}\n${promotionSection("2026-04-20", 600)}`;
|
||||
const result = compactMemoryForBudget({
|
||||
existingMemory: existing,
|
||||
newSection: `\n${promotionSection("2026-04-29", 600)}`,
|
||||
budgetChars: 700,
|
||||
});
|
||||
expect(result.droppedDates).toEqual(["2026-04-20"]);
|
||||
expect(result.compacted).toContain("USER-AUTHORED: recovery key is paper-copy-17");
|
||||
expect(result.compacted).not.toContain("(2026-04-20)");
|
||||
});
|
||||
|
||||
it("preserves a marker-only promotion-shaped block", () => {
|
||||
const existing = [
|
||||
"## Promoted From Short-Term Memory (2026-04-10)",
|
||||
PROMOTION_MARKER_LINE,
|
||||
"",
|
||||
].join("\n");
|
||||
const result = compactMemoryForBudget({
|
||||
existingMemory: existing,
|
||||
newSection: `\n${promotionSection("2026-04-29", 600)}`,
|
||||
budgetChars: 400,
|
||||
});
|
||||
expect(result.droppedDates).toEqual([]);
|
||||
expect(result.compacted).toBe(existing);
|
||||
});
|
||||
|
||||
it("preserves an entire mixed block when user text follows a generated entry", () => {
|
||||
const existing = [
|
||||
promotionSection("2026-04-10", 400),
|
||||
"",
|
||||
"USER-AUTHORED: keep this unheaded durable note.",
|
||||
"",
|
||||
promotionSection("2026-04-20", 400),
|
||||
].join("\n");
|
||||
const result = compactMemoryForBudget({
|
||||
existingMemory: existing,
|
||||
newSection: `\n${promotionSection("2026-04-29", 400)}`,
|
||||
budgetChars: 900,
|
||||
});
|
||||
expect(result.droppedDates).toEqual(["2026-04-20"]);
|
||||
expect(result.compacted).toContain("(2026-04-10)");
|
||||
expect(result.compacted).toContain("USER-AUTHORED: keep this unheaded durable note.");
|
||||
expect(result.compacted).not.toContain("(2026-04-20)");
|
||||
});
|
||||
|
||||
it("preserves a block with user text between generated entries", () => {
|
||||
const existing = [
|
||||
"## Promoted From Short-Term Memory (2026-04-10)",
|
||||
PROMOTION_MARKER_LINE,
|
||||
"- first generated entry",
|
||||
"USER-AUTHORED: this line is not owned by either marker.",
|
||||
"<!-- openclaw-memory-promotion:memory/short-term.md#second -->",
|
||||
"- second generated entry",
|
||||
"",
|
||||
].join("\n");
|
||||
const result = compactMemoryForBudget({
|
||||
existingMemory: existing,
|
||||
newSection: `\n${promotionSection("2026-04-29", 600)}`,
|
||||
budgetChars: 400,
|
||||
});
|
||||
expect(result.droppedDates).toEqual([]);
|
||||
expect(result.compacted).toBe(existing);
|
||||
});
|
||||
|
||||
it("exposes a sane default budget below the bootstrap injection cap", () => {
|
||||
// Bootstrap injection is capped at 12_000 chars per file (see
|
||||
// src/agents/embedded-agent-helpers/bootstrap.ts). The MEMORY.md budget
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
* See issue #73691.
|
||||
*
|
||||
* Strategy: drop the OLDEST auto-promoted sections (date-ordered) until
|
||||
* the file plus the new section fit within the budget. User-authored
|
||||
* content (anything that is not a `## Promoted From Short-Term Memory
|
||||
* (DATE)` section) is preserved unconditionally — only dreaming-owned
|
||||
* sections are eligible for compaction.
|
||||
* the file plus the new section fit within the budget. A section counts as
|
||||
* dreaming-owned only when its complete body matches the marker + entry
|
||||
* structure emitted by `buildPromotionSection`. Ambiguous or mixed content
|
||||
* is preserved unconditionally.
|
||||
*/
|
||||
|
||||
const PROMOTION_SECTION_HEADING_RE = /^## Promoted From Short-Term Memory \(([^)]+)\)\s*$/;
|
||||
@@ -46,6 +46,39 @@ type MemoryBlock =
|
||||
| { kind: "preserved"; text: string }
|
||||
| { kind: "promotion"; date: string; text: string };
|
||||
|
||||
function isGeneratedPromotionBlock(lines: string[]): boolean {
|
||||
let sawEntry = false;
|
||||
let index = 1;
|
||||
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? "";
|
||||
if (line.trim().length === 0) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (PROMOTION_SUBSECTION_HEADING_RE.test(line)) {
|
||||
index += 1;
|
||||
while (index < lines.length && (lines[index] ?? "").trim().length === 0) {
|
||||
index += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (!PROMOTION_ENTRY_MARKER_RE.test(lines[index] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
// A marker owns only the single bullet emitted with it. Treat any other
|
||||
// body shape as mixed user content so compaction cannot delete it.
|
||||
if (!(lines[index + 1] ?? "").startsWith("- ")) {
|
||||
return false;
|
||||
}
|
||||
sawEntry = true;
|
||||
index += 2;
|
||||
}
|
||||
|
||||
return sawEntry;
|
||||
}
|
||||
|
||||
function startsGeneratedPromotionSubsection(lines: string[], index: number): boolean {
|
||||
if (!PROMOTION_SUBSECTION_HEADING_RE.test(lines[index] ?? "")) {
|
||||
return false;
|
||||
@@ -91,7 +124,7 @@ function parseMemoryBlocks(content: string): MemoryBlock[] {
|
||||
return;
|
||||
}
|
||||
const text = currentLines.join("\n");
|
||||
if (currentKind === "promotion" && currentDate) {
|
||||
if (currentKind === "promotion" && currentDate && isGeneratedPromotionBlock(currentLines)) {
|
||||
blocks.push({ kind: "promotion", date: currentDate, text });
|
||||
} else {
|
||||
blocks.push({ kind: "preserved", text });
|
||||
@@ -152,7 +185,8 @@ type CompactMemoryResult = {
|
||||
*
|
||||
* Guarantees:
|
||||
* - Non-promotion content (user-authored markdown, the file header, any
|
||||
* heading of any level not matching the promotion pattern) is preserved.
|
||||
* heading of any level not matching the promotion pattern, and any mixed
|
||||
* or malformed promotion-shaped block) is preserved.
|
||||
* - Promotion sections are dropped in ascending date order (oldest first).
|
||||
* - If `existingMemory + newSection` already fits the budget, the existing
|
||||
* memory is returned unchanged.
|
||||
|
||||
@@ -3712,6 +3712,74 @@ describe("short-term promotion", () => {
|
||||
});
|
||||
|
||||
describe("MEMORY.md budget compaction (#73691)", () => {
|
||||
it("preserves mixed marker-backed user text during a real promotion write", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-29", [
|
||||
"Notes",
|
||||
"",
|
||||
"Rotate the staging Postgres credentials before next deploy.",
|
||||
]);
|
||||
|
||||
const memoryPath = path.join(workspaceDir, "MEMORY.md");
|
||||
const filler = "x".repeat(600);
|
||||
const seeded = [
|
||||
"# Long-Term Memory",
|
||||
"",
|
||||
"## Promoted From Short-Term Memory (2026-04-10)",
|
||||
"<!-- openclaw-memory-promotion:legacy-mixed -->",
|
||||
`- ${filler}`,
|
||||
"",
|
||||
"USER-AUTHORED: recovery key is paper-copy-17",
|
||||
"",
|
||||
"## Promoted From Short-Term Memory (2026-04-20)",
|
||||
"<!-- openclaw-memory-promotion:legacy-generated -->",
|
||||
`- ${filler}`,
|
||||
"",
|
||||
].join("\n");
|
||||
await fs.writeFile(memoryPath, seeded, "utf-8");
|
||||
|
||||
await recordShortTermRecalls({
|
||||
workspaceDir,
|
||||
query: "rotate creds",
|
||||
nowMs: Date.parse("2026-04-29T10:00:00.000Z"),
|
||||
results: [
|
||||
{
|
||||
path: "memory/2026-04-29.md",
|
||||
startLine: 3,
|
||||
endLine: 3,
|
||||
score: 0.96,
|
||||
snippet: "Rotate the staging Postgres credentials before next deploy.",
|
||||
source: "memory",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const ranked = await rankShortTermPromotionCandidates({
|
||||
workspaceDir,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
});
|
||||
const applied = await applyShortTermPromotions({
|
||||
workspaceDir,
|
||||
candidates: ranked,
|
||||
minScore: 0,
|
||||
minRecallCount: 0,
|
||||
minUniqueQueries: 0,
|
||||
nowMs: Date.parse("2026-04-29T10:00:00.000Z"),
|
||||
memoryFileMaxChars: 1_400,
|
||||
});
|
||||
|
||||
expect(applied.applied).toBe(1);
|
||||
expect(applied.compactedDates).toEqual(["2026-04-20"]);
|
||||
const memoryText = await fs.readFile(memoryPath, "utf-8");
|
||||
expect(memoryText).toContain("legacy-mixed");
|
||||
expect(memoryText).toContain("USER-AUTHORED: recovery key is paper-copy-17");
|
||||
expect(memoryText).not.toContain("legacy-generated");
|
||||
expect(memoryText).toContain("Rotate the staging Postgres credentials");
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an indented user ATX heading when compaction writes MEMORY.md", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
await writeDailyMemoryNote(workspaceDir, "2026-04-29", [
|
||||
|
||||
@@ -93,6 +93,7 @@ describe("memory search citations", () => {
|
||||
return result;
|
||||
}
|
||||
|
||||
// The first tool call pays Vitest's cold lazy-runtime transform cost on Node 24 CI.
|
||||
it("appends source information when citations are enabled", async () => {
|
||||
setMemoryBackend("builtin");
|
||||
const cfg = asOpenClawConfig({
|
||||
@@ -105,7 +106,7 @@ describe("memory search citations", () => {
|
||||
const firstResult = expectFirstMemoryResult(details);
|
||||
expect(firstResult.snippet).toMatch(/Source: MEMORY.md#L5-L7/);
|
||||
expect(firstResult.citation).toBe("MEMORY.md#L5-L7");
|
||||
});
|
||||
}, 180_000);
|
||||
|
||||
it("leaves snippet untouched when citations are off", async () => {
|
||||
setMemoryBackend("builtin");
|
||||
|
||||
@@ -2491,6 +2491,133 @@ describe("buildOpenAIRealtimeVoiceProvider", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves FIFO playback acknowledgements after sustained output", async () => {
|
||||
const provider = buildOpenAIRealtimeVoiceProvider();
|
||||
const onClearAudio = vi.fn();
|
||||
const onMark = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio,
|
||||
onMark,
|
||||
});
|
||||
const connecting = bridge.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
if (!socket) {
|
||||
throw new Error("expected bridge to create a websocket");
|
||||
}
|
||||
|
||||
socket.readyState = FakeWebSocket.OPEN;
|
||||
socket.emit("open");
|
||||
socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" })));
|
||||
await connecting;
|
||||
|
||||
bridge.setMediaTimestamp(1000);
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "response.audio.delta",
|
||||
item_id: "item_1",
|
||||
delta: Buffer.from("assistant audio").toString("base64"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const marks = onMark.mock.calls.map(([markName]) => String(markName));
|
||||
expect(marks).toHaveLength(300);
|
||||
for (let index = 0; index < 299; index += 1) {
|
||||
bridge.acknowledgeMark();
|
||||
}
|
||||
bridge.setMediaTimestamp(1300);
|
||||
bridge.handleBargeIn?.();
|
||||
|
||||
expect(parseSent(socket).slice(-1)).toEqual([
|
||||
{
|
||||
type: "conversation.item.truncate",
|
||||
item_id: "item_1",
|
||||
content_index: 0,
|
||||
audio_end_ms: 300,
|
||||
},
|
||||
]);
|
||||
expect(onClearAudio).toHaveBeenCalledWith("barge-in");
|
||||
|
||||
for (let index = 0; index < 300; index += 1) {
|
||||
socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "response.audio.delta",
|
||||
item_id: "item_1",
|
||||
delta: Buffer.from("assistant audio").toString("base64"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
const latestMark = onMark.mock.calls.at(-1)?.[0];
|
||||
if (typeof latestMark !== "string") {
|
||||
throw new Error("expected a playback mark");
|
||||
}
|
||||
bridge.acknowledgeMark(latestMark);
|
||||
bridge.setMediaTimestamp(1600);
|
||||
bridge.handleBargeIn?.();
|
||||
|
||||
expect(
|
||||
parseSent(socket).filter((event) => event.type === "conversation.item.truncate"),
|
||||
).toHaveLength(1);
|
||||
bridge.close();
|
||||
});
|
||||
|
||||
it("treats a later named mark as cumulative playback progress", async () => {
|
||||
const provider = buildOpenAIRealtimeVoiceProvider();
|
||||
const onMark = vi.fn();
|
||||
const bridge = provider.createBridge({
|
||||
providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret
|
||||
onAudio: vi.fn(),
|
||||
onClearAudio: vi.fn(),
|
||||
onMark,
|
||||
});
|
||||
const connecting = bridge.connect();
|
||||
const socket = FakeWebSocket.instances[0];
|
||||
if (!socket) {
|
||||
throw new Error("expected bridge to create a websocket");
|
||||
}
|
||||
|
||||
socket.readyState = FakeWebSocket.OPEN;
|
||||
socket.emit("open");
|
||||
socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" })));
|
||||
await connecting;
|
||||
|
||||
bridge.setMediaTimestamp(1000);
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
socket.emit(
|
||||
"message",
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
type: "response.audio.delta",
|
||||
item_id: "item_1",
|
||||
delta: Buffer.from("assistant audio").toString("base64"),
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
const marks = onMark.mock.calls.map(([markName]) => String(markName));
|
||||
expect(marks).toHaveLength(3);
|
||||
|
||||
bridge.acknowledgeMark(marks[2]);
|
||||
bridge.acknowledgeMark(marks[0]);
|
||||
bridge.acknowledgeMark(marks[1]);
|
||||
bridge.setMediaTimestamp(1300);
|
||||
bridge.handleBargeIn?.();
|
||||
|
||||
expect(
|
||||
parseSent(socket).filter((event) => event.type === "conversation.item.truncate"),
|
||||
).toHaveLength(0);
|
||||
bridge.close();
|
||||
});
|
||||
|
||||
it("forwards current realtime output audio events", async () => {
|
||||
const provider = buildOpenAIRealtimeVoiceProvider();
|
||||
const onAudio = vi.fn();
|
||||
|
||||
@@ -558,6 +558,15 @@ function readRealtimeErrorEventId(error: unknown): string | undefined {
|
||||
return typeof eventId === "string" ? eventId : undefined;
|
||||
}
|
||||
|
||||
function parsePlaybackMarkSequence(markName: string): number | undefined {
|
||||
const match = /^audio-(\d+)$/u.exec(markName);
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
const sequence = Number(match[1]);
|
||||
return Number.isSafeInteger(sequence) && sequence > 0 ? sequence : undefined;
|
||||
}
|
||||
|
||||
class OpenAIRealtimeMalformedAudioError extends Error {}
|
||||
|
||||
function base64ToBuffer(b64: string): Buffer {
|
||||
@@ -586,7 +595,9 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
private readonly lifecycle = new OpenAIRealtimeVoiceLifecycle();
|
||||
private pendingAudio: Buffer[] = [];
|
||||
private pendingAudioBytes = 0;
|
||||
private markQueue: string[] = [];
|
||||
private nextMarkSequence = 1;
|
||||
private oldestOutstandingMarkSequence: number | null = null;
|
||||
private latestOutstandingMarkSequence: number | null = null;
|
||||
private responseStartTimestamp: number | null = null;
|
||||
private responseActive = false;
|
||||
private responseCreateInFlight = false;
|
||||
@@ -697,10 +708,28 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
}
|
||||
|
||||
acknowledgeMark(markName?: string): void {
|
||||
const index = markName === undefined ? 0 : this.markQueue.indexOf(markName);
|
||||
if (index >= 0) {
|
||||
this.markQueue.splice(index, 1);
|
||||
const oldest = this.oldestOutstandingMarkSequence;
|
||||
const latest = this.latestOutstandingMarkSequence;
|
||||
if (oldest === null || latest === null) {
|
||||
return;
|
||||
}
|
||||
const acknowledgedSequence =
|
||||
markName === undefined ? oldest : parsePlaybackMarkSequence(markName);
|
||||
if (
|
||||
acknowledgedSequence === undefined ||
|
||||
acknowledgedSequence < oldest ||
|
||||
acknowledgedSequence > latest
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Marks follow ordered playback. Reaching a named mark also acknowledges every
|
||||
// earlier mark, while late acknowledgements from that prefix remain harmless.
|
||||
if (acknowledgedSequence === latest) {
|
||||
this.oldestOutstandingMarkSequence = null;
|
||||
this.latestOutstandingMarkSequence = null;
|
||||
return;
|
||||
}
|
||||
this.oldestOutstandingMarkSequence = acknowledgedSequence + 1;
|
||||
}
|
||||
|
||||
close(): void {
|
||||
@@ -1461,7 +1490,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
const shouldInterruptProvider =
|
||||
assistantItemId !== null &&
|
||||
((responseStartTimestamp !== null &&
|
||||
(this.markQueue.length > 0 || options?.audioPlaybackActive === true)) ||
|
||||
(this.oldestOutstandingMarkSequence !== null || options?.audioPlaybackActive === true)) ||
|
||||
force);
|
||||
const audioEndMs = shouldInterruptProvider
|
||||
? Math.max(
|
||||
@@ -1502,7 +1531,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
`reason=barge-in audioEndMs=${audioEndMs}`,
|
||||
);
|
||||
this.config.onClearAudio("barge-in");
|
||||
this.markQueue = [];
|
||||
this.clearOutstandingMarks();
|
||||
this.lastAssistantItemId = null;
|
||||
this.responseStartTimestamp = null;
|
||||
return;
|
||||
@@ -1586,7 +1615,7 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
}
|
||||
|
||||
private resetRealtimeSessionState(): void {
|
||||
this.markQueue = [];
|
||||
this.clearOutstandingMarks();
|
||||
this.responseStartTimestamp = null;
|
||||
this.responseActive = false;
|
||||
this.responseCreateInFlight = false;
|
||||
@@ -1657,11 +1686,21 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge {
|
||||
}
|
||||
|
||||
private sendMark(): void {
|
||||
const markName = `audio-${Date.now()}`;
|
||||
this.markQueue.push(markName);
|
||||
const sequence = this.nextMarkSequence;
|
||||
this.nextMarkSequence += 1;
|
||||
if (this.oldestOutstandingMarkSequence === null) {
|
||||
this.oldestOutstandingMarkSequence = sequence;
|
||||
}
|
||||
this.latestOutstandingMarkSequence = sequence;
|
||||
const markName = `audio-${sequence}`;
|
||||
this.config.onMark?.(markName);
|
||||
}
|
||||
|
||||
private clearOutstandingMarks(): void {
|
||||
this.oldestOutstandingMarkSequence = null;
|
||||
this.latestOutstandingMarkSequence = null;
|
||||
}
|
||||
|
||||
private sendEvent(event: unknown, detail?: string): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
const type =
|
||||
|
||||
@@ -9,6 +9,7 @@ import { createQaChannelTransport } from "./qa-channel-transport.js";
|
||||
|
||||
const HOOK_TOKEN = "qa-hook-account-routing-token";
|
||||
const MARKER = "QA_HOOK_ACCOUNT_ROUTING_OK";
|
||||
const DEFAULT_MARKER = "QA_HOOK_DEFAULT_ACCOUNT_ROUTING_OK";
|
||||
const MODEL = "mock-openai/gpt-5.6-luna";
|
||||
|
||||
async function postJson(url: string, body: unknown, headers: Record<string, string>) {
|
||||
@@ -27,65 +28,127 @@ async function postJson(url: string, body: unknown, headers: Record<string, stri
|
||||
};
|
||||
}
|
||||
|
||||
async function startHookAccountFixture() {
|
||||
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const lab = await startQaLabServer({
|
||||
repoRoot,
|
||||
embeddedGateway: "disabled",
|
||||
});
|
||||
const mock = await startQaProviderServer("mock-openai", {
|
||||
modelRefs: [MODEL],
|
||||
});
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
|
||||
try {
|
||||
if (!mock) {
|
||||
throw new Error("mock-openai provider server did not start");
|
||||
}
|
||||
const transport = createQaChannelTransport(lab.state);
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot,
|
||||
useRepoCli: true,
|
||||
providerBaseUrl: `${mock.baseUrl}/v1`,
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: MODEL,
|
||||
alternateModel: MODEL,
|
||||
transportBaseUrl: lab.listenUrl,
|
||||
transport,
|
||||
controlUiEnabled: false,
|
||||
mutateConfig: (config) => {
|
||||
const qaChannel = config.channels?.["qa-channel"];
|
||||
if (!qaChannel) {
|
||||
throw new Error("qa-channel transport config missing");
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: HOOK_TOKEN,
|
||||
path: "/hooks",
|
||||
},
|
||||
channels: {
|
||||
...config.channels,
|
||||
"qa-channel": {
|
||||
...qaChannel,
|
||||
defaultAccount: "default",
|
||||
accounts: {
|
||||
default: { name: "Default" },
|
||||
work: { name: "Work" },
|
||||
disabled: { name: "Disabled", enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
await gateway?.stop().catch(() => undefined);
|
||||
await mock?.stop().catch(() => undefined);
|
||||
await lab.stop().catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
if (!gateway || !mock) {
|
||||
throw new Error("hook account fixture did not start");
|
||||
}
|
||||
|
||||
return {
|
||||
gateway,
|
||||
lab,
|
||||
stop: async () => {
|
||||
await gateway.stop().catch(() => undefined);
|
||||
await mock.stop().catch(() => undefined);
|
||||
await lab.stop().catch(() => undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("hook agent account routing product proof", () => {
|
||||
it(
|
||||
"rejects invalid explicit accounts before running the agent",
|
||||
{ timeout: 180_000 },
|
||||
async () => {
|
||||
const fixture = await startHookAccountFixture();
|
||||
|
||||
try {
|
||||
for (const { accountId, expectedError } of [
|
||||
{ accountId: "missing", expectedError: 'Unknown account "missing"' },
|
||||
{ accountId: "disabled", expectedError: 'Account "disabled"' },
|
||||
{ accountId: "__proto__", expectedError: 'Invalid account ID "__proto__"' },
|
||||
]) {
|
||||
const response = await postJson(
|
||||
`${fixture.gateway.baseUrl}/hooks/agent`,
|
||||
{
|
||||
message: `Reply exactly: ${MARKER}`,
|
||||
deliver: true,
|
||||
channel: "qa-channel",
|
||||
to: "dm:hook-recipient",
|
||||
accountId,
|
||||
},
|
||||
{ Authorization: `Bearer ${HOOK_TOKEN}` },
|
||||
);
|
||||
|
||||
expect(response.status, JSON.stringify(response.json)).toBe(400);
|
||||
expect(response.json).toMatchObject({ ok: false, runId: expect.any(String) });
|
||||
expect((response.json as { error?: unknown }).error).toEqual(
|
||||
expect.stringContaining(expectedError),
|
||||
);
|
||||
}
|
||||
expect(fixture.lab.state.getSnapshot().messages).toHaveLength(0);
|
||||
} finally {
|
||||
await fixture.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it(
|
||||
"delivers exactly once through the selected qa-channel account",
|
||||
{ timeout: 180_000 },
|
||||
async () => {
|
||||
const repoRoot = fileURLToPath(new URL("../../../", import.meta.url));
|
||||
const lab = await startQaLabServer({
|
||||
repoRoot,
|
||||
embeddedGateway: "disabled",
|
||||
});
|
||||
const mock = await startQaProviderServer("mock-openai", {
|
||||
modelRefs: [MODEL],
|
||||
});
|
||||
let gateway: Awaited<ReturnType<typeof startQaGatewayChild>> | undefined;
|
||||
const fixture = await startHookAccountFixture();
|
||||
|
||||
try {
|
||||
if (!mock) {
|
||||
throw new Error("mock-openai provider server did not start");
|
||||
}
|
||||
const transport = createQaChannelTransport(lab.state);
|
||||
gateway = await startQaGatewayChild({
|
||||
repoRoot,
|
||||
useRepoCli: true,
|
||||
providerBaseUrl: `${mock.baseUrl}/v1`,
|
||||
providerMode: "mock-openai",
|
||||
primaryModel: MODEL,
|
||||
alternateModel: MODEL,
|
||||
transportBaseUrl: lab.listenUrl,
|
||||
transport,
|
||||
controlUiEnabled: false,
|
||||
mutateConfig: (config) => {
|
||||
const qaChannel = config.channels?.["qa-channel"];
|
||||
if (!qaChannel) {
|
||||
throw new Error("qa-channel transport config missing");
|
||||
}
|
||||
return {
|
||||
...config,
|
||||
hooks: {
|
||||
enabled: true,
|
||||
token: HOOK_TOKEN,
|
||||
path: "/hooks",
|
||||
},
|
||||
channels: {
|
||||
...config.channels,
|
||||
"qa-channel": {
|
||||
...qaChannel,
|
||||
defaultAccount: "default",
|
||||
accounts: {
|
||||
default: { name: "Default" },
|
||||
work: { name: "Work" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const response = await postJson(
|
||||
`${gateway.baseUrl}/hooks/agent`,
|
||||
`${fixture.gateway.baseUrl}/hooks/agent`,
|
||||
{
|
||||
message: `Reply exactly: ${MARKER}`,
|
||||
deliver: true,
|
||||
@@ -103,7 +166,7 @@ describe("hook agent account routing product proof", () => {
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const outbound = lab.state
|
||||
const outbound = fixture.lab.state
|
||||
.getSnapshot()
|
||||
.messages.filter((message) => message.direction === "outbound");
|
||||
expect(outbound).toHaveLength(1);
|
||||
@@ -117,15 +180,40 @@ describe("hook agent account routing product proof", () => {
|
||||
);
|
||||
await sleep(500);
|
||||
|
||||
const outbound = lab.state
|
||||
const outbound = fixture.lab.state
|
||||
.getSnapshot()
|
||||
.messages.filter((message) => message.direction === "outbound");
|
||||
expect(outbound).toHaveLength(1);
|
||||
expect(outbound.filter((message) => message.accountId === "default")).toHaveLength(0);
|
||||
|
||||
const defaultResponse = await postJson(
|
||||
`${fixture.gateway.baseUrl}/hooks/agent`,
|
||||
{
|
||||
message: `Reply exactly: ${DEFAULT_MARKER}`,
|
||||
deliver: true,
|
||||
channel: "qa-channel",
|
||||
to: "dm:hook-default-recipient",
|
||||
},
|
||||
{ Authorization: `Bearer ${HOOK_TOKEN}` },
|
||||
);
|
||||
expect(defaultResponse.status, JSON.stringify(defaultResponse.json)).toBe(200);
|
||||
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
const currentOutbound = fixture.lab.state
|
||||
.getSnapshot()
|
||||
.messages.filter((message) => message.direction === "outbound");
|
||||
expect(currentOutbound).toHaveLength(2);
|
||||
expect(currentOutbound[1]).toMatchObject({
|
||||
accountId: "default",
|
||||
conversation: { id: "hook-default-recipient", kind: "direct" },
|
||||
text: DEFAULT_MARKER,
|
||||
});
|
||||
},
|
||||
{ interval: 50, timeout: 60_000 },
|
||||
);
|
||||
} finally {
|
||||
await gateway?.stop().catch(() => undefined);
|
||||
await mock?.stop().catch(() => undefined);
|
||||
await lab.stop().catch(() => undefined);
|
||||
await fixture.stop();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -59,6 +59,54 @@ describe("engine/tools/remind-logic", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "uses the Gateway timezone when omitted",
|
||||
timezone: undefined,
|
||||
expectedSchedule: { kind: "cron", expr: "0 9 * * *" },
|
||||
expectedSummary: '⏰ Recurring reminder: "test reminder" (0 9 * * *, tz=gateway local)',
|
||||
},
|
||||
{
|
||||
name: "preserves an explicit IANA timezone",
|
||||
timezone: " America/New_York ",
|
||||
expectedSchedule: {
|
||||
kind: "cron",
|
||||
expr: "0 9 * * *",
|
||||
tz: "America/New_York",
|
||||
},
|
||||
expectedSummary: '⏰ Recurring reminder: "test reminder" (0 9 * * *, tz=America/New_York)',
|
||||
},
|
||||
])("$name for recurring reminders", async ({ timezone, expectedSchedule, expectedSummary }) => {
|
||||
const calls: RemindCronAction[] = [];
|
||||
const result = await executeScheduledRemind(
|
||||
{
|
||||
action: "add",
|
||||
content: "test reminder",
|
||||
to: "qqbot:c2c:123",
|
||||
time: "0 9 * * *",
|
||||
...(timezone ? { timezone } : {}),
|
||||
},
|
||||
{},
|
||||
async (params) => {
|
||||
calls.push(params);
|
||||
return { id: "job-cron" };
|
||||
},
|
||||
);
|
||||
|
||||
const call = calls[0];
|
||||
expect(call?.action).toBe("add");
|
||||
if (call?.action !== "add") {
|
||||
throw new Error("expected add cron action");
|
||||
}
|
||||
expect(call.job.schedule).toEqual(expectedSchedule);
|
||||
expect(result.details).toEqual({
|
||||
ok: true,
|
||||
action: "add",
|
||||
summary: expectedSummary,
|
||||
cronResult: { id: "job-cron" },
|
||||
});
|
||||
});
|
||||
|
||||
it("runs cron list and remove through the scheduler", async () => {
|
||||
const calls: unknown[] = [];
|
||||
await executeScheduledRemind({ action: "list" }, {}, async (params) => {
|
||||
|
||||
@@ -27,8 +27,6 @@ export interface RemindParams {
|
||||
jobId?: string;
|
||||
}
|
||||
|
||||
const QQBOT_DEFAULT_REMINDER_TIMEZONE = "Asia/Shanghai";
|
||||
|
||||
/**
|
||||
* Context supplied by the bridge layer so the engine can remain free of
|
||||
* framework / AsyncLocalStorage dependencies. `fallbackTo` and
|
||||
@@ -99,7 +97,7 @@ export const RemindSchema = {
|
||||
timezone: {
|
||||
type: "string",
|
||||
description:
|
||||
"Optional IANA timezone used for cron reminders. Include it when the user provides or confirms a timezone; if omitted, QQBot preserves its existing default timezone.",
|
||||
"Optional IANA timezone used for cron reminders. Include it when the user provides or confirms a timezone; if omitted, Gateway cron uses the host timezone.",
|
||||
},
|
||||
name: {
|
||||
type: "string",
|
||||
@@ -226,12 +224,16 @@ function buildOnceJob(params: RemindParams, atMs: number, to: string, accountId:
|
||||
function buildCronJob(params: RemindParams, to: string, accountId: string) {
|
||||
const content = params.content!;
|
||||
const name = params.name || generateJobName(content);
|
||||
const tz = params.timezone || QQBOT_DEFAULT_REMINDER_TIMEZONE;
|
||||
const timezone = params.timezone?.trim();
|
||||
return {
|
||||
action: "add" as const,
|
||||
job: {
|
||||
name,
|
||||
schedule: { kind: "cron" as const, expr: params.time!.trim(), tz },
|
||||
schedule: {
|
||||
kind: "cron" as const,
|
||||
expr: params.time!.trim(),
|
||||
...(timezone ? { tz: timezone } : {}),
|
||||
},
|
||||
sessionTarget: "isolated" as const,
|
||||
wakeMode: "now" as const,
|
||||
payload: {
|
||||
@@ -309,11 +311,12 @@ function prepareRemindCronAction(
|
||||
const resolvedAccountId = ctx.fallbackAccountId || "default";
|
||||
|
||||
if (isCronExpression(params.time)) {
|
||||
const timezone = params.timezone?.trim();
|
||||
return {
|
||||
ok: true,
|
||||
action: "add",
|
||||
cronAction: buildCronJob(params, resolvedTo, resolvedAccountId),
|
||||
summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${params.timezone || QQBOT_DEFAULT_REMINDER_TIMEZONE})`,
|
||||
summary: `⏰ Recurring reminder: "${params.content}" (${params.time}, tz=${timezone || "gateway local"})`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import { selectSlowStartupTraceDurations } from "./lib/gateway-startup-trace-ranking.js";
|
||||
|
||||
type GatewayBenchCase = {
|
||||
agentTopology?: "single" | "shared-eleven-plus-distinct-one";
|
||||
completionTracePhase?: string;
|
||||
config: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
id: string;
|
||||
@@ -25,6 +27,8 @@ type GatewayBenchCase = {
|
||||
pluginActivationOnStartup?: boolean;
|
||||
pluginCount?: number;
|
||||
providerCatalogStallMs?: number;
|
||||
providerStaticCatalogModelCount?: number;
|
||||
providerStaticCatalogStallMs?: number;
|
||||
};
|
||||
|
||||
type ProbeResult = {
|
||||
@@ -42,6 +46,7 @@ type ProbeTransition = {
|
||||
};
|
||||
|
||||
type GatewaySample = {
|
||||
completionMs: number | null;
|
||||
cpuCoreRatio: number | null;
|
||||
cpuMs: number | null;
|
||||
exitedBeforeTeardown?: boolean;
|
||||
@@ -72,6 +77,7 @@ type CaseResult = {
|
||||
name: string;
|
||||
samples: GatewaySample[];
|
||||
summary: {
|
||||
completionMs: SummaryStats | null;
|
||||
firstOutputMs: SummaryStats | null;
|
||||
cpuCoreRatio: SummaryStats | null;
|
||||
cpuMs: SummaryStats | null;
|
||||
@@ -178,6 +184,50 @@ const GATEWAY_CASES: readonly GatewayBenchCase[] = [
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preparedRuntimeScaleOne",
|
||||
name: "gateway, prepared runtime scale with one agent",
|
||||
agentTopology: "single",
|
||||
completionTracePhase: "sidecars.ready",
|
||||
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
||||
providerStaticCatalogModelCount: 64,
|
||||
providerStaticCatalogStallMs: 100,
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
||||
models: {
|
||||
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
||||
agentRuntime: { id: "openclaw" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "preparedRuntimeScaleMany",
|
||||
name: "gateway, prepared runtime scale with 11 shared-workspace agents and one distinct",
|
||||
agentTopology: "shared-eleven-plus-distinct-one",
|
||||
completionTracePhase: "sidecars.ready",
|
||||
env: { OPENCLAW_SKIP_CHANNELS: "1" },
|
||||
providerStaticCatalogModelCount: 64,
|
||||
providerStaticCatalogStallMs: 100,
|
||||
config: {
|
||||
...BASE_CONFIG,
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: `${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}` },
|
||||
models: {
|
||||
[`${STALLED_CATALOG_PROVIDER_ID}/${STALLED_CATALOG_MODEL_ID}`]: {
|
||||
agentRuntime: { id: "openclaw" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "oneInternalHook",
|
||||
name: "gateway, one configured internal hook",
|
||||
@@ -432,6 +482,11 @@ function summarizeCase(benchCase: GatewayBenchCase, samples: GatewaySample[]): C
|
||||
name: benchCase.name,
|
||||
samples,
|
||||
summary: {
|
||||
completionMs: summarizeNumbers(
|
||||
samples
|
||||
.map((sample) => sample.completionMs)
|
||||
.filter((value): value is number => typeof value === "number"),
|
||||
),
|
||||
firstOutputMs: summarizeNumbers(
|
||||
samples
|
||||
.map((sample) => sample.firstOutputMs)
|
||||
@@ -492,6 +547,9 @@ function collectResultFailures(
|
||||
if (sample.readyz.status !== 200 || sample.readyz.ms == null) {
|
||||
missing.push("/readyz");
|
||||
}
|
||||
if (sample.completionMs == null) {
|
||||
missing.push("completion");
|
||||
}
|
||||
if (processMetricsRequired) {
|
||||
if (sample.cpuMs == null || sample.cpuCoreRatio == null) {
|
||||
missing.push("cpu");
|
||||
@@ -561,7 +619,7 @@ function formatRatio(value: number | null): string {
|
||||
return value.toFixed(3);
|
||||
}
|
||||
|
||||
function formatStats(stats: SummaryStats | null): string {
|
||||
function formatStats(stats: SummaryStats | null | undefined): string {
|
||||
if (!stats) {
|
||||
return "n/a";
|
||||
}
|
||||
@@ -635,11 +693,32 @@ async function waitForProbe(params: {
|
||||
return { firstErrorKind, firstRecoveryMs, ms: null, status: lastStatus, transitions };
|
||||
}
|
||||
|
||||
async function waitForStartupTracePhase(params: {
|
||||
deadlineAt: number;
|
||||
isDone: () => boolean;
|
||||
phase: string;
|
||||
startupTrace: Record<string, number>;
|
||||
}): Promise<number | null> {
|
||||
const totalKey = `${params.phase}.total`;
|
||||
while (performance.now() < params.deadlineAt) {
|
||||
if (Object.hasOwn(params.startupTrace, totalKey)) {
|
||||
return params.startupTrace[totalKey] ?? null;
|
||||
}
|
||||
if (params.isDone()) {
|
||||
return null;
|
||||
}
|
||||
await delay(25);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function writePluginFixtures(
|
||||
root: string,
|
||||
count: number,
|
||||
activationOnStartup?: boolean,
|
||||
providerCatalogStallMs?: number,
|
||||
providerStaticCatalogStallMs?: number,
|
||||
providerStaticCatalogModelCount?: number,
|
||||
): PluginFixtureResult {
|
||||
const pluginIds: string[] = [];
|
||||
const pluginsDir = path.join(root, "plugins");
|
||||
@@ -647,28 +726,44 @@ function writePluginFixtures(
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const id = `bench-plugin-${String(index + 1).padStart(2, "0")}`;
|
||||
const stallsProviderCatalog = providerCatalogStallMs !== undefined && index === 0;
|
||||
const stallsProviderStaticCatalog = providerStaticCatalogStallMs !== undefined && index === 0;
|
||||
pluginIds.push(id);
|
||||
const pluginDir = path.join(pluginsDir, id);
|
||||
mkdirSync(pluginDir, { recursive: true });
|
||||
const entry = path.join(pluginDir, "index.cjs");
|
||||
const model = {
|
||||
id: STALLED_CATALOG_MODEL_ID,
|
||||
name: "Benchmark Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
const providerDiscoveryEntry = path.join(pluginDir, "provider-discovery.cjs");
|
||||
const models = Array.from(
|
||||
{ length: stallsProviderStaticCatalog ? (providerStaticCatalogModelCount ?? 1) : 1 },
|
||||
(_, modelIndex) => ({
|
||||
id:
|
||||
modelIndex === 0
|
||||
? STALLED_CATALOG_MODEL_ID
|
||||
: `${STALLED_CATALOG_MODEL_ID}-${modelIndex + 1}`,
|
||||
name: `Benchmark Model ${modelIndex + 1}`,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
}),
|
||||
);
|
||||
const provider = {
|
||||
baseUrl: "http://127.0.0.1:1/v1",
|
||||
api: "openai-completions",
|
||||
models: [model],
|
||||
models,
|
||||
};
|
||||
const entrySource = stallsProviderCatalog
|
||||
? `const provider = ${JSON.stringify(provider)};\nmodule.exports = { id: ${JSON.stringify(id)}, register(api) { api.registerProvider({ id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Catalog Stall", auth: [], catalog: { order: "simple", run: async () => { const stopAt = Date.now() + ${providerCatalogStallMs}; while (Date.now() < stopAt) {} return { provider }; } }, staticCatalog: { order: "simple", run: async () => ({ provider }) } }); } };\n`
|
||||
: `module.exports = { id: ${JSON.stringify(id)}, register() {} };\n`;
|
||||
: stallsProviderStaticCatalog
|
||||
? `const provider = ${JSON.stringify(provider)};\nmodule.exports = { id: ${JSON.stringify(id)}, register(api) { api.registerProvider({ id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Static Catalog Stall", auth: [], staticCatalog: { order: "simple", run: async () => ({ provider }) } }); } };\n`
|
||||
: `module.exports = { id: ${JSON.stringify(id)}, register() {} };\n`;
|
||||
writeFileSync(entry, entrySource);
|
||||
if (stallsProviderStaticCatalog) {
|
||||
writeFileSync(
|
||||
providerDiscoveryEntry,
|
||||
`const provider = ${JSON.stringify(provider)};\nlet staticCatalogCallCount = 0;\nmodule.exports = { id: ${JSON.stringify(STALLED_CATALOG_PROVIDER_ID)}, label: "Benchmark Static Catalog Stall", auth: [], staticCatalog: { order: "simple", run: async () => { staticCatalogCallCount += 1; console.log("startup trace: benchmark preparedRuntimeStaticCatalogCallCount=" + staticCatalogCallCount); const stopAt = Date.now() + ${providerStaticCatalogStallMs}; while (Date.now() < stopAt) {} return { provider }; } } };\n`,
|
||||
);
|
||||
}
|
||||
writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
`${JSON.stringify(
|
||||
@@ -677,12 +772,19 @@ function writePluginFixtures(
|
||||
...(activationOnStartup === undefined
|
||||
? {}
|
||||
: { activation: { onStartup: activationOnStartup } }),
|
||||
...(stallsProviderCatalog
|
||||
...(stallsProviderCatalog || stallsProviderStaticCatalog
|
||||
? {
|
||||
providers: [STALLED_CATALOG_PROVIDER_ID],
|
||||
modelCatalog: {
|
||||
providers: { [STALLED_CATALOG_PROVIDER_ID]: provider },
|
||||
},
|
||||
...(stallsProviderStaticCatalog
|
||||
? { providerCatalogEntry: "./provider-discovery.cjs" }
|
||||
: {}),
|
||||
...(stallsProviderCatalog
|
||||
? {
|
||||
modelCatalog: {
|
||||
providers: { [STALLED_CATALOG_PROVIDER_ID]: provider },
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
configSchema: { type: "object", additionalProperties: false },
|
||||
@@ -695,18 +797,53 @@ function writePluginFixtures(
|
||||
return { pluginIds, pluginsDir };
|
||||
}
|
||||
|
||||
function buildBenchAgentList(
|
||||
root: string,
|
||||
topology: GatewayBenchCase["agentTopology"],
|
||||
): Array<{ id: string; default?: boolean; workspace: string }> | undefined {
|
||||
if (!topology) {
|
||||
return undefined;
|
||||
}
|
||||
const sharedWorkspace = path.join(root, "shared-workspace");
|
||||
const distinctWorkspace = path.join(root, "distinct-workspace");
|
||||
mkdirSync(sharedWorkspace, { recursive: true });
|
||||
if (topology === "single") {
|
||||
return [{ id: "main", default: true, workspace: sharedWorkspace }];
|
||||
}
|
||||
mkdirSync(distinctWorkspace, { recursive: true });
|
||||
return Array.from({ length: 12 }, (_, index) => ({
|
||||
id: `agent-${String(index + 1).padStart(2, "0")}`,
|
||||
...(index === 0 ? { default: true } : {}),
|
||||
workspace: index === 11 ? distinctWorkspace : sharedWorkspace,
|
||||
}));
|
||||
}
|
||||
|
||||
function writeConfig(root: string, benchCase: GatewayBenchCase): string {
|
||||
const pluginCount = benchCase.providerCatalogStallMs === undefined ? benchCase.pluginCount : 1;
|
||||
const hasCatalogFixture =
|
||||
benchCase.providerCatalogStallMs !== undefined ||
|
||||
benchCase.providerStaticCatalogStallMs !== undefined;
|
||||
const pluginCount = hasCatalogFixture ? 1 : benchCase.pluginCount;
|
||||
const pluginFixtures = pluginCount
|
||||
? writePluginFixtures(
|
||||
root,
|
||||
pluginCount,
|
||||
benchCase.providerCatalogStallMs === undefined ? benchCase.pluginActivationOnStartup : true,
|
||||
hasCatalogFixture ? true : benchCase.pluginActivationOnStartup,
|
||||
benchCase.providerCatalogStallMs,
|
||||
benchCase.providerStaticCatalogStallMs,
|
||||
benchCase.providerStaticCatalogModelCount,
|
||||
)
|
||||
: null;
|
||||
const agentList = buildBenchAgentList(root, benchCase.agentTopology);
|
||||
const config = {
|
||||
...benchCase.config,
|
||||
...(agentList
|
||||
? {
|
||||
agents: {
|
||||
...(benchCase.config.agents as Record<string, unknown> | undefined),
|
||||
list: agentList,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
plugins: {
|
||||
...(benchCase.config.plugins as Record<string, unknown> | undefined),
|
||||
...(pluginFixtures
|
||||
@@ -919,10 +1056,18 @@ async function runGatewaySample(options: {
|
||||
startAt,
|
||||
}),
|
||||
]);
|
||||
const readyAt = performance.now();
|
||||
const completionMs = options.benchCase.completionTracePhase
|
||||
? await waitForStartupTracePhase({
|
||||
deadlineAt,
|
||||
isDone: () => childExited,
|
||||
phase: options.benchCase.completionTracePhase,
|
||||
startupTrace,
|
||||
})
|
||||
: performance.now() - startAt;
|
||||
const completedAt = performance.now();
|
||||
const cpuEndMs = readProcessTreeCpuMs(child.pid);
|
||||
const cpuMs = cpuStartMs == null || cpuEndMs == null ? null : Math.max(0, cpuEndMs - cpuStartMs);
|
||||
const cpuCoreRatio = cpuMs == null ? null : cpuMs / Math.max(1, readyAt - startAt);
|
||||
const cpuCoreRatio = cpuMs == null ? null : cpuMs / Math.max(1, completedAt - startAt);
|
||||
const exit = await stopChild(child);
|
||||
clearInterval(rssTimer);
|
||||
sampleRss();
|
||||
@@ -931,6 +1076,7 @@ async function runGatewaySample(options: {
|
||||
rmSync(root, { force: true, maxRetries: 3, recursive: true, retryDelay: 100 });
|
||||
|
||||
return {
|
||||
completionMs,
|
||||
cpuCoreRatio,
|
||||
cpuMs,
|
||||
exitedBeforeTeardown: exit.exitedBeforeTeardown,
|
||||
@@ -973,8 +1119,18 @@ async function runCase(options: {
|
||||
samples.push(sample);
|
||||
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
|
||||
console.log(
|
||||
`[gateway-startup-bench] ${options.benchCase.id} run ${samples.length}/${options.runs}: healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} httpListen=${formatMs(sample.httpListenLogMs)} gatewayReady=${formatMs(sample.gatewayReadyLogMs)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
|
||||
`[gateway-startup-bench] ${options.benchCase.id} run ${samples.length}/${options.runs}: completion=${formatMs(sample.completionMs)} healthz=${formatMs(sample.healthz.ms)} readyz=${formatMs(sample.readyz.ms)} httpListen=${formatMs(sample.httpListenLogMs)} gatewayReady=${formatMs(sample.gatewayReadyLogMs)} cpu=${formatMs(sample.cpuMs)} cpuCore=${formatRatio(sample.cpuCoreRatio)} rss=${formatMb(sample.maxRssMb)} heap=${formatMb(heapUsedMb)}`,
|
||||
);
|
||||
if (
|
||||
sample.outputTail &&
|
||||
(sample.completionMs == null ||
|
||||
sample.healthz.status !== 200 ||
|
||||
sample.readyz.status !== 200)
|
||||
) {
|
||||
console.error(
|
||||
`[gateway-startup-bench] ${options.benchCase.id} output tail:\n${sample.outputTail}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const heapUsedMb = sample.startupTrace["memory.ready.heapUsedMb"] ?? null;
|
||||
console.log(
|
||||
@@ -987,6 +1143,7 @@ async function runCase(options: {
|
||||
|
||||
function printResult(result: CaseResult): void {
|
||||
console.log(`\n${result.name} (${result.id})`);
|
||||
console.log(` completion: ${formatStats(result.summary.completionMs)}`);
|
||||
console.log(` first output: ${formatStats(result.summary.firstOutputMs)}`);
|
||||
console.log(` CPU: ${formatStats(result.summary.cpuMs)}`);
|
||||
console.log(` CPU core: ${formatRatioStats(result.summary.cpuCoreRatio)}`);
|
||||
@@ -1001,6 +1158,9 @@ function printResult(result: CaseResult): void {
|
||||
console.log(
|
||||
` post-ready memory: rss=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.rssMb"])} heap=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.heapUsedMb"])} external=${formatMemoryStats(result.summary.startupTrace["memory.post-ready.externalMb"])}`,
|
||||
);
|
||||
console.log(
|
||||
` prepared runtime: agents=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentCount"])} workspaces=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceGroupCount"])} configuredGroups=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredFactsGroupCount"])} configuredModels=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredRuntimeModelCount"])} generatedPlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogPluginCount"])} generatedReads=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.generatedCatalogReadCount"])} sources=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceCount"])} credentials=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.credentialGroupCount"])} catalogs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogGroupCount"])} registries=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimeRegistryCount"])} workspaceFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.workspaceFactsMs"])} runtimePlugins=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.runtimePluginMs"])} metadata=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.pluginMetadataMs"])} staticProviders=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.staticProviderCatalogMs"])} ambientAuth=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.ambientCredentialsMs"])} agentFacts=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.agentFactsMs"])} configuredProjection=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.configuredProjectionMs"])} sourceMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.catalogSourceMs"])} registryMs=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.registryMs"])} sourceLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.sourceConcurrencyLimitCount"])} fullCatalogLimit=${formatStats(result.summary.startupTrace["sidecars.model-runtime-build.fullCatalogConcurrencyLimitCount"])} staticCatalog=${formatStats(result.summary.startupTrace["benchmark.preparedRuntimeStaticCatalogCallCount"])} pluginLoader=${formatStats(result.summary.startupTrace["sidecars.plugin-loader.callsCount"])} eventLoopMax=${formatStats(result.summary.startupTrace["sidecars.model-runtime.eventLoopMax"])}`,
|
||||
);
|
||||
const trace = selectSlowStartupTraceDurations(result.summary.startupTrace, 8);
|
||||
if (trace.length > 0) {
|
||||
console.log(" trace top:");
|
||||
@@ -1077,6 +1237,7 @@ export const testing = {
|
||||
summarizeCase,
|
||||
validateCliArgs,
|
||||
waitForProbe,
|
||||
waitForStartupTracePhase,
|
||||
writeConfig,
|
||||
};
|
||||
|
||||
|
||||
@@ -149,6 +149,9 @@ function buildMockFunctionCall(name, args) {
|
||||
// commentary, which channels render as the draft's status headline. Streaming
|
||||
// text and then a call in one response is the only way to exercise
|
||||
// headline-plus-tool-line composition without a live model.
|
||||
// The Responses API carries that tag as `phase` on the message item, and the
|
||||
// transport reads it straight off the item, so an untagged item produces no
|
||||
// preamble at all and the scenario silently proves nothing.
|
||||
function preambleThenToolCallEvents(preamble, name, args) {
|
||||
const messageItemId = "msg_e2e_preamble";
|
||||
const call = buildMockFunctionCall(name, args);
|
||||
@@ -184,6 +187,7 @@ function preambleThenToolCallEvents(preamble, name, args) {
|
||||
id: messageItemId,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: preamble, annotations: [] }],
|
||||
},
|
||||
},
|
||||
@@ -210,6 +214,7 @@ function preambleThenToolCallEvents(preamble, name, args) {
|
||||
id: messageItemId,
|
||||
role: "assistant",
|
||||
status: "completed",
|
||||
phase: "commentary",
|
||||
content: [{ type: "output_text", text: preamble, annotations: [] }],
|
||||
},
|
||||
call.item,
|
||||
|
||||
@@ -656,6 +656,7 @@ const GITHUB_WORKFLOW_OWNER_TEST_TARGETS = new Map([
|
||||
".github/workflows/openclaw-npm-release.yml",
|
||||
[
|
||||
"test/openclaw-npm-postpublish-verify.test.ts",
|
||||
"test/scripts/openclaw-npm-extended-stable-workflow.test.ts",
|
||||
"test/scripts/package-acceptance-workflow.test.ts",
|
||||
],
|
||||
],
|
||||
|
||||
@@ -5,9 +5,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
const storeMocks = vi.hoisted(() => ({
|
||||
ensureAuthProfileStore: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
ensureAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
loadAuthProfileStoreWithoutExternalProfiles: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
loadAuthProfileStoreForRuntime: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
loadAuthProfileStoreForSecretsRuntime: vi.fn(() => ({ version: 1, profiles: {} })),
|
||||
}));
|
||||
|
||||
const credentialMocks = vi.hoisted(() => ({
|
||||
@@ -44,6 +41,7 @@ import { externalCliDiscoveryForProviders } from "./auth-profiles/external-cli-d
|
||||
describe("resolveAgentCredentialsForDiscovery external CLI scoping", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
credentialMocks.resolveAgentCredentialMapFromStore.mockReturnValue({});
|
||||
});
|
||||
|
||||
it("threads scoped external CLI discovery into writable auth store loading", () => {
|
||||
@@ -64,10 +62,9 @@ describe("resolveAgentCredentialsForDiscovery external CLI scoping", () => {
|
||||
config: cfg,
|
||||
externalCli,
|
||||
});
|
||||
expect(storeMocks.loadAuthProfileStoreForRuntime).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves scoped external CLI discovery for read-only auth store loading", () => {
|
||||
it("reuses the active runtime generation for read-only auth discovery", () => {
|
||||
const cfg = {} as OpenClawConfig;
|
||||
const externalCli = externalCliDiscoveryForProviders({
|
||||
cfg,
|
||||
@@ -81,7 +78,7 @@ describe("resolveAgentCredentialsForDiscovery external CLI scoping", () => {
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
expect(storeMocks.loadAuthProfileStoreForRuntime).toHaveBeenCalledWith("/tmp/openclaw-agent", {
|
||||
expect(storeMocks.ensureAuthProfileStore).toHaveBeenCalledWith("/tmp/openclaw-agent", {
|
||||
allowKeychainPrompt: false,
|
||||
config: cfg,
|
||||
externalCli,
|
||||
@@ -89,6 +86,29 @@ describe("resolveAgentCredentialsForDiscovery external CLI scoping", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("merges prepared ambient credentials without repeating ambient discovery", () => {
|
||||
credentialMocks.resolveAgentCredentialMapFromStore.mockReturnValue({
|
||||
fireworks: { type: "api_key", key: "agent-key" },
|
||||
});
|
||||
|
||||
const credentials = resolveAgentCredentialsForDiscovery("/tmp/openclaw-agent", {
|
||||
ambientCredentials: {
|
||||
fireworks: { type: "api_key", key: "ambient-key" },
|
||||
"claude-cli": { type: "api_key", key: "synthetic-key" },
|
||||
},
|
||||
env: {},
|
||||
readOnly: true,
|
||||
});
|
||||
|
||||
expect(credentials).toEqual({
|
||||
fireworks: { type: "api_key", key: "agent-key" },
|
||||
"claude-cli": { type: "api_key", key: "synthetic-key" },
|
||||
});
|
||||
expect(discoveryCoreMocks.addEnvBackedAgentCredentials).not.toHaveBeenCalled();
|
||||
expect(syntheticAuthMocks.resolveRuntimeSyntheticAuthProviderRefs).not.toHaveBeenCalled();
|
||||
expect(syntheticAuthMocks.resolveProviderSyntheticAuthWithPlugin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("can skip runtime external auth overlays and scope synthetic auth discovery", () => {
|
||||
resolveAgentCredentialsForDiscovery("/tmp/openclaw-agent", {
|
||||
env: {},
|
||||
@@ -106,6 +126,9 @@ describe("resolveAgentCredentialsForDiscovery external CLI scoping", () => {
|
||||
expect(syntheticAuthMocks.resolveRuntimeSyntheticAuthProviderRefs).not.toHaveBeenCalled();
|
||||
expect(syntheticAuthMocks.resolveProviderSyntheticAuthWithPlugin).toHaveBeenCalledWith({
|
||||
provider: "fireworks",
|
||||
config: undefined,
|
||||
workspaceDir: undefined,
|
||||
env: {},
|
||||
context: {
|
||||
config: undefined,
|
||||
provider: "fireworks",
|
||||
|
||||
@@ -14,13 +14,11 @@ import type { ExternalCliAuthDiscovery } from "./auth-profiles/external-cli-disc
|
||||
import {
|
||||
ensureAuthProfileStore,
|
||||
ensureAuthProfileStoreWithoutExternalProfiles,
|
||||
loadAuthProfileStoreWithoutExternalProfiles,
|
||||
loadAuthProfileStoreForRuntime,
|
||||
loadAuthProfileStoreForSecretsRuntime,
|
||||
} from "./auth-profiles/store.js";
|
||||
|
||||
/** Options for discovering credentials without prompting for secret material. */
|
||||
export type DiscoverAuthStorageOptions = {
|
||||
ambientCredentials?: Readonly<AgentCredentialMap>;
|
||||
externalCli?: ExternalCliAuthDiscovery;
|
||||
inheritedAuthDir?: string;
|
||||
readOnly?: boolean;
|
||||
@@ -29,6 +27,62 @@ export type DiscoverAuthStorageOptions = {
|
||||
syntheticAuthProviderRefs?: Iterable<string>;
|
||||
} & AgentDiscoveryAuthLookupOptions;
|
||||
|
||||
type AmbientAgentCredentialOptions = AgentDiscoveryAuthLookupOptions & {
|
||||
resolveSyntheticAuth?: (provider: string) => { apiKey?: string } | undefined;
|
||||
syntheticAuthProviderRefs?: Iterable<string>;
|
||||
};
|
||||
|
||||
/** Resolves workspace/config/env-stable credentials independently of agent-local profiles. */
|
||||
export function resolveAmbientAgentCredentialsForDiscovery(
|
||||
options: AmbientAgentCredentialOptions = {},
|
||||
): AgentCredentialMap {
|
||||
const credentials = addEnvBackedAgentCredentials({}, options);
|
||||
const syntheticAuthProviderRefs =
|
||||
options.syntheticAuthProviderRefs ?? resolveRuntimeSyntheticAuthProviderRefs();
|
||||
const resolveSyntheticAuth =
|
||||
options.resolveSyntheticAuth ??
|
||||
((provider: string) =>
|
||||
resolveProviderSyntheticAuthWithPlugin({
|
||||
provider,
|
||||
config: options.config,
|
||||
workspaceDir: options.workspaceDir,
|
||||
env: options.env,
|
||||
context: {
|
||||
config: options.config,
|
||||
provider,
|
||||
providerConfig: options.config?.models?.providers?.[provider],
|
||||
},
|
||||
}));
|
||||
for (const provider of syntheticAuthProviderRefs) {
|
||||
if (credentials[provider]) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!isAmbientCredentialAllowedByProviderAuthPin({
|
||||
config: options.config,
|
||||
authAliasLookupParams: {
|
||||
...(options.env ? { env: options.env } : {}),
|
||||
...(options.workspaceDir ? { workspaceDir: options.workspaceDir } : {}),
|
||||
},
|
||||
provider,
|
||||
type: "api_key",
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const resolved = resolveSyntheticAuth(provider);
|
||||
const apiKey = resolved?.apiKey?.trim();
|
||||
if (!apiKey) {
|
||||
continue;
|
||||
}
|
||||
credentials[provider] = {
|
||||
type: "api_key",
|
||||
key: apiKey,
|
||||
};
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/** Resolves agent credentials from auth profiles, env, and synthetic auth hooks. */
|
||||
export function resolveAgentCredentialsForDiscovery(
|
||||
agentDir: string,
|
||||
@@ -42,68 +96,33 @@ export function resolveAgentCredentialsForDiscovery(
|
||||
};
|
||||
const store =
|
||||
options?.skipExternalAuthProfiles === true
|
||||
? options.readOnly === true
|
||||
? loadAuthProfileStoreWithoutExternalProfiles(
|
||||
agentDir,
|
||||
options.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : undefined,
|
||||
)
|
||||
: ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}),
|
||||
})
|
||||
: options?.readOnly === true
|
||||
? options.externalCli || options.config || options.inheritedAuthDir
|
||||
? loadAuthProfileStoreForRuntime(agentDir, { readOnly: true, ...storeOptions })
|
||||
: loadAuthProfileStoreForSecretsRuntime(agentDir)
|
||||
: ensureAuthProfileStore(agentDir, storeOptions);
|
||||
const credentials = addEnvBackedAgentCredentials(
|
||||
resolveAgentCredentialMapFromStore(store, {
|
||||
includeSecretRefPlaceholders: options?.readOnly === true,
|
||||
config: options?.config,
|
||||
}),
|
||||
{
|
||||
? ensureAuthProfileStoreWithoutExternalProfiles(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
...(options?.inheritedAuthDir ? { inheritedAuthDir: options.inheritedAuthDir } : {}),
|
||||
...(options?.readOnly === true ? { readOnly: true } : {}),
|
||||
})
|
||||
: ensureAuthProfileStore(agentDir, {
|
||||
...storeOptions,
|
||||
...(options?.readOnly === true ? { readOnly: true } : {}),
|
||||
});
|
||||
const credentials = resolveAgentCredentialMapFromStore(store, {
|
||||
includeSecretRefPlaceholders: options?.readOnly === true,
|
||||
config: options?.config,
|
||||
});
|
||||
const ambientCredentials =
|
||||
options?.ambientCredentials ??
|
||||
resolveAmbientAgentCredentialsForDiscovery({
|
||||
config: options?.config,
|
||||
workspaceDir: options?.workspaceDir,
|
||||
env: options?.env,
|
||||
},
|
||||
);
|
||||
const syntheticAuthProviderRefs =
|
||||
options?.syntheticAuthProviderRefs ?? resolveRuntimeSyntheticAuthProviderRefs();
|
||||
for (const provider of syntheticAuthProviderRefs) {
|
||||
syntheticAuthProviderRefs: options?.syntheticAuthProviderRefs,
|
||||
});
|
||||
for (const [provider, credential] of Object.entries(ambientCredentials)) {
|
||||
if (credentials[provider]) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
!isAmbientCredentialAllowedByProviderAuthPin({
|
||||
config: options?.config,
|
||||
authAliasLookupParams: {
|
||||
...(options?.env ? { env: options.env } : {}),
|
||||
...(options?.workspaceDir ? { workspaceDir: options.workspaceDir } : {}),
|
||||
},
|
||||
provider,
|
||||
type: "api_key",
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
// Synthetic auth is a plugin/runtime fallback. Only fill empty providers so
|
||||
// persisted profiles and env-backed credentials remain authoritative.
|
||||
const resolved = resolveProviderSyntheticAuthWithPlugin({
|
||||
provider,
|
||||
context: {
|
||||
config: undefined,
|
||||
provider,
|
||||
providerConfig: undefined,
|
||||
},
|
||||
});
|
||||
const apiKey = resolved?.apiKey?.trim();
|
||||
if (!apiKey) {
|
||||
continue;
|
||||
}
|
||||
credentials[provider] = {
|
||||
type: "api_key",
|
||||
key: apiKey,
|
||||
};
|
||||
// Ambient auth is a lifecycle-owned fallback. Agent-local profiles remain authoritative.
|
||||
credentials[provider] = credential;
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("../plugins/provider-runtime.js", () => ({
|
||||
|
||||
vi.mock("./auth-profiles/store.js", () => ({
|
||||
ensureAuthProfileStore: () => ({ version: 1, profiles: {} }),
|
||||
loadAuthProfileStoreForSecretsRuntime: () => ({ version: 1, profiles: {} }),
|
||||
ensureAuthProfileStoreWithoutExternalProfiles: () => ({ version: 1, profiles: {} }),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-auth-discovery-core.js", () => ({
|
||||
@@ -74,6 +74,9 @@ describe("agent model discovery synthetic auth", () => {
|
||||
expect(resolveProviderSyntheticAuthWithPlugin).toHaveBeenCalledTimes(1);
|
||||
expect(resolveProviderSyntheticAuthWithPlugin).toHaveBeenCalledWith({
|
||||
provider: "claude-cli",
|
||||
config: undefined,
|
||||
workspaceDir: undefined,
|
||||
env: undefined,
|
||||
context: {
|
||||
config: undefined,
|
||||
provider: "claude-cli",
|
||||
|
||||
@@ -5,7 +5,11 @@ import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { clearCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-state.js";
|
||||
import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
discoverModelsFromCapturedSources,
|
||||
} from "./agent-model-discovery.js";
|
||||
|
||||
// Discovery must not cold-load bundled plugin runtime: with build artifacts
|
||||
// present, the openai plugin's normalizeResolvedModel currently overrides
|
||||
@@ -37,6 +41,33 @@ function writeModelsJson(agentDir: string, modelId: string): void {
|
||||
}
|
||||
|
||||
describe("discoverModels", () => {
|
||||
it("uses a directory-independent source label for lifecycle-captured catalogs", () => {
|
||||
const firstAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-models-first-"));
|
||||
const secondAgentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-models-second-"));
|
||||
try {
|
||||
const createRegistry = (agentDir: string) =>
|
||||
discoverModelsFromCapturedSources(
|
||||
discoverAuthStorage(agentDir, { skipCredentials: true }),
|
||||
{
|
||||
includePluginCatalogs: true,
|
||||
modelsJsonContents: "not valid json",
|
||||
pluginCatalogs: [],
|
||||
},
|
||||
);
|
||||
|
||||
const firstError = createRegistry(firstAgentDir).getError();
|
||||
const secondError = createRegistry(secondAgentDir).getError();
|
||||
|
||||
expect(firstError).toBe(secondError);
|
||||
expect(firstError).toContain("captured:models.json");
|
||||
expect(firstError).not.toContain(firstAgentDir);
|
||||
expect(secondError).not.toContain(secondAgentDir);
|
||||
} finally {
|
||||
fs.rmSync(firstAgentDir, { recursive: true, force: true });
|
||||
fs.rmSync(secondAgentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("clears cached find results when the agent model registry refreshes", () => {
|
||||
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-agent-models-"));
|
||||
writeModelsJson(agentDir, "old-model");
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "./agent-auth-discovery.js";
|
||||
import { resolveModelPluginMetadataSnapshot } from "./model-discovery-context.js";
|
||||
import type { PluginModelCatalogMetadataSnapshot } from "./plugin-model-catalog.js";
|
||||
import type { PersistedPluginModelCatalog } from "./plugin-model-catalog.js";
|
||||
import {
|
||||
AuthStorage,
|
||||
ModelRegistry,
|
||||
@@ -30,14 +31,27 @@ type DiscoveredProviderRuntimeModelLike = Omit<ProviderRuntimeModelLike, "api">
|
||||
api?: string | null;
|
||||
};
|
||||
|
||||
const CAPTURED_MODELS_JSON_SOURCE_PATH = "captured:models.json";
|
||||
|
||||
type DiscoverModelsOptions = {
|
||||
config?: OpenClawConfig;
|
||||
includePluginCatalogs?: boolean;
|
||||
modelsJsonContents?: string | null;
|
||||
pluginCatalogs?: readonly PersistedPluginModelCatalog[];
|
||||
providerFilter?: string;
|
||||
pluginMetadataSnapshot?: PluginModelCatalogMetadataSnapshot;
|
||||
workspaceDir?: string;
|
||||
normalizeModels?: boolean;
|
||||
};
|
||||
|
||||
type DiscoverCapturedModelsOptions = Omit<
|
||||
DiscoverModelsOptions,
|
||||
"modelsJsonContents" | "normalizeModels" | "pluginCatalogs"
|
||||
> & {
|
||||
modelsJsonContents: string | null;
|
||||
pluginCatalogs: readonly PersistedPluginModelCatalog[];
|
||||
};
|
||||
|
||||
/** Applies plugin model normalization and transport hooks to discovered agent models. */
|
||||
export function normalizeDiscoveredAgentModel<T>(
|
||||
value: T,
|
||||
@@ -98,7 +112,7 @@ export function normalizeDiscoveredAgentModel<T>(
|
||||
function createOpenClawModelRegistry(
|
||||
authStorage: AgentAuthStorage,
|
||||
modelsJsonPath: string,
|
||||
agentDir: string,
|
||||
agentDir: string | undefined,
|
||||
options?: DiscoverModelsOptions,
|
||||
): AgentModelRegistry {
|
||||
const pluginMetadataSnapshot = resolveModelPluginMetadataSnapshot({
|
||||
@@ -110,7 +124,16 @@ function createOpenClawModelRegistry(
|
||||
allowWorkspaceScopedCurrent: options?.workspaceDir === undefined,
|
||||
useRuntimeConfig: options?.config === undefined,
|
||||
});
|
||||
const registryOptions = pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {};
|
||||
const registryOptions = {
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
...(options?.includePluginCatalogs !== undefined
|
||||
? { includePluginCatalogs: options.includePluginCatalogs }
|
||||
: {}),
|
||||
...(options?.modelsJsonContents !== undefined
|
||||
? { modelsJsonContents: options.modelsJsonContents }
|
||||
: {}),
|
||||
...(options?.pluginCatalogs !== undefined ? { pluginCatalogs: options.pluginCatalogs } : {}),
|
||||
};
|
||||
const registry = ModelRegistry.create(authStorage, modelsJsonPath, registryOptions);
|
||||
const getAll = registry.getAll.bind(registry);
|
||||
const getAvailable = registry.getAvailable.bind(registry);
|
||||
@@ -121,8 +144,15 @@ function createOpenClawModelRegistry(
|
||||
!providerFilter || normalizeProviderId(entry.provider) === providerFilter;
|
||||
const shouldNormalize = options?.normalizeModels !== false;
|
||||
const findCache = new Map<string, Model | undefined>();
|
||||
const normalizeEntry = (entry: Model) =>
|
||||
shouldNormalize ? normalizeDiscoveredAgentModel(entry, agentDir, options) : entry;
|
||||
const normalizeEntry = (entry: Model) => {
|
||||
if (!shouldNormalize) {
|
||||
return entry;
|
||||
}
|
||||
if (!agentDir) {
|
||||
throw new Error("agent directory is required for model normalization");
|
||||
}
|
||||
return normalizeDiscoveredAgentModel(entry, agentDir, options);
|
||||
};
|
||||
|
||||
registry.getAll = () => {
|
||||
const entries = getAll().filter((entry: Model) => matchesProviderFilter(entry));
|
||||
@@ -151,7 +181,6 @@ function createOpenClawModelRegistry(
|
||||
return registry;
|
||||
}
|
||||
|
||||
/** Creates auth storage for model discovery from stored and env-backed credentials. */
|
||||
/** Builds auth storage for model discovery without prompting for secrets. */
|
||||
export function discoverAuthStorage(
|
||||
agentDir: string,
|
||||
@@ -176,3 +205,17 @@ export function discoverModels(
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses complete lifecycle-captured sources without retaining an agent-directory dependency.
|
||||
* Callers may share the resulting immutable catalog snapshot across exact source generations.
|
||||
*/
|
||||
export function discoverModelsFromCapturedSources(
|
||||
authStorage: AgentAuthStorage,
|
||||
options: DiscoverCapturedModelsOptions,
|
||||
): AgentModelRegistry {
|
||||
return createOpenClawModelRegistry(authStorage, CAPTURED_MODELS_JSON_SOURCE_PATH, undefined, {
|
||||
...options,
|
||||
normalizeModels: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
import {
|
||||
attachModelProviderRequestTransport,
|
||||
getModelProviderRequestTransport,
|
||||
inheritModelProviderMetadataOwners,
|
||||
resolveProviderRequestPolicyConfig,
|
||||
} from "./provider-request-config.js";
|
||||
import { transformTransportMessages } from "./transport-message-transform.js";
|
||||
@@ -89,9 +90,12 @@ export function configureAiTransportRuntimeHost(): void {
|
||||
return Boolean(request?.proxy || request?.tls || getModelProviderLocalService(model));
|
||||
},
|
||||
inheritManagedTransport: (source, target) =>
|
||||
attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(target, getModelProviderRequestTransport(source)),
|
||||
getModelProviderLocalService(source),
|
||||
inheritModelProviderMetadataOwners(
|
||||
source,
|
||||
attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(target, getModelProviderRequestTransport(source)),
|
||||
getModelProviderLocalService(source),
|
||||
),
|
||||
),
|
||||
transformTransportMessages,
|
||||
registerCustomApi: ensureCustomApiRegistered,
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.public.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { EMPTY_PREPARED_MESSAGE_TOOL_CATALOG } from "../plugins/prepared-message-tool-catalog.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { defaultRuntime } from "../runtime.js";
|
||||
import { createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { listAllChannelSupportedActions, listChannelSupportedActions } from "./channel-tools.js";
|
||||
|
||||
const EMPTY_PREPARED_MESSAGE_TOOL_CATALOG = {
|
||||
version: 0,
|
||||
channels: [],
|
||||
getChannel: () => undefined,
|
||||
} as const;
|
||||
|
||||
describe("channel tools", () => {
|
||||
const errorSpy = vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined);
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { Model } from "../../llm/types.js";
|
||||
import type { PluginMetadataSnapshotOwnerMaps } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
|
||||
import { resolveCatalogOwnedModelCompat } from "../model-compat-catalog.js";
|
||||
import { attachModelProviderLocalService } from "../provider-local-service.js";
|
||||
import {
|
||||
attachModelProviderMetadataOwners,
|
||||
attachModelProviderRequestTransport,
|
||||
resolveProviderRequestConfig,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
@@ -42,6 +44,7 @@ export function resolveConfiguredFallbackModel(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
agentDir?: string;
|
||||
manifestAlias: ManifestModelCatalogProviderAliasMetadata;
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
|
||||
workspaceDir?: string;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
}): Model | undefined {
|
||||
@@ -138,6 +141,9 @@ export function resolveConfiguredFallbackModel(params: {
|
||||
provider,
|
||||
api: fallbackTransport.api ?? "openai-responses",
|
||||
baseUrl: fallbackTransport.baseUrl,
|
||||
...(params.providerMetadataOwners
|
||||
? { providerMetadataOwners: params.providerMetadataOwners }
|
||||
: {}),
|
||||
discoveredHeaders: staticCatalogHeaders,
|
||||
providerHeaders,
|
||||
modelHeaders,
|
||||
@@ -171,52 +177,55 @@ export function resolveConfiguredFallbackModel(params: {
|
||||
cfg,
|
||||
agentDir,
|
||||
workspaceDir,
|
||||
model: attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(
|
||||
{
|
||||
id: modelId,
|
||||
name: metadataModel?.name ?? modelId,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
provider,
|
||||
baseUrl: requestConfig.baseUrl,
|
||||
reasoning: fallbackReasoning,
|
||||
input: resolveProviderModelInput({
|
||||
model: attachModelProviderMetadataOwners(
|
||||
attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(
|
||||
{
|
||||
id: modelId,
|
||||
name: metadataModel?.name ?? modelId,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
provider,
|
||||
modelId,
|
||||
modelName: metadataModel?.name ?? modelId,
|
||||
input: metadataModel?.input,
|
||||
}),
|
||||
...(configuredModel?.thinkingLevelMap !== undefined
|
||||
? { thinkingLevelMap: configuredModel.thinkingLevelMap }
|
||||
: {}),
|
||||
cost: metadataModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: resolvedFallbackContextWindow,
|
||||
contextTokens:
|
||||
configuredModel?.contextTokens ??
|
||||
providerConfig?.contextTokens ??
|
||||
providerConfig?.models?.[0]?.contextTokens ??
|
||||
staticCatalogModel?.contextTokens,
|
||||
// maxTokens is a wire-level output cap, not a context-budget fallback.
|
||||
// Omit an unknown cap so strict providers can apply their own limit.
|
||||
...(normalizedResolvedFallbackMaxTokens !== undefined
|
||||
? {
|
||||
maxTokens: normalizedResolvedFallbackMaxTokens,
|
||||
maxTokensSource:
|
||||
configuredFallbackMaxTokens !== undefined ? "configured" : "discovered",
|
||||
}
|
||||
: {}),
|
||||
...(resolvedParams ? { params: resolvedParams } : {}),
|
||||
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
|
||||
headers: requestConfig.headers,
|
||||
...(providerConfig?.authHeader !== undefined
|
||||
? { authHeader: providerConfig.authHeader }
|
||||
: {}),
|
||||
compat: fallbackCompat,
|
||||
mediaInput: fallbackMediaInput,
|
||||
} as Model,
|
||||
providerRequest,
|
||||
baseUrl: requestConfig.baseUrl,
|
||||
reasoning: fallbackReasoning,
|
||||
input: resolveProviderModelInput({
|
||||
provider,
|
||||
modelId,
|
||||
modelName: metadataModel?.name ?? modelId,
|
||||
input: metadataModel?.input,
|
||||
}),
|
||||
...(configuredModel?.thinkingLevelMap !== undefined
|
||||
? { thinkingLevelMap: configuredModel.thinkingLevelMap }
|
||||
: {}),
|
||||
cost: metadataModel?.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: resolvedFallbackContextWindow,
|
||||
contextTokens:
|
||||
configuredModel?.contextTokens ??
|
||||
providerConfig?.contextTokens ??
|
||||
providerConfig?.models?.[0]?.contextTokens ??
|
||||
staticCatalogModel?.contextTokens,
|
||||
// maxTokens is a wire-level output cap, not a context-budget fallback.
|
||||
// Omit an unknown cap so strict providers can apply their own limit.
|
||||
...(normalizedResolvedFallbackMaxTokens !== undefined
|
||||
? {
|
||||
maxTokens: normalizedResolvedFallbackMaxTokens,
|
||||
maxTokensSource:
|
||||
configuredFallbackMaxTokens !== undefined ? "configured" : "discovered",
|
||||
}
|
||||
: {}),
|
||||
...(resolvedParams ? { params: resolvedParams } : {}),
|
||||
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
|
||||
headers: requestConfig.headers,
|
||||
...(providerConfig?.authHeader !== undefined
|
||||
? { authHeader: providerConfig.authHeader }
|
||||
: {}),
|
||||
compat: fallbackCompat,
|
||||
mediaInput: fallbackMediaInput,
|
||||
} as Model,
|
||||
providerRequest,
|
||||
),
|
||||
providerConfig?.localService,
|
||||
),
|
||||
providerConfig?.localService,
|
||||
params.providerMetadataOwners,
|
||||
),
|
||||
runtimeHooks,
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st
|
||||
import type { ModelCompatConfig, ModelMediaInputConfig } from "../../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { Api, Model } from "../../llm/types.js";
|
||||
import type { PluginMetadataSnapshotOwnerMaps } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
|
||||
import { resolveCatalogOwnedModelCompat } from "../model-compat-catalog.js";
|
||||
import { modelKey, normalizeStaticProviderModelId } from "../model-ref-shared.js";
|
||||
@@ -9,6 +10,7 @@ import { findNormalizedProviderValue, normalizeProviderId } from "../model-selec
|
||||
import { shouldSuppressBuiltInModel, shouldUnconditionallySuppress } from "../model-suppression.js";
|
||||
import { attachModelProviderLocalService } from "../provider-local-service.js";
|
||||
import {
|
||||
attachModelProviderMetadataOwners,
|
||||
attachModelProviderRequestTransport,
|
||||
resolveProviderRequestConfig,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
@@ -328,6 +330,7 @@ export function applyConfiguredProviderOverrides(params: {
|
||||
modelId: string;
|
||||
cfg?: OpenClawConfig;
|
||||
manifestAlias: ManifestModelCatalogProviderAliasMetadata;
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
|
||||
runtimeHooks?: ProviderRuntimeHooks;
|
||||
preferDiscoveredModelMetadata?: boolean;
|
||||
preferDiscoveredTransport?: boolean;
|
||||
@@ -335,7 +338,10 @@ export function applyConfiguredProviderOverrides(params: {
|
||||
workspaceDir?: string;
|
||||
}): ProviderRuntimeModel {
|
||||
const { providerConfig, modelId } = params;
|
||||
const discoveredModel = markDiscoveredMaxTokensSource(params.discoveredModel);
|
||||
const discoveredModel = attachModelProviderMetadataOwners(
|
||||
markDiscoveredMaxTokensSource(params.discoveredModel),
|
||||
params.providerMetadataOwners,
|
||||
);
|
||||
const manifestAliasTransport = params.manifestAlias.transport;
|
||||
const requestTimeoutMs = resolveProviderRequestTimeoutMs(providerConfig?.timeoutSeconds);
|
||||
const defaultModelParams = findConfiguredAgentModelParams({
|
||||
@@ -367,6 +373,9 @@ export function applyConfiguredProviderOverrides(params: {
|
||||
provider: params.provider,
|
||||
api: aliasTransport?.api ?? discoveredModel.api,
|
||||
baseUrl: aliasTransport?.baseUrl ?? discoveredModel.baseUrl,
|
||||
...(params.providerMetadataOwners
|
||||
? { providerMetadataOwners: params.providerMetadataOwners }
|
||||
: {}),
|
||||
discoveredHeaders,
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
@@ -419,6 +428,9 @@ export function applyConfiguredProviderOverrides(params: {
|
||||
provider: params.provider,
|
||||
api: discoveredModel.api,
|
||||
baseUrl: discoveredModel.baseUrl,
|
||||
...(params.providerMetadataOwners
|
||||
? { providerMetadataOwners: params.providerMetadataOwners }
|
||||
: {}),
|
||||
discoveredHeaders,
|
||||
providerHeaders,
|
||||
modelHeaders: configuredHeaders,
|
||||
@@ -562,6 +574,9 @@ export function applyConfiguredProviderOverrides(params: {
|
||||
"openai-responses",
|
||||
baseUrl:
|
||||
resolvedTransport.baseUrl ?? configuredStaticCatalogModel?.baseUrl ?? discoveredModel.baseUrl,
|
||||
...(params.providerMetadataOwners
|
||||
? { providerMetadataOwners: params.providerMetadataOwners }
|
||||
: {}),
|
||||
discoveredHeaders,
|
||||
providerHeaders,
|
||||
modelHeaders: configuredHeaders,
|
||||
@@ -570,47 +585,50 @@ export function applyConfiguredProviderOverrides(params: {
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
});
|
||||
return attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(
|
||||
{
|
||||
...discoveredModel,
|
||||
provider: params.provider,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
baseUrl: requestConfig.baseUrl ?? discoveredModel.baseUrl,
|
||||
reasoning: resolvedReasoning,
|
||||
input: normalizedInput,
|
||||
cost: metadataOverrideModel?.cost ?? discoveredModel.cost,
|
||||
contextWindow: resolvedContextWindow ?? discoveredModel.contextWindow,
|
||||
contextTokens:
|
||||
metadataOverrideModel?.contextTokens ??
|
||||
providerConfig.contextTokens ??
|
||||
discoveredModel.contextTokens,
|
||||
...(normalizedResolvedMaxTokens !== undefined
|
||||
? {
|
||||
maxTokens: normalizedResolvedMaxTokens,
|
||||
maxTokensSource:
|
||||
configuredMaxTokens !== undefined
|
||||
? "configured"
|
||||
: (discoveredModel.maxTokensSource ?? "discovered"),
|
||||
}
|
||||
: {}),
|
||||
...(resolvedParams ? { params: resolvedParams } : {}),
|
||||
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
|
||||
headers: requestConfig.headers,
|
||||
...(providerConfig.authHeader !== undefined
|
||||
? { authHeader: providerConfig.authHeader }
|
||||
: {}),
|
||||
compat: resolvedCompat,
|
||||
mediaInput: mergeModelMediaInput(
|
||||
mergeModelMediaInput(
|
||||
configuredStaticCatalogModel?.mediaInput,
|
||||
discoveredModel.mediaInput,
|
||||
return attachModelProviderMetadataOwners(
|
||||
attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(
|
||||
{
|
||||
...discoveredModel,
|
||||
provider: params.provider,
|
||||
api: requestConfig.api ?? "openai-responses",
|
||||
baseUrl: requestConfig.baseUrl ?? discoveredModel.baseUrl,
|
||||
reasoning: resolvedReasoning,
|
||||
input: normalizedInput,
|
||||
cost: metadataOverrideModel?.cost ?? discoveredModel.cost,
|
||||
contextWindow: resolvedContextWindow ?? discoveredModel.contextWindow,
|
||||
contextTokens:
|
||||
metadataOverrideModel?.contextTokens ??
|
||||
providerConfig.contextTokens ??
|
||||
discoveredModel.contextTokens,
|
||||
...(normalizedResolvedMaxTokens !== undefined
|
||||
? {
|
||||
maxTokens: normalizedResolvedMaxTokens,
|
||||
maxTokensSource:
|
||||
configuredMaxTokens !== undefined
|
||||
? "configured"
|
||||
: (discoveredModel.maxTokensSource ?? "discovered"),
|
||||
}
|
||||
: {}),
|
||||
...(resolvedParams ? { params: resolvedParams } : {}),
|
||||
...(requestTimeoutMs !== undefined ? { requestTimeoutMs } : {}),
|
||||
headers: requestConfig.headers,
|
||||
...(providerConfig.authHeader !== undefined
|
||||
? { authHeader: providerConfig.authHeader }
|
||||
: {}),
|
||||
compat: resolvedCompat,
|
||||
mediaInput: mergeModelMediaInput(
|
||||
mergeModelMediaInput(
|
||||
configuredStaticCatalogModel?.mediaInput,
|
||||
discoveredModel.mediaInput,
|
||||
),
|
||||
metadataOverrideModel?.mediaInput,
|
||||
),
|
||||
metadataOverrideModel?.mediaInput,
|
||||
),
|
||||
},
|
||||
providerRequest,
|
||||
},
|
||||
providerRequest,
|
||||
),
|
||||
providerConfig.localService,
|
||||
),
|
||||
providerConfig.localService,
|
||||
params.providerMetadataOwners,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/s
|
||||
import type { ModelDefinitionConfig, ModelProviderConfig } from "../../config/types.js";
|
||||
import { normalizeGoogleApiBaseUrl } from "../../infra/google-api-base-url.js";
|
||||
import type { Api } from "../../llm/types.js";
|
||||
import type { PluginMetadataSnapshotOwnerMaps } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { isSecretRefHeaderValueMarker } from "../model-auth-markers.js";
|
||||
import { attachModelProviderLocalService } from "../provider-local-service.js";
|
||||
import {
|
||||
attachModelProviderMetadataOwners,
|
||||
attachModelProviderRequestTransport,
|
||||
resolveProviderRequestConfig,
|
||||
sanitizeConfiguredModelProviderRequest,
|
||||
@@ -141,6 +143,7 @@ function resolveInlineProviderTransport(params: { api?: Api | null; baseUrl?: st
|
||||
/** Builds runtime model records from inline provider config, inheriting provider-level defaults. */
|
||||
export function buildInlineProviderModels(
|
||||
providers: Record<string, InlineProviderConfig>,
|
||||
options: { providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps } = {},
|
||||
): InlineModelEntry[] {
|
||||
return Object.entries(providers).flatMap(([providerId, entry]) => {
|
||||
const trimmed = providerId.trim();
|
||||
@@ -163,6 +166,9 @@ export function buildInlineProviderModels(
|
||||
provider: trimmed,
|
||||
api: transport.api ?? model.api,
|
||||
baseUrl: transport.baseUrl,
|
||||
...(options.providerMetadataOwners
|
||||
? { providerMetadataOwners: options.providerMetadataOwners }
|
||||
: {}),
|
||||
providerHeaders,
|
||||
modelHeaders,
|
||||
authHeader: entry?.authHeader,
|
||||
@@ -170,27 +176,30 @@ export function buildInlineProviderModels(
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
});
|
||||
return attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(
|
||||
{
|
||||
...model,
|
||||
contextWindow: model.contextWindow ?? entry?.contextWindow,
|
||||
contextTokens: model.contextTokens ?? entry?.contextTokens,
|
||||
maxTokens: model.maxTokens ?? entry?.maxTokens,
|
||||
input: resolveProviderModelInput({
|
||||
return attachModelProviderMetadataOwners(
|
||||
attachModelProviderLocalService(
|
||||
attachModelProviderRequestTransport(
|
||||
{
|
||||
...model,
|
||||
contextWindow: model.contextWindow ?? entry?.contextWindow,
|
||||
contextTokens: model.contextTokens ?? entry?.contextTokens,
|
||||
maxTokens: model.maxTokens ?? entry?.maxTokens,
|
||||
input: resolveProviderModelInput({
|
||||
provider: trimmed,
|
||||
modelId: model.id,
|
||||
modelName: model.name,
|
||||
input: model.input,
|
||||
}),
|
||||
provider: trimmed,
|
||||
modelId: model.id,
|
||||
modelName: model.name,
|
||||
input: model.input,
|
||||
}),
|
||||
provider: trimmed,
|
||||
baseUrl: requestConfig.baseUrl ?? transport.baseUrl,
|
||||
api: requestConfig.api ?? model.api,
|
||||
headers: requestConfig.headers,
|
||||
},
|
||||
providerRequest,
|
||||
baseUrl: requestConfig.baseUrl ?? transport.baseUrl,
|
||||
api: requestConfig.api ?? model.api,
|
||||
headers: requestConfig.headers,
|
||||
},
|
||||
providerRequest,
|
||||
),
|
||||
entry?.localService,
|
||||
),
|
||||
entry?.localService,
|
||||
options.providerMetadataOwners,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
shouldPreferProviderRuntimeResolvedModel,
|
||||
} from "../../plugins/provider-runtime.js";
|
||||
import { canonicalizeOpenAIModelId } from "../openai-routing.js";
|
||||
import { inheritModelProviderMetadataOwners } from "../provider-request-config.js";
|
||||
import {
|
||||
normalizeResolvedTransportApi,
|
||||
resolveProviderModelInput,
|
||||
@@ -231,10 +232,13 @@ export function normalizeResolvedModel(params: {
|
||||
normalizedInputModel.requestTimeoutMs !== undefined
|
||||
? { ...normalizedModel, requestTimeoutMs: normalizedInputModel.requestTimeoutMs }
|
||||
: normalizedModel;
|
||||
return canonicalizeLegacyResolvedModel({
|
||||
provider: params.provider,
|
||||
model: modelWithProviderTimeout,
|
||||
});
|
||||
return inheritModelProviderMetadataOwners(
|
||||
params.model,
|
||||
canonicalizeLegacyResolvedModel({
|
||||
provider: params.provider,
|
||||
model: modelWithProviderTimeout,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveProviderTransport(params: {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import type { ModelRegistry as CoreModelRegistry } from "../../llm/model-registry.js";
|
||||
import type { Model } from "../../llm/types.js";
|
||||
import type { PluginMetadataSnapshotOwnerMaps } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { ensureAuthProfileStore, resolveAuthProfileOrder } from "../auth-profiles.js";
|
||||
import type { AuthProfileCredential } from "../auth-profiles/types.js";
|
||||
import { resolveAgentHarnessPolicy } from "../harness/policy.js";
|
||||
@@ -35,6 +36,16 @@ type ExplicitModelResolution =
|
||||
| { kind: "resolved"; dropOnRuntimeMiss: boolean; model: Model; source: "registry" }
|
||||
| { kind: "suppressed" };
|
||||
|
||||
function getRegistryProviderMetadataOwners(
|
||||
modelRegistry: CoreModelRegistry,
|
||||
): PluginMetadataSnapshotOwnerMaps | undefined {
|
||||
return (
|
||||
modelRegistry as CoreModelRegistry & {
|
||||
getProviderMetadataOwners?: () => PluginMetadataSnapshotOwnerMaps | undefined;
|
||||
}
|
||||
).getProviderMetadataOwners?.();
|
||||
}
|
||||
|
||||
export function resolveExplicitModelWithRegistry(params: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
@@ -48,6 +59,7 @@ export function resolveExplicitModelWithRegistry(params: {
|
||||
preparedStaticCatalogModel?: StaticCatalogFallbackModel;
|
||||
}): ExplicitModelResolution | undefined {
|
||||
const { provider, modelId, modelRegistry, cfg, agentDir, workspaceDir, runtimeHooks } = params;
|
||||
const providerMetadataOwners = getRegistryProviderMetadataOwners(modelRegistry);
|
||||
const providerConfig = resolveConfiguredProviderConfig(cfg, provider);
|
||||
const inlineMatch = findInlineModelMatch({
|
||||
providers: cfg?.models?.providers ?? {},
|
||||
@@ -100,6 +112,7 @@ export function resolveExplicitModelWithRegistry(params: {
|
||||
modelId,
|
||||
cfg,
|
||||
manifestAlias: params.manifestAlias,
|
||||
providerMetadataOwners,
|
||||
runtimeHooks,
|
||||
workspaceDir,
|
||||
preferDiscoveredTransport: true,
|
||||
@@ -158,6 +171,7 @@ export function resolveExplicitModelWithRegistry(params: {
|
||||
modelId,
|
||||
cfg,
|
||||
manifestAlias: params.manifestAlias,
|
||||
providerMetadataOwners,
|
||||
runtimeHooks,
|
||||
workspaceDir,
|
||||
}),
|
||||
@@ -309,6 +323,7 @@ function resolvePluginDynamicModelWithRegistry(params: {
|
||||
modelId,
|
||||
cfg,
|
||||
manifestAlias: params.manifestAlias,
|
||||
providerMetadataOwners: getRegistryProviderMetadataOwners(modelRegistry),
|
||||
runtimeHooks,
|
||||
workspaceDir,
|
||||
preferDiscoveredModelMetadata,
|
||||
@@ -446,7 +461,12 @@ export function resolveModelWithPreparedRegistry(
|
||||
if (pluginDynamicModel) {
|
||||
return pluginDynamicModel;
|
||||
}
|
||||
return params.skipConfiguredFallback ? undefined : resolveConfiguredFallbackModel(params);
|
||||
return params.skipConfiguredFallback
|
||||
? undefined
|
||||
: resolveConfiguredFallbackModel({
|
||||
...params,
|
||||
providerMetadataOwners: getRegistryProviderMetadataOwners(params.modelRegistry),
|
||||
});
|
||||
}
|
||||
|
||||
export function resolveModelWithRegistry(
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { getModelProviderMetadataOwners } from "../provider-request-config.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
loadPluginManifestRegistry: vi.fn(),
|
||||
normalizePluginDiscoveryResult: vi.fn(),
|
||||
resolveBundledProviderCompatPluginIds: vi.fn(),
|
||||
resolveRuntimePluginDiscoveryProviders: vi.fn(),
|
||||
runProviderStaticCatalog: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/current-plugin-metadata-snapshot.js", () => ({
|
||||
getCurrentPluginMetadataSnapshot: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/manifest-metadata-scan.js", () => ({
|
||||
listOpenClawPluginManifestMetadata: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/manifest-owner-policy.js", () => ({
|
||||
passesManifestOwnerBasePolicy: () => true,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/manifest-registry.js", () => ({
|
||||
loadPluginManifestRegistry: mocks.loadPluginManifestRegistry,
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/manifest.js", () => ({
|
||||
loadPluginManifest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/providers.js", () => ({
|
||||
resolveActivatableProviderOwnerPluginIds: vi.fn(),
|
||||
resolveBundledProviderCompatPluginIds: mocks.resolveBundledProviderCompatPluginIds,
|
||||
resolveOwningPluginIdsForProviderRef: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../plugins/provider-discovery.js", () => ({
|
||||
normalizePluginDiscoveryResult: mocks.normalizePluginDiscoveryResult,
|
||||
resolveRuntimePluginDiscoveryProviders: mocks.resolveRuntimePluginDiscoveryProviders,
|
||||
runProviderStaticCatalog: mocks.runProviderStaticCatalog,
|
||||
}));
|
||||
|
||||
import { loadBundledProviderStaticCatalogContextModels } from "./model.static-catalog.js";
|
||||
|
||||
const cfg = { plugins: { entries: { google: { enabled: true } } } };
|
||||
const provider = {
|
||||
id: "google",
|
||||
pluginId: "google",
|
||||
label: "Google",
|
||||
auth: [],
|
||||
staticCatalog: { run: vi.fn() },
|
||||
};
|
||||
const unconfiguredProvider = {
|
||||
id: "anthropic",
|
||||
pluginId: "anthropic",
|
||||
label: "Anthropic",
|
||||
auth: [],
|
||||
staticCatalog: { run: vi.fn() },
|
||||
};
|
||||
|
||||
function createMetadataSnapshot(pluginIds: string[]): PluginMetadataSnapshot {
|
||||
return {
|
||||
manifestRegistry: {
|
||||
diagnostics: [],
|
||||
plugins: pluginIds.map((id) => ({
|
||||
id,
|
||||
origin: "bundled",
|
||||
providerDiscoverySource: `/fixtures/${id}/provider-discovery.ts`,
|
||||
})),
|
||||
},
|
||||
owners: {
|
||||
providerEndpoints: [],
|
||||
providerRequests: new Map(),
|
||||
},
|
||||
} as unknown as PluginMetadataSnapshot;
|
||||
}
|
||||
|
||||
describe("prepared bundled provider static catalogs", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["google"]);
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "google",
|
||||
origin: "bundled",
|
||||
providerDiscoverySource: "/fixtures/google/provider-discovery.ts",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("projects prepared rows without rerunning hooks", async () => {
|
||||
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([provider]);
|
||||
mocks.normalizePluginDiscoveryResult.mockReturnValue({
|
||||
google: {
|
||||
models: [
|
||||
{
|
||||
id: "gemini-3.1-pro-preview",
|
||||
name: "Gemini Pro",
|
||||
contextWindow: 1_048_576,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const metadataSnapshot = createMetadataSnapshot(["google"]);
|
||||
const models = await loadBundledProviderStaticCatalogContextModels({
|
||||
cfg,
|
||||
metadataSnapshot,
|
||||
preparedStaticProviderCatalog: {
|
||||
entries: [{ provider, result: { marker: "prepared-static-result" } as never }],
|
||||
},
|
||||
});
|
||||
|
||||
expect(models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "gemini-3.1-pro-preview",
|
||||
provider: "google",
|
||||
contextWindow: 1_048_576,
|
||||
}),
|
||||
]);
|
||||
expect(getModelProviderMetadataOwners(models[0]!)).toBe(metadataSnapshot.owners);
|
||||
expect(mocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledOnce();
|
||||
expect(mocks.runProviderStaticCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs hooks omitted from a configured-only prepared catalog on full load", async () => {
|
||||
mocks.runProviderStaticCatalog.mockResolvedValue({ marker: "full-static-result" });
|
||||
mocks.normalizePluginDiscoveryResult.mockReturnValue({
|
||||
google: {
|
||||
models: [{ id: "gemini-3.1-pro-preview", contextWindow: 1_048_576 }],
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadBundledProviderStaticCatalogContextModels({
|
||||
cfg,
|
||||
metadataSnapshot: createMetadataSnapshot(["google"]),
|
||||
preparedStaticProviderCatalog: {
|
||||
providers: [provider],
|
||||
entries: [],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: "gemini-3.1-pro-preview",
|
||||
provider: "google",
|
||||
}),
|
||||
]);
|
||||
expect(mocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
|
||||
expect(mocks.runProviderStaticCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider }),
|
||||
);
|
||||
});
|
||||
|
||||
it("discovers unconfigured providers when the full catalog is requested", async () => {
|
||||
mocks.resolveBundledProviderCompatPluginIds.mockReturnValue(["anthropic", "google"]);
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
plugins: [
|
||||
{
|
||||
id: "anthropic",
|
||||
origin: "bundled",
|
||||
providerDiscoverySource: "/fixtures/anthropic/provider-discovery.ts",
|
||||
},
|
||||
{
|
||||
id: "google",
|
||||
origin: "bundled",
|
||||
providerDiscoverySource: "/fixtures/google/provider-discovery.ts",
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([unconfiguredProvider]);
|
||||
mocks.runProviderStaticCatalog.mockResolvedValue({ marker: "unconfigured-static-result" });
|
||||
mocks.normalizePluginDiscoveryResult.mockImplementation(
|
||||
({ provider: catalogProvider }: { provider: { id: string } }) => ({
|
||||
[catalogProvider.id]: {
|
||||
models: [{ id: `${catalogProvider.id}-model`, contextWindow: 128_000 }],
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
loadBundledProviderStaticCatalogContextModels({
|
||||
cfg,
|
||||
metadataSnapshot: createMetadataSnapshot(["anthropic", "google"]),
|
||||
preparedStaticProviderCatalog: {
|
||||
providers: [provider],
|
||||
entries: [{ provider, result: { marker: "prepared-static-result" } as never }],
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
expect.objectContaining({ id: "anthropic-model", provider: "anthropic" }),
|
||||
expect.objectContaining({ id: "google-model", provider: "google" }),
|
||||
]);
|
||||
expect(mocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledOnce();
|
||||
expect(mocks.resolveRuntimePluginDiscoveryProviders).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ onlyPluginIds: ["anthropic"] }),
|
||||
);
|
||||
expect(mocks.runProviderStaticCatalog).toHaveBeenCalledOnce();
|
||||
expect(mocks.runProviderStaticCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ provider: unconfiguredProvider }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -841,12 +841,7 @@ describe("resolveBundledProviderStaticCatalogModel", () => {
|
||||
discoveryEntriesOnly: true,
|
||||
includeManifestModelCatalogProviders: false,
|
||||
});
|
||||
expect(providerMocks.runProviderStaticCatalog).toHaveBeenCalledWith({
|
||||
provider,
|
||||
config: cfg,
|
||||
workspaceDir: undefined,
|
||||
env: process.env,
|
||||
});
|
||||
expect(providerMocks.runProviderStaticCatalog).toHaveBeenCalledWith({ provider });
|
||||
});
|
||||
|
||||
it("does not load bundled provider static catalogs when owner policy blocks the plugin", async () => {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
normalizePluginDiscoveryResult,
|
||||
resolveRuntimePluginDiscoveryProviders,
|
||||
runProviderStaticCatalog,
|
||||
type PreparedProviderStaticCatalog,
|
||||
} from "../../plugins/provider-discovery.js";
|
||||
import type { ProviderRuntimeModel } from "../../plugins/provider-runtime-model.types.js";
|
||||
import {
|
||||
@@ -92,10 +93,14 @@ function modelFromProviderStaticCatalog(params: {
|
||||
provider: string;
|
||||
providerConfig: ModelProviderConfig;
|
||||
model: ModelProviderConfig["models"][number];
|
||||
providerMetadataOwners?: PluginMetadataSnapshot["owners"];
|
||||
}): ProviderRuntimeModel {
|
||||
const [model] = buildInlineProviderModels({
|
||||
[params.provider]: { ...params.providerConfig, models: [params.model] },
|
||||
});
|
||||
const [model] = buildInlineProviderModels(
|
||||
{
|
||||
[params.provider]: { ...params.providerConfig, models: [params.model] },
|
||||
},
|
||||
{ providerMetadataOwners: params.providerMetadataOwners },
|
||||
);
|
||||
return {
|
||||
...model,
|
||||
id: model?.id ?? params.model.id,
|
||||
@@ -275,6 +280,8 @@ type BundledProviderStaticCatalogResolverParams = {
|
||||
cfg?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
metadataSnapshot?: PluginMetadataSnapshot;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerIds?: readonly string[];
|
||||
};
|
||||
|
||||
@@ -403,25 +410,50 @@ async function loadBundledProviderStaticCatalogModels(params: {
|
||||
cfg?: OpenClawConfig;
|
||||
workspaceDir?: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerMetadataOwners?: PluginMetadataSnapshot["owners"];
|
||||
}): Promise<Map<string, ProviderRuntimeModel[]>> {
|
||||
const providers = await resolveRuntimePluginDiscoveryProviders({
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
onlyPluginIds: params.pluginIds,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
requireCompleteDiscoveryEntryCoverage: true,
|
||||
discoveryEntriesOnly: true,
|
||||
includeManifestModelCatalogProviders: false,
|
||||
});
|
||||
const pluginIds = new Set(params.pluginIds);
|
||||
const preparedProviders = (params.preparedStaticProviderCatalog?.providers ?? []).filter(
|
||||
(provider) => provider.pluginId !== undefined && pluginIds.has(provider.pluginId),
|
||||
);
|
||||
const preparedPluginIds = new Set(
|
||||
preparedProviders.flatMap((provider) => (provider.pluginId ? [provider.pluginId] : [])),
|
||||
);
|
||||
const missingPluginIds = params.pluginIds.filter((pluginId) => !preparedPluginIds.has(pluginId));
|
||||
// Prepared provider lists are complete only for the plugin ids they name. Full catalog reads
|
||||
// must still discover omitted plugins, while reusing cached hook results for covered providers.
|
||||
const discoveredProviders =
|
||||
missingPluginIds.length === 0
|
||||
? []
|
||||
: await resolveRuntimePluginDiscoveryProviders({
|
||||
config: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
onlyPluginIds: missingPluginIds,
|
||||
includeUntrustedWorkspacePlugins: false,
|
||||
requireCompleteDiscoveryEntryCoverage: true,
|
||||
discoveryEntriesOnly: true,
|
||||
includeManifestModelCatalogProviders: false,
|
||||
});
|
||||
const providers = [...preparedProviders, ...discoveredProviders];
|
||||
const preparedEntries = params.preparedStaticProviderCatalog?.entries.filter(
|
||||
({ provider }) => provider.pluginId !== undefined && pluginIds.has(provider.pluginId),
|
||||
);
|
||||
const preparedResults = preparedEntries
|
||||
? new Map(
|
||||
preparedEntries.map(({ provider, result }) => [
|
||||
`${provider.pluginId ?? ""}\0${normalizeProviderId(provider.id)}`,
|
||||
result,
|
||||
]),
|
||||
)
|
||||
: undefined;
|
||||
const modelsByProvider = new Map<string, ProviderRuntimeModel[]>();
|
||||
for (const catalogProvider of providers) {
|
||||
const result = await runProviderStaticCatalog({
|
||||
provider: catalogProvider,
|
||||
config: params.cfg ?? {},
|
||||
workspaceDir: params.workspaceDir,
|
||||
env: params.env,
|
||||
});
|
||||
const preparedResultKey = `${catalogProvider.pluginId ?? ""}\0${normalizeProviderId(catalogProvider.id)}`;
|
||||
const result = preparedResults?.has(preparedResultKey)
|
||||
? preparedResults.get(preparedResultKey)
|
||||
: await runProviderStaticCatalog({ provider: catalogProvider });
|
||||
const normalized = normalizePluginDiscoveryResult({
|
||||
provider: catalogProvider,
|
||||
result,
|
||||
@@ -438,6 +470,9 @@ async function loadBundledProviderStaticCatalogModels(params: {
|
||||
provider,
|
||||
providerConfig,
|
||||
model,
|
||||
...(params.providerMetadataOwners
|
||||
? { providerMetadataOwners: params.providerMetadataOwners }
|
||||
: {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
@@ -455,6 +490,7 @@ export async function loadBundledProviderStaticCatalogContextModels(
|
||||
const metadataSnapshot = resolveBundledStaticCatalogMetadataSnapshot({
|
||||
cfg: params.cfg,
|
||||
env,
|
||||
...(params.metadataSnapshot ? { metadataSnapshot: params.metadataSnapshot } : {}),
|
||||
workspaceDir: params.workspaceDir,
|
||||
});
|
||||
const discoveryEntryPluginIds = new Set(
|
||||
@@ -499,6 +535,10 @@ export async function loadBundledProviderStaticCatalogContextModels(
|
||||
cfg: params.cfg,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
...(params.preparedStaticProviderCatalog
|
||||
? { preparedStaticProviderCatalog: params.preparedStaticProviderCatalog }
|
||||
: {}),
|
||||
...(metadataSnapshot ? { providerMetadataOwners: metadataSnapshot.owners } : {}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js";
|
||||
import { isRecord } from "../utils.js";
|
||||
import {
|
||||
mergeProviders,
|
||||
@@ -35,12 +36,16 @@ type ResolveImplicitProvidersForModelsJson = (params: {
|
||||
workspaceDir?: string;
|
||||
explicitProviders: Record<string, ProviderConfig>;
|
||||
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
providerDiscoveryTimeoutMs?: number;
|
||||
providerDiscoveryEntriesOnly?: boolean;
|
||||
}) => Promise<Record<string, ProviderConfig>>;
|
||||
|
||||
/** Planned models.json write/noop/skip result plus plugin catalog sidecar writes. */
|
||||
/**
|
||||
* Planned models.json result. When present, pluginCatalogWrites is the complete
|
||||
* replacement set; omission means the plan is non-authoritative for plugin catalogs.
|
||||
*/
|
||||
type ModelsJsonPlan =
|
||||
| {
|
||||
action: "skip";
|
||||
@@ -99,6 +104,7 @@ async function resolveProvidersForModelsJsonWithDeps(
|
||||
env: NodeJS.ProcessEnv;
|
||||
workspaceDir?: string;
|
||||
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
providerDiscoveryTimeoutMs?: number;
|
||||
providerDiscoveryEntriesOnly?: boolean;
|
||||
@@ -128,6 +134,9 @@ async function resolveProvidersForModelsJsonWithDeps(
|
||||
...(params.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
...(params.preparedStaticProviderCatalog
|
||||
? { preparedStaticProviderCatalog: params.preparedStaticProviderCatalog }
|
||||
: {}),
|
||||
...(params.providerDiscoveryProviderIds
|
||||
? { providerDiscoveryProviderIds: params.providerDiscoveryProviderIds }
|
||||
: {}),
|
||||
@@ -210,6 +219,7 @@ async function planOpenClawModelsJsonWithDeps(
|
||||
existingRaw: string;
|
||||
existingParsed: unknown;
|
||||
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
providerDiscoveryTimeoutMs?: number;
|
||||
providerDiscoveryEntriesOnly?: boolean;
|
||||
@@ -228,6 +238,9 @@ async function planOpenClawModelsJsonWithDeps(
|
||||
...(params.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
...(params.preparedStaticProviderCatalog
|
||||
? { preparedStaticProviderCatalog: params.preparedStaticProviderCatalog }
|
||||
: {}),
|
||||
...(params.providerDiscoveryProviderIds
|
||||
? { providerDiscoveryProviderIds: params.providerDiscoveryProviderIds }
|
||||
: {}),
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { ProviderPlugin } from "../plugins/types.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
prepareProviderStaticCatalog: vi.fn(),
|
||||
resolveRuntimePluginDiscoveryProviders: vi.fn(),
|
||||
runProviderCatalog: vi.fn(),
|
||||
runProviderStaticCatalog: vi.fn(),
|
||||
@@ -32,9 +33,13 @@ vi.mock("../plugins/provider-discovery.js", () => ({
|
||||
provider: ProviderPlugin;
|
||||
result?: { provider?: unknown; providers?: Record<string, unknown> } | null;
|
||||
}) => result?.providers ?? (result?.provider ? { [provider.id]: result.provider } : {}),
|
||||
prepareProviderStaticCatalog: mocks.prepareProviderStaticCatalog,
|
||||
}));
|
||||
|
||||
import { resolveImplicitProviders } from "./models-config.providers.implicit.js";
|
||||
import {
|
||||
prepareImplicitProviderStaticCatalog,
|
||||
resolveImplicitProviders,
|
||||
} from "./models-config.providers.implicit.js";
|
||||
|
||||
function metadataOwners(
|
||||
overrides: Partial<PluginMetadataSnapshotOwnerMaps>,
|
||||
@@ -131,6 +136,32 @@ describe("resolveImplicitProviders startup discovery scope", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
mocks.prepareProviderStaticCatalog.mockResolvedValue({
|
||||
providers: [],
|
||||
entries: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("loads configured provider entrypoints but runs static hooks only for unresolved refs", async () => {
|
||||
const openai = createStaticOnlyProvider("openai");
|
||||
const anthropic = createStaticOnlyProvider("anthropic");
|
||||
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([openai, anthropic]);
|
||||
mocks.prepareProviderStaticCatalog.mockResolvedValue({
|
||||
providers: [anthropic],
|
||||
entries: [],
|
||||
});
|
||||
|
||||
const prepared = await prepareImplicitProviderStaticCatalog({
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
providerDiscoveryProviderIds: ["openai", "anthropic"],
|
||||
staticCatalogProviderIds: ["anthropic"],
|
||||
});
|
||||
|
||||
expect(mocks.prepareProviderStaticCatalog).toHaveBeenCalledWith({
|
||||
providers: [anthropic],
|
||||
});
|
||||
expect(prepared.providers).toEqual([openai, anthropic]);
|
||||
});
|
||||
|
||||
it("passes startup provider scopes as plugin owner filters", async () => {
|
||||
@@ -207,6 +238,99 @@ describe("resolveImplicitProviders startup discovery scope", () => {
|
||||
expect(mocks.runProviderCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses prepared static results while preserving the requesting provider scope", async () => {
|
||||
const openai = { ...createStaticOnlyProvider("openai"), pluginId: "openai" };
|
||||
const anthropic = { ...createStaticOnlyProvider("anthropic"), pluginId: "anthropic" };
|
||||
const providers = await resolveImplicitProviders({
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
explicitProviders: {},
|
||||
pluginMetadataSnapshot: {
|
||||
index: { plugins: [] } as never,
|
||||
manifestRegistry: { plugins: [], diagnostics: [] },
|
||||
owners: metadataOwners({
|
||||
providers: new Map([
|
||||
["openai", ["openai"]],
|
||||
["anthropic", ["anthropic"]],
|
||||
]),
|
||||
}),
|
||||
},
|
||||
preparedStaticProviderCatalog: {
|
||||
providers: [openai, anthropic],
|
||||
entries: [
|
||||
{
|
||||
provider: openai,
|
||||
result: {
|
||||
providers: {
|
||||
openai: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-responses",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
provider: anthropic,
|
||||
result: {
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
api: "anthropic-messages",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
providerDiscoveryEntriesOnly: true,
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
});
|
||||
|
||||
expect(Object.keys(providers ?? {})).toEqual(["openai"]);
|
||||
expect(mocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
|
||||
expect(mocks.runProviderStaticCatalog).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("runs a prepared provider's static hook when its result was not prepared", async () => {
|
||||
const anthropic = { ...createStaticOnlyProvider("anthropic"), pluginId: "anthropic" };
|
||||
mocks.runProviderStaticCatalog.mockResolvedValueOnce({
|
||||
providers: {
|
||||
anthropic: {
|
||||
baseUrl: "https://api.anthropic.com",
|
||||
api: "anthropic-messages",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const providers = await resolveImplicitProviders({
|
||||
agentDir: "/tmp/openclaw-agent",
|
||||
config: {},
|
||||
env: {} as NodeJS.ProcessEnv,
|
||||
explicitProviders: {},
|
||||
pluginMetadataSnapshot: {
|
||||
index: { plugins: [] } as never,
|
||||
manifestRegistry: { plugins: [], diagnostics: [] },
|
||||
owners: metadataOwners({
|
||||
providers: new Map([["anthropic", ["anthropic"]]]),
|
||||
}),
|
||||
},
|
||||
preparedStaticProviderCatalog: {
|
||||
providers: [anthropic],
|
||||
entries: [],
|
||||
},
|
||||
providerDiscoveryEntriesOnly: true,
|
||||
providerDiscoveryProviderIds: ["anthropic"],
|
||||
});
|
||||
|
||||
expect(Object.keys(providers ?? {})).toEqual(["anthropic"]);
|
||||
expect(mocks.resolveRuntimePluginDiscoveryProviders).not.toHaveBeenCalled();
|
||||
expect(mocks.runProviderStaticCatalog).toHaveBeenCalledWith({ provider: anthropic });
|
||||
});
|
||||
|
||||
it("uses static-only provider catalogs for scoped startup discovery", async () => {
|
||||
mocks.resolveRuntimePluginDiscoveryProviders.mockResolvedValue([
|
||||
createStaticOnlyProvider("openai"),
|
||||
|
||||
@@ -15,9 +15,11 @@ import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot
|
||||
import {
|
||||
groupPluginDiscoveryProvidersByOrder,
|
||||
normalizePluginDiscoveryResult,
|
||||
prepareProviderStaticCatalog,
|
||||
resolveRuntimePluginDiscoveryProviders,
|
||||
runProviderCatalog,
|
||||
runProviderStaticCatalog,
|
||||
type PreparedProviderStaticCatalog,
|
||||
} from "../plugins/provider-discovery.js";
|
||||
import { resolveOwningPluginIdsForProviderRef } from "../plugins/providers.js";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles/store.js";
|
||||
@@ -60,7 +62,9 @@ type ImplicitProviderParams = {
|
||||
workspaceDir?: string;
|
||||
explicitProviders?: Record<string, ProviderConfig> | null;
|
||||
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
staticCatalogProviderIds?: readonly string[];
|
||||
providerDiscoveryTimeoutMs?: number;
|
||||
providerDiscoveryEntriesOnly?: boolean;
|
||||
};
|
||||
@@ -361,6 +365,10 @@ async function resolvePluginImplicitProviders(
|
||||
ctx: ImplicitProviderContext,
|
||||
providers: import("../plugins/types.js").ProviderPlugin[],
|
||||
order: import("../plugins/types.js").ProviderCatalogOrder,
|
||||
preparedStaticResults?: ReadonlyMap<
|
||||
import("../plugins/types.js").ProviderPlugin,
|
||||
PreparedProviderStaticCatalog["entries"][number]["result"]
|
||||
>,
|
||||
): Promise<Record<string, ProviderConfig> | undefined> {
|
||||
const byOrder = groupPluginDiscoveryProvidersByOrder(providers);
|
||||
const discovered: Record<string, ProviderConfig> = {};
|
||||
@@ -413,34 +421,28 @@ async function resolvePluginImplicitProviders(
|
||||
(ctx.providerDiscoveryEntriesOnly === true || !hasRuntimeProviderCatalog(provider));
|
||||
// Static catalogs are preferred for entries-only discovery and as a fallback
|
||||
// when runtime discovery produces no usable provider config.
|
||||
let result = useStaticCatalog
|
||||
? await runProviderStaticCatalog({
|
||||
provider,
|
||||
config: catalogConfig,
|
||||
agentDir: ctx.agentDir,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
env: ctx.env,
|
||||
})
|
||||
: await runProviderCatalogWithTimeout({
|
||||
provider,
|
||||
config: catalogConfig,
|
||||
agentDir: ctx.agentDir,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
env: ctx.env,
|
||||
resolveProviderApiKey: resolveCatalogProviderApiKey,
|
||||
resolveProviderAuth: (providerId, options) =>
|
||||
ctx.resolveProviderAuth(providerId?.trim() || provider.id, options),
|
||||
timeoutMs: ctx.providerDiscoveryTimeoutMs ?? resolveLiveProviderCatalogTimeoutMs(ctx.env),
|
||||
});
|
||||
if (!result && !useStaticCatalog && provider.staticCatalog) {
|
||||
result = await runProviderStaticCatalog({
|
||||
const hasPreparedStaticResult = preparedStaticResults?.has(provider) === true;
|
||||
let result;
|
||||
if (useStaticCatalog) {
|
||||
result = hasPreparedStaticResult
|
||||
? preparedStaticResults.get(provider)
|
||||
: await runProviderStaticCatalog({ provider });
|
||||
} else {
|
||||
result = await runProviderCatalogWithTimeout({
|
||||
provider,
|
||||
config: catalogConfig,
|
||||
agentDir: ctx.agentDir,
|
||||
workspaceDir: ctx.workspaceDir,
|
||||
env: ctx.env,
|
||||
resolveProviderApiKey: resolveCatalogProviderApiKey,
|
||||
resolveProviderAuth: (providerId, options) =>
|
||||
ctx.resolveProviderAuth(providerId?.trim() || provider.id, options),
|
||||
timeoutMs: ctx.providerDiscoveryTimeoutMs ?? resolveLiveProviderCatalogTimeoutMs(ctx.env),
|
||||
});
|
||||
}
|
||||
if (!result && !useStaticCatalog && provider.staticCatalog) {
|
||||
result = await runProviderStaticCatalog({ provider });
|
||||
}
|
||||
if (!result) {
|
||||
continue;
|
||||
}
|
||||
@@ -534,6 +536,58 @@ async function runProviderCatalogWithTimeout(
|
||||
}
|
||||
}
|
||||
|
||||
/** Prepares sterile provider catalog results for one workspace/config generation. */
|
||||
export async function prepareImplicitProviderStaticCatalog(
|
||||
params: Pick<
|
||||
ImplicitProviderParams,
|
||||
| "config"
|
||||
| "env"
|
||||
| "pluginMetadataSnapshot"
|
||||
| "providerDiscoveryProviderIds"
|
||||
| "staticCatalogProviderIds"
|
||||
| "workspaceDir"
|
||||
>,
|
||||
): Promise<PreparedProviderStaticCatalog> {
|
||||
const env = params.env ?? process.env;
|
||||
const providers = await resolveRuntimePluginDiscoveryProviders({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
onlyPluginIds: resolveProviderDiscoveryFilter({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
resolveOwners: params.pluginMetadataSnapshot
|
||||
? (provider) => resolvePluginMetadataProviderOwners(params.pluginMetadataSnapshot, provider)
|
||||
: undefined,
|
||||
providerIds: params.providerDiscoveryProviderIds,
|
||||
}),
|
||||
...(params.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
discoveryEntriesOnly: true,
|
||||
includeSyntheticAuthProviders: true,
|
||||
});
|
||||
const staticCatalogProviderIds = params.staticCatalogProviderIds
|
||||
? new Set(params.staticCatalogProviderIds.map((provider) => normalizeProviderId(provider)))
|
||||
: undefined;
|
||||
const prepared = await prepareProviderStaticCatalog({
|
||||
providers: staticCatalogProviderIds
|
||||
? providers.filter((provider) =>
|
||||
[provider.id, ...(provider.aliases ?? []), ...(provider.hookAliases ?? [])].some((id) =>
|
||||
staticCatalogProviderIds.has(normalizeProviderId(id)),
|
||||
),
|
||||
)
|
||||
: providers,
|
||||
});
|
||||
// Synthetic auth consumes the complete configured provider entrypoint set. Static results may
|
||||
// be narrower because startup only executes hooks for unresolved configured model refs.
|
||||
return Object.freeze({
|
||||
providers: Object.freeze(providers),
|
||||
entries: prepared.entries,
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolve all implicit provider configs contributed by runtime plugin discovery. */
|
||||
export async function resolveImplicitProviders(
|
||||
params: ImplicitProviderParams,
|
||||
@@ -555,29 +609,82 @@ export async function resolveImplicitProviders(
|
||||
resolveProviderApiKey: createProviderApiKeyResolver(env, getAuthStore, params.config),
|
||||
resolveProviderAuth: createProviderAuthResolver(env, getAuthStore, params.config),
|
||||
};
|
||||
const discoveryProviders = await resolveRuntimePluginDiscoveryProviders({
|
||||
const discoveryPluginIds = resolveProviderDiscoveryFilter({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
onlyPluginIds: resolveProviderDiscoveryFilter({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
resolveOwners: params.pluginMetadataSnapshot
|
||||
? (provider) => resolvePluginMetadataProviderOwners(params.pluginMetadataSnapshot, provider)
|
||||
: undefined,
|
||||
providerIds: params.providerDiscoveryProviderIds,
|
||||
}),
|
||||
...(params.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
...(params.providerDiscoveryEntriesOnly === true ? { discoveryEntriesOnly: true } : {}),
|
||||
resolveOwners: params.pluginMetadataSnapshot
|
||||
? (provider) => resolvePluginMetadataProviderOwners(params.pluginMetadataSnapshot, provider)
|
||||
: undefined,
|
||||
providerIds: params.providerDiscoveryProviderIds,
|
||||
});
|
||||
|
||||
const preparedStaticEntries = params.preparedStaticProviderCatalog
|
||||
? params.preparedStaticProviderCatalog.entries.filter(
|
||||
({ provider }) =>
|
||||
discoveryPluginIds === undefined ||
|
||||
(provider.pluginId !== undefined && discoveryPluginIds.includes(provider.pluginId)),
|
||||
)
|
||||
: undefined;
|
||||
const preparedProviders =
|
||||
params.providerDiscoveryEntriesOnly === true && params.preparedStaticProviderCatalog?.providers
|
||||
? params.preparedStaticProviderCatalog.providers.filter(
|
||||
(provider) =>
|
||||
discoveryPluginIds === undefined ||
|
||||
(provider.pluginId !== undefined && discoveryPluginIds.includes(provider.pluginId)),
|
||||
)
|
||||
: [];
|
||||
const preparedPluginIds = new Set(
|
||||
preparedProviders.flatMap((provider) => (provider.pluginId ? [provider.pluginId] : [])),
|
||||
);
|
||||
const missingDiscoveryPluginIds =
|
||||
discoveryPluginIds?.filter((pluginId) => !preparedPluginIds.has(pluginId)) ??
|
||||
(preparedProviders.length > 0 ? undefined : discoveryPluginIds);
|
||||
const resolvedProviders =
|
||||
missingDiscoveryPluginIds === undefined || missingDiscoveryPluginIds.length > 0
|
||||
? await resolveRuntimePluginDiscoveryProviders({
|
||||
config: params.config,
|
||||
workspaceDir: params.workspaceDir,
|
||||
env,
|
||||
onlyPluginIds: missingDiscoveryPluginIds,
|
||||
...(params.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: params.pluginMetadataSnapshot }
|
||||
: {}),
|
||||
...(params.providerDiscoveryEntriesOnly === true ? { discoveryEntriesOnly: true } : {}),
|
||||
})
|
||||
: [];
|
||||
const discoveryProviders = [
|
||||
...new Map(
|
||||
[...resolvedProviders, ...preparedProviders].map((provider) => [
|
||||
`${provider.pluginId ?? ""}\0${normalizeProviderId(provider.id)}`,
|
||||
provider,
|
||||
]),
|
||||
).values(),
|
||||
];
|
||||
const preparedStaticResultsByProvider = new Map(
|
||||
preparedStaticEntries?.map(({ provider, result }) => [
|
||||
`${provider.pluginId ?? ""}\0${normalizeProviderId(provider.id)}`,
|
||||
result,
|
||||
]) ?? [],
|
||||
);
|
||||
const preparedStaticResults = params.preparedStaticProviderCatalog
|
||||
? new Map(
|
||||
discoveryProviders.flatMap((provider) => {
|
||||
const key = `${provider.pluginId ?? ""}\0${normalizeProviderId(provider.id)}`;
|
||||
return preparedStaticResultsByProvider.has(key)
|
||||
? [[provider, preparedStaticResultsByProvider.get(key)] as const]
|
||||
: [];
|
||||
}),
|
||||
)
|
||||
: undefined;
|
||||
for (const order of PLUGIN_DISCOVERY_ORDERS) {
|
||||
mergeImplicitProviderSet(
|
||||
providers,
|
||||
await resolvePluginImplicitProviders(context, discoveryProviders, order),
|
||||
await resolvePluginImplicitProviders(
|
||||
context,
|
||||
discoveryProviders,
|
||||
order,
|
||||
preparedStaticResults,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+104
-3
@@ -21,6 +21,7 @@ import {
|
||||
resolvePluginMetadataSnapshot,
|
||||
type PluginMetadataSnapshot,
|
||||
} from "../plugins/plugin-metadata-snapshot.js";
|
||||
import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js";
|
||||
import {
|
||||
resolveAgentWorkspaceDir,
|
||||
resolveDefaultAgentDir,
|
||||
@@ -33,11 +34,15 @@ import {
|
||||
type ModelsJsonReadyState,
|
||||
} from "./models-config-state.js";
|
||||
import { planOpenClawModelsJson } from "./models-config.plan.js";
|
||||
import { repairPluginModelCatalogTransportMetadata } from "./plugin-model-catalog-repair.js";
|
||||
import {
|
||||
decodePluginModelCatalogRelativePathPluginId,
|
||||
isGeneratedPluginModelCatalog,
|
||||
loadPersistedPluginModelCatalogs,
|
||||
loadPersistedPluginModelCatalogsReadOnly,
|
||||
replacePersistedPluginModelCatalogs,
|
||||
resolvePluginModelCatalogOwnerPluginId,
|
||||
type PersistedPluginModelCatalog,
|
||||
} from "./plugin-model-catalog.js";
|
||||
import { stableStringify } from "./stable-stringify.js";
|
||||
|
||||
@@ -49,12 +54,19 @@ type PreparedOpenClawModelsJsonSource = ModelsJsonReadyResult & {
|
||||
type EnsureOpenClawModelsJsonOptions = {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "index" | "manifestRegistry" | "owners">;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
workspaceDir?: string;
|
||||
providerDiscoveryProviderIds?: readonly string[];
|
||||
providerDiscoveryTimeoutMs?: number;
|
||||
providerDiscoveryEntriesOnly?: boolean;
|
||||
};
|
||||
|
||||
type PlannedOpenClawModelsJsonSource = Readonly<{
|
||||
agentDir: string;
|
||||
modelsJsonContents: string | null;
|
||||
pluginCatalogs: readonly PersistedPluginModelCatalog[];
|
||||
}>;
|
||||
|
||||
function listPreparedPluginModelCatalogs(agentDir: string) {
|
||||
const { catalogs, warnings } = loadPersistedPluginModelCatalogs(agentDir);
|
||||
if (warnings.length > 0) {
|
||||
@@ -167,14 +179,14 @@ if (process.env.VITEST || process.env.NODE_ENV === "test") {
|
||||
async function mergeGeneratedPluginCatalogProvidersIntoExistingParsed(params: {
|
||||
agentDir: string;
|
||||
existingParsed: unknown;
|
||||
pluginCatalogs?: readonly PersistedPluginModelCatalog[];
|
||||
pluginMetadataSnapshot?: Pick<PluginMetadataSnapshot, "owners">;
|
||||
}): Promise<unknown> {
|
||||
const root = isRecord(params.existingParsed) ? params.existingParsed : {};
|
||||
const providers = isRecord(root.providers) ? { ...root.providers } : {};
|
||||
let changed = false;
|
||||
for (const { pluginId: catalogPluginId, contents } of listPreparedPluginModelCatalogs(
|
||||
params.agentDir,
|
||||
)) {
|
||||
const pluginCatalogs = params.pluginCatalogs ?? listPreparedPluginModelCatalogs(params.agentDir);
|
||||
for (const { pluginId: catalogPluginId, contents } of pluginCatalogs) {
|
||||
let catalog: unknown;
|
||||
try {
|
||||
catalog = JSON.parse(contents) as unknown;
|
||||
@@ -206,6 +218,23 @@ async function mergeGeneratedPluginCatalogProvidersIntoExistingParsed(params: {
|
||||
return { ...root, providers };
|
||||
}
|
||||
|
||||
function materializePlannedPluginCatalogs(
|
||||
pluginCatalogWrites: Readonly<Record<string, string>>,
|
||||
): PersistedPluginModelCatalog[] {
|
||||
return Object.entries(pluginCatalogWrites)
|
||||
.map(([relativePath, contents]) => {
|
||||
const pluginId = decodePluginModelCatalogRelativePathPluginId(relativePath);
|
||||
if (!pluginId) {
|
||||
throw new Error(`Invalid generated plugin model catalog key: ${relativePath}`);
|
||||
}
|
||||
return {
|
||||
pluginId,
|
||||
contents: repairPluginModelCatalogTransportMetadata(contents).contents,
|
||||
};
|
||||
})
|
||||
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
|
||||
}
|
||||
|
||||
function writePluginCatalogsForModelsJson(params: {
|
||||
agentDir: string;
|
||||
pluginCatalogWrites?: Record<string, string>;
|
||||
@@ -360,6 +389,9 @@ async function prepareOpenClawModelsJsonSource(
|
||||
existingRaw: existingModelsFile.raw,
|
||||
existingParsed: existingParsedForMerge,
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
...(options.preparedStaticProviderCatalog
|
||||
? { preparedStaticProviderCatalog: options.preparedStaticProviderCatalog }
|
||||
: {}),
|
||||
...(options.providerDiscoveryProviderIds
|
||||
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
|
||||
: {}),
|
||||
@@ -442,6 +474,75 @@ async function prepareOpenClawModelsJsonSource(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plans the complete root/plugin catalog generation without mutating agent-owned state.
|
||||
* Control-plane inventory reads use this when their lifecycle generation may be superseded.
|
||||
*/
|
||||
export async function planOpenClawModelsJsonSource(
|
||||
config?: OpenClawConfig,
|
||||
agentDirOverride?: string,
|
||||
options: EnsureOpenClawModelsJsonOptions = {},
|
||||
): Promise<PlannedOpenClawModelsJsonSource> {
|
||||
const resolved = resolveModelsConfigInput(config);
|
||||
const cfg = resolved.config;
|
||||
const workspaceDir =
|
||||
options.workspaceDir ??
|
||||
(agentDirOverride?.trim()
|
||||
? undefined
|
||||
: resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)));
|
||||
const providerScopedDiscovery = Boolean(options.providerDiscoveryProviderIds?.length);
|
||||
const pluginMetadataSnapshot =
|
||||
options.pluginMetadataSnapshot ??
|
||||
resolvePluginMetadataSnapshot({
|
||||
config: cfg,
|
||||
env: createConfigRuntimeEnv(cfg, options.env),
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
...(providerScopedDiscovery ? { preferPersisted: false } : {}),
|
||||
});
|
||||
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveDefaultAgentDir(cfg);
|
||||
const existingModelsFile = await readExistingModelsFile(path.join(agentDir, "models.json"));
|
||||
const existingPluginCatalogs = loadPersistedPluginModelCatalogsReadOnly(agentDir);
|
||||
const existingParsedForMerge = await mergeGeneratedPluginCatalogProvidersIntoExistingParsed({
|
||||
agentDir,
|
||||
existingParsed: existingModelsFile.parsed,
|
||||
pluginCatalogs: existingPluginCatalogs,
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
});
|
||||
const env = createConfigRuntimeEnv(cfg, options.env);
|
||||
const plan = await planOpenClawModelsJson({
|
||||
cfg,
|
||||
sourceConfigForSecrets: resolved.sourceConfigForSecrets,
|
||||
agentDir,
|
||||
env,
|
||||
...(workspaceDir ? { workspaceDir } : {}),
|
||||
existingRaw: existingModelsFile.raw,
|
||||
existingParsed: existingParsedForMerge,
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
...(options.preparedStaticProviderCatalog
|
||||
? { preparedStaticProviderCatalog: options.preparedStaticProviderCatalog }
|
||||
: {}),
|
||||
...(options.providerDiscoveryProviderIds
|
||||
? { providerDiscoveryProviderIds: options.providerDiscoveryProviderIds }
|
||||
: {}),
|
||||
...(options.providerDiscoveryTimeoutMs !== undefined
|
||||
? { providerDiscoveryTimeoutMs: options.providerDiscoveryTimeoutMs }
|
||||
: {}),
|
||||
...(options.providerDiscoveryEntriesOnly === true
|
||||
? { providerDiscoveryEntriesOnly: true }
|
||||
: {}),
|
||||
});
|
||||
return {
|
||||
agentDir,
|
||||
modelsJsonContents: plan.action === "write" ? plan.contents : existingModelsFile.raw || null,
|
||||
// Planned writes share the writer's complete-replacement contract, including intentional
|
||||
// stale-catalog deletion. Only a non-authoritative plan omits this field.
|
||||
pluginCatalogs:
|
||||
plan.pluginCatalogWrites === undefined
|
||||
? existingPluginCatalogs
|
||||
: materializePlannedPluginCatalogs(plan.pluginCatalogWrites),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ensures models.json and the agent SQLite catalog cache are current. */
|
||||
export async function ensureOpenClawModelsJson(
|
||||
config?: OpenClawConfig,
|
||||
|
||||
@@ -57,6 +57,7 @@ let actualPrivateFileStore:
|
||||
installModelsConfigTestHooks();
|
||||
|
||||
let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClawModelsJson;
|
||||
let planOpenClawModelsJsonSource: typeof import("./models-config.js").planOpenClawModelsJsonSource;
|
||||
let clearCurrentPluginMetadataSnapshot: typeof import("../plugins/current-plugin-metadata-state.js").clearCurrentPluginMetadataSnapshot;
|
||||
let setCurrentPluginMetadataSnapshot: typeof import("../plugins/current-plugin-metadata-snapshot.js").setCurrentPluginMetadataSnapshot;
|
||||
|
||||
@@ -159,7 +160,7 @@ beforeAll(async () => {
|
||||
},
|
||||
};
|
||||
});
|
||||
({ ensureOpenClawModelsJson } = await import("./models-config.js"));
|
||||
({ ensureOpenClawModelsJson, planOpenClawModelsJsonSource } = await import("./models-config.js"));
|
||||
({ clearCurrentPluginMetadataSnapshot } =
|
||||
await import("../plugins/current-plugin-metadata-state.js"));
|
||||
({ setCurrentPluginMetadataSnapshot } =
|
||||
@@ -190,6 +191,54 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("models-config write serialization", () => {
|
||||
it("materializes an authoritative plugin catalog replacement without mutating state", async () => {
|
||||
await withModelsTempHome(async (home) => {
|
||||
const agentDir = path.join(home, "agent");
|
||||
const workspaceDir = path.join(home, "workspace");
|
||||
const rootContents = `${JSON.stringify({ providers: { existing: { models: [] } } })}\n`;
|
||||
const existingPluginContents = `${JSON.stringify({
|
||||
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
|
||||
providers: {},
|
||||
})}\n`;
|
||||
await fs.mkdir(agentDir, { recursive: true });
|
||||
await fs.writeFile(path.join(agentDir, "models.json"), rootContents);
|
||||
replacePersistedPluginModelCatalogs({
|
||||
agentDir,
|
||||
pluginCatalogWrites: {
|
||||
[encodePluginModelCatalogRelativePath("existing-plugin")]: existingPluginContents,
|
||||
},
|
||||
});
|
||||
const originalPluginRow = readRawCatalogCacheRow(agentDir, "existing-plugin");
|
||||
const plannedPluginContents = `${JSON.stringify({
|
||||
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
|
||||
providers: {},
|
||||
})}\n`;
|
||||
planOpenClawModelsJsonMock.mockResolvedValue({
|
||||
action: "write",
|
||||
contents: `${JSON.stringify({ providers: { discovered: { models: [] } } })}\n`,
|
||||
pluginCatalogWrites: {
|
||||
[encodePluginModelCatalogRelativePath("planned-plugin")]: plannedPluginContents,
|
||||
},
|
||||
});
|
||||
|
||||
const planned = await planOpenClawModelsJsonSource({}, agentDir, {
|
||||
workspaceDir,
|
||||
pluginMetadataSnapshot: createPluginMetadataSnapshot(workspaceDir),
|
||||
});
|
||||
|
||||
expect(planned.modelsJsonContents).toContain("discovered");
|
||||
expect(planned.pluginCatalogs).toEqual([
|
||||
{ pluginId: "planned-plugin", contents: plannedPluginContents },
|
||||
]);
|
||||
expect(await fs.readFile(path.join(agentDir, "models.json"), "utf8")).toBe(rootContents);
|
||||
expect(listPersistedPluginModelCatalogs(agentDir)).toEqual([
|
||||
{ pluginId: "existing-plugin", contents: existingPluginContents },
|
||||
]);
|
||||
expect(readRawCatalogCacheRow(agentDir, "existing-plugin")).toEqual(originalPluginRow);
|
||||
expect(writePrivateStoreTextWriteMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not reuse default workspace plugin metadata for explicit agent dirs without workspace", async () => {
|
||||
await withModelsTempHome(async (home) => {
|
||||
const snapshot = createPluginMetadataSnapshot(path.join(home, "default-workspace"));
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
decodePluginModelCatalogRelativePathPluginId,
|
||||
encodePluginModelCatalogRelativePath,
|
||||
loadPersistedPluginModelCatalogs,
|
||||
loadPersistedPluginModelCatalogsReadOnly,
|
||||
migrateLegacyPluginModelCatalogs,
|
||||
PLUGIN_MODEL_CATALOG_GENERATED_BY,
|
||||
replacePersistedPluginModelCatalogs,
|
||||
@@ -83,6 +84,31 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("SQLite-backed plugin model catalogs", () => {
|
||||
it("reads only named catalog rows without running migration or repair", () => {
|
||||
const agentDir = createAgentDir();
|
||||
const zai = catalogContents("zai");
|
||||
const anthropic = catalogContents("anthropic");
|
||||
replacePersistedPluginModelCatalogs({
|
||||
agentDir,
|
||||
pluginCatalogWrites: {
|
||||
[encodePluginModelCatalogRelativePath("zai")]: zai,
|
||||
[encodePluginModelCatalogRelativePath("anthropic")]: anthropic,
|
||||
},
|
||||
});
|
||||
const legacyPath = join(agentDir, encodePluginModelCatalogRelativePath("legacy"));
|
||||
mkdirSync(join(agentDir, "plugins", "legacy"), { recursive: true });
|
||||
writeFileSync(legacyPath, catalogContents("legacy"), "utf8");
|
||||
|
||||
expect(loadPersistedPluginModelCatalogsReadOnly(agentDir, ["zai"])).toEqual([
|
||||
{ pluginId: "zai", contents: zai },
|
||||
]);
|
||||
expect(loadPersistedPluginModelCatalogsReadOnly(agentDir)).toEqual([
|
||||
{ pluginId: "anthropic", contents: anthropic },
|
||||
{ pluginId: "zai", contents: zai },
|
||||
]);
|
||||
expect(existsSync(legacyPath)).toBe(true);
|
||||
});
|
||||
|
||||
it("removes generated model rows whose API semantics cannot be derived", () => {
|
||||
const agentDir = createAgentDir();
|
||||
const relativePath = encodePluginModelCatalogRelativePath("nvidia");
|
||||
|
||||
@@ -43,7 +43,7 @@ export function isPluginModelCatalogMigrationFile(filename: string): boolean {
|
||||
|
||||
type PluginModelCatalogDatabase = Pick<OpenClawAgentKyselyDatabase, "cache_entries">;
|
||||
|
||||
type PersistedPluginModelCatalog = {
|
||||
export type PersistedPluginModelCatalog = {
|
||||
pluginId: string;
|
||||
contents: string;
|
||||
};
|
||||
@@ -84,6 +84,25 @@ function readPersistedPluginModelCatalogs(agentDir: string): PersistedPluginMode
|
||||
return readPersistedPluginModelCatalogEntries(agentDir, PLUGIN_MODEL_CATALOG_CACHE_SCOPE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an exact plugin-catalog generation without migration or repair writes.
|
||||
* Lifecycle preparation uses this for configured providers before atomic publication.
|
||||
*/
|
||||
export function loadPersistedPluginModelCatalogsReadOnly(
|
||||
agentDir: string,
|
||||
pluginIds?: readonly string[],
|
||||
): PersistedPluginModelCatalog[] {
|
||||
if (pluginIds?.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const catalogs = readPersistedPluginModelCatalogs(agentDir);
|
||||
if (!pluginIds) {
|
||||
return catalogs;
|
||||
}
|
||||
const allowed = new Set(pluginIds);
|
||||
return catalogs.filter(({ pluginId }) => allowed.has(pluginId));
|
||||
}
|
||||
|
||||
function repairPersistedPluginModelCatalogs(params: {
|
||||
agentDir: string;
|
||||
catalogs: readonly PersistedPluginModelCatalog[];
|
||||
|
||||
@@ -105,6 +105,38 @@ describe("prepared model catalog access", () => {
|
||||
expect(mocks.releaseSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps read-only catalog reads on configured facts and materializes full reads once", async () => {
|
||||
const configuredCatalog = {
|
||||
entries: [{ provider: "test", id: "configured", name: "Configured" }],
|
||||
routeVariants: [],
|
||||
};
|
||||
const discoveredCatalog = {
|
||||
entries: [{ provider: "test", id: "discovered", name: "Discovered" }],
|
||||
routeVariants: [],
|
||||
};
|
||||
const loadFullModelCatalog = vi.fn(async () => discoveredCatalog);
|
||||
const snapshot = {
|
||||
...fullSnapshot,
|
||||
modelCatalog: configuredCatalog,
|
||||
loadFullModelCatalog,
|
||||
};
|
||||
mocks.prepareSnapshot.mockResolvedValue(snapshot);
|
||||
|
||||
await expect(loadPreparedModelCatalogSnapshot({ readOnly: true })).resolves.toBe(
|
||||
configuredCatalog,
|
||||
);
|
||||
expect(loadFullModelCatalog).not.toHaveBeenCalled();
|
||||
|
||||
await expect(loadPreparedModelCatalogSnapshot({ readOnly: false })).resolves.toBe(
|
||||
discoveredCatalog,
|
||||
);
|
||||
expect(loadFullModelCatalog).toHaveBeenCalledOnce();
|
||||
|
||||
mocks.getSnapshot.mockReturnValue(snapshot);
|
||||
expect(getPreparedModelCatalogSnapshot({ readOnly: true })).toBe(configuredCatalog);
|
||||
expect(getPreparedModelCatalogSnapshot()).toBe(configuredCatalog);
|
||||
});
|
||||
|
||||
it("carries an explicit dynamic workspace into the read-only loader", async () => {
|
||||
mocks.prepareSnapshot.mockRejectedValue(new PreparedModelRuntimeOwnerNotPublishedError());
|
||||
mocks.loadSnapshot.mockResolvedValue(readOnlySnapshot);
|
||||
|
||||
@@ -35,6 +35,19 @@ export type LoadPreparedModelCatalogParams = {
|
||||
|
||||
type PreparedModelCatalogConfigPolicy = "exact" | "published";
|
||||
|
||||
async function materializeRequestedModelCatalog(
|
||||
snapshot: PreparedModelRuntimeSnapshot,
|
||||
readOnly: boolean | undefined,
|
||||
): Promise<PreparedModelRuntimeSnapshot> {
|
||||
if (readOnly === true || !snapshot.loadFullModelCatalog) {
|
||||
return snapshot;
|
||||
}
|
||||
const modelCatalog = await snapshot.loadFullModelCatalog();
|
||||
return modelCatalog === snapshot.modelCatalog
|
||||
? snapshot
|
||||
: Object.freeze({ ...snapshot, modelCatalog });
|
||||
}
|
||||
|
||||
function acceptsPreparedSnapshotConfig(
|
||||
snapshot: PreparedModelRuntimeSnapshot,
|
||||
input: PreparedModelRuntimeInput,
|
||||
@@ -93,7 +106,7 @@ function resolveInputs(params: LoadPreparedModelCatalogParams = {}): {
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns the current published catalog without waiting or starting discovery. */
|
||||
/** Returns the configured catalog for the current generation without starting discovery. */
|
||||
export function getPreparedModelCatalogSnapshot(
|
||||
params: LoadPreparedModelCatalogParams = {},
|
||||
): ModelCatalogSnapshot | undefined {
|
||||
@@ -124,7 +137,7 @@ export function getPreparedModelCatalogSnapshot(
|
||||
: undefined;
|
||||
}
|
||||
|
||||
async function loadPreparedModelCatalogOwnerSnapshotWithPolicy(
|
||||
async function resolvePreparedModelCatalogOwnerSnapshotWithPolicy(
|
||||
params: LoadPreparedModelCatalogParams,
|
||||
configPolicy: PreparedModelCatalogConfigPolicy,
|
||||
): Promise<PreparedModelRuntimeSnapshot> {
|
||||
@@ -208,6 +221,16 @@ async function loadPreparedModelCatalogOwnerSnapshotWithPolicy(
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPreparedModelCatalogOwnerSnapshotWithPolicy(
|
||||
params: LoadPreparedModelCatalogParams,
|
||||
configPolicy: PreparedModelCatalogConfigPolicy,
|
||||
): Promise<PreparedModelRuntimeSnapshot> {
|
||||
return await materializeRequestedModelCatalog(
|
||||
await resolvePreparedModelCatalogOwnerSnapshotWithPolicy(params, configPolicy),
|
||||
params.readOnly,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolves the lifecycle owner for an exact caller-supplied config. */
|
||||
export async function loadPreparedModelCatalogOwnerSnapshot(
|
||||
params: LoadPreparedModelCatalogParams = {},
|
||||
|
||||
@@ -0,0 +1,495 @@
|
||||
import { performance } from "node:perf_hooks";
|
||||
import pLimit from "p-limit";
|
||||
import { withTimeout } from "../node-host/with-timeout.js";
|
||||
import { runTasksWithConcurrency } from "../utils/run-with-concurrency.js";
|
||||
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
|
||||
import {
|
||||
PreparedModelRuntimePublicationSupersededError,
|
||||
toPreparedModelRuntimeError,
|
||||
} from "./prepared-model-runtime.errors.js";
|
||||
import {
|
||||
fingerprintPreparedRuntimeFacts,
|
||||
prepareAgentCatalogSource,
|
||||
prepareConfiguredRuntimeFactsBatch,
|
||||
prepareFullCatalogFacts,
|
||||
preparedModelRuntimeWorkspaceFactsKey,
|
||||
prepareWorkspaceBuildGroup,
|
||||
type PreparedModelRuntimeAgentFacts,
|
||||
type PreparedModelRuntimeCatalogFacts,
|
||||
type PreparedModelRuntimeCatalogSource,
|
||||
type PreparedModelRuntimeWorkspaceFacts,
|
||||
} from "./prepared-model-runtime.facts.js";
|
||||
import type {
|
||||
PreparedModelRuntimeBuildStats,
|
||||
PreparedModelRuntimeCatalogMode,
|
||||
PreparedModelRuntimeInput,
|
||||
PreparedModelRuntimeSnapshot,
|
||||
PreparedModelRuntimeStores,
|
||||
} from "./prepared-model-runtime.types.js";
|
||||
import { AuthStorage } from "./sessions/auth-storage.js";
|
||||
|
||||
const MAX_CONCURRENT_MODEL_RUNTIME_AGENT_SOURCE_BUILDS = 2;
|
||||
const MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS = 1;
|
||||
const limitFullModelCatalogBuild = pLimit(MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS);
|
||||
|
||||
type PreparedModelRuntimeCatalogAccess = Readonly<{
|
||||
loadFullModelCatalog: () => Promise<ModelCatalogSnapshot>;
|
||||
}>;
|
||||
type PreparedModelRuntimeBuildGuards =
|
||||
| ReadonlyMap<PreparedModelRuntimeInput, () => boolean>
|
||||
| (() => boolean);
|
||||
|
||||
function runSerializedPreparedModelRuntimeTask<T>(params: {
|
||||
agentDir: string;
|
||||
agentBuildCompletions: Map<string, Promise<void>>;
|
||||
isCurrent: () => boolean;
|
||||
task: () => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const previous = params.agentBuildCompletions.get(params.agentDir);
|
||||
const pending = (async () => {
|
||||
if (previous) {
|
||||
await previous;
|
||||
}
|
||||
if (!params.isCurrent()) {
|
||||
throw new PreparedModelRuntimePublicationSupersededError(
|
||||
`prepared model runtime catalog generation was superseded for ${params.agentDir}`,
|
||||
);
|
||||
}
|
||||
return await params.task();
|
||||
})();
|
||||
const completion = pending.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
params.agentBuildCompletions.set(params.agentDir, completion);
|
||||
void completion.then(() => {
|
||||
if (params.agentBuildCompletions.get(params.agentDir) === completion) {
|
||||
params.agentBuildCompletions.delete(params.agentDir);
|
||||
}
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
function assertPreparedModelRuntimeInputCurrent(
|
||||
input: PreparedModelRuntimeInput,
|
||||
guards: PreparedModelRuntimeBuildGuards,
|
||||
): void {
|
||||
const isCurrent = typeof guards === "function" ? guards : guards.get(input);
|
||||
if (isCurrent && !isCurrent()) {
|
||||
throw new PreparedModelRuntimePublicationSupersededError(
|
||||
`prepared model runtime publication was superseded for ${input.agentDir}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertPreparedModelRuntimeInputsCurrent(
|
||||
inputs: readonly PreparedModelRuntimeInput[],
|
||||
guards: PreparedModelRuntimeBuildGuards,
|
||||
): void {
|
||||
for (const input of inputs) {
|
||||
assertPreparedModelRuntimeInputCurrent(input, guards);
|
||||
}
|
||||
}
|
||||
|
||||
function createFullModelCatalogAccess(params: {
|
||||
agentFacts: PreparedModelRuntimeAgentFacts;
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts;
|
||||
agentBuildCompletions: Map<string, Promise<void>>;
|
||||
isCurrent: () => boolean;
|
||||
eagerCatalog?: ModelCatalogSnapshot;
|
||||
}): PreparedModelRuntimeCatalogAccess {
|
||||
let fullCatalog = params.eagerCatalog;
|
||||
let pending: Promise<ModelCatalogSnapshot> | undefined;
|
||||
const assertCurrent = () => {
|
||||
if (!params.isCurrent()) {
|
||||
throw new PreparedModelRuntimePublicationSupersededError(
|
||||
`prepared model runtime catalog generation was superseded for ${params.agentFacts.input.agentDir}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
return {
|
||||
loadFullModelCatalog: () => {
|
||||
if (fullCatalog) {
|
||||
return Promise.resolve(fullCatalog);
|
||||
}
|
||||
if (!pending) {
|
||||
pending = runSerializedPreparedModelRuntimeTask({
|
||||
agentDir: params.agentFacts.input.agentDir,
|
||||
agentBuildCompletions: params.agentBuildCompletions,
|
||||
isCurrent: params.isCurrent,
|
||||
task: async () =>
|
||||
await limitFullModelCatalogBuild(async () => {
|
||||
// Full inventory belongs to explicit control-plane reads. The generation queue
|
||||
// prevents a stale plan from overlapping or following a replacement build.
|
||||
assertCurrent();
|
||||
const fullCatalogMode: PreparedModelRuntimeCatalogMode = "live";
|
||||
const liveWorkspaceFacts = (
|
||||
await prepareWorkspaceBuildGroup([params.agentFacts.input], fullCatalogMode)
|
||||
).workspaceFacts;
|
||||
assertCurrent();
|
||||
// Agent facts remain bound to the published turn generation. Auth mutations advance
|
||||
// that owner generation, so these guards reject rather than mixing credential facts.
|
||||
const catalogSource = await prepareAgentCatalogSource(
|
||||
params.agentFacts,
|
||||
liveWorkspaceFacts,
|
||||
fullCatalogMode,
|
||||
false,
|
||||
);
|
||||
assertCurrent();
|
||||
const facts = await prepareFullCatalogFacts(
|
||||
params.agentFacts,
|
||||
liveWorkspaceFacts,
|
||||
fullCatalogMode,
|
||||
catalogSource,
|
||||
);
|
||||
assertCurrent();
|
||||
fullCatalog = facts.modelCatalog;
|
||||
return fullCatalog;
|
||||
}),
|
||||
}).finally(() => {
|
||||
pending = undefined;
|
||||
});
|
||||
}
|
||||
return pending;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSnapshot(
|
||||
agentFacts: PreparedModelRuntimeAgentFacts,
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts,
|
||||
catalogFacts: PreparedModelRuntimeCatalogFacts,
|
||||
catalogAccess: PreparedModelRuntimeCatalogAccess,
|
||||
): PreparedModelRuntimeSnapshot {
|
||||
const { credentials, input } = agentFacts;
|
||||
const { mediaCapabilityProviders, messageToolCatalog, pluginMetadataSnapshot } = workspaceFacts;
|
||||
const { configuredRuntimeModels, inlineProviderModels, modelCatalog, templateModelRegistry } =
|
||||
catalogFacts;
|
||||
const createStores = (): PreparedModelRuntimeStores => {
|
||||
// Runtime API keys and session extensions mutate these objects. Fork them per run while the
|
||||
// credential map and parsed catalog remain owned by the lifecycle snapshot.
|
||||
const authStorage = AuthStorage.inMemory(credentials);
|
||||
return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) };
|
||||
};
|
||||
return Object.freeze({
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
agentDir: input.agentDir,
|
||||
activeProjectKeys: [],
|
||||
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
config: input.config,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
...(messageToolCatalog ? { messageToolCatalog } : {}),
|
||||
...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}),
|
||||
modelCatalog,
|
||||
loadFullModelCatalog: catalogAccess.loadFullModelCatalog,
|
||||
configuredRuntimeModels,
|
||||
inlineProviderModels,
|
||||
createStores,
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSnapshotBatch(
|
||||
inputs: readonly PreparedModelRuntimeInput[],
|
||||
catalogMode: PreparedModelRuntimeCatalogMode,
|
||||
agentBuildCompletions: Map<string, Promise<void>>,
|
||||
generationGuards: ReadonlyMap<PreparedModelRuntimeInput, () => boolean>,
|
||||
buildGuards: PreparedModelRuntimeBuildGuards,
|
||||
onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void,
|
||||
): Promise<PreparedModelRuntimeSnapshot[]> {
|
||||
const groups = new Map<string, PreparedModelRuntimeInput[]>();
|
||||
for (const input of inputs) {
|
||||
const key = preparedModelRuntimeWorkspaceFactsKey(input);
|
||||
const group = groups.get(key);
|
||||
if (group) {
|
||||
group.push(input);
|
||||
} else {
|
||||
groups.set(key, [input]);
|
||||
}
|
||||
}
|
||||
const preparedInputs = new Map<PreparedModelRuntimeInput, PreparedModelRuntimeAgentFacts>();
|
||||
const workspaceFacts = new Map<string, PreparedModelRuntimeWorkspaceFacts>();
|
||||
const workspaceKeys = new Map<PreparedModelRuntimeInput, string>();
|
||||
let runtimePluginMs = 0;
|
||||
let pluginMetadataMs = 0;
|
||||
let staticProviderCatalogMs = 0;
|
||||
let ambientCredentialsMs = 0;
|
||||
let agentFactsMs = 0;
|
||||
let configuredProjectionMs = 0;
|
||||
const workspaceFactsStartedAt = performance.now();
|
||||
// Workspace plugin loading and static hooks are intentionally sequential. Large parallel
|
||||
// workspace fanout recreates the CPU/RSS spike this generation boundary is meant to contain.
|
||||
for (const [key, group] of groups) {
|
||||
if (typeof buildGuards === "function") {
|
||||
assertPreparedModelRuntimeInputsCurrent(group, buildGuards);
|
||||
}
|
||||
const prepared = await prepareWorkspaceBuildGroup(group, catalogMode);
|
||||
assertPreparedModelRuntimeInputsCurrent(group, buildGuards);
|
||||
workspaceFacts.set(key, prepared.workspaceFacts);
|
||||
runtimePluginMs += prepared.buildStats.runtimePluginMs;
|
||||
pluginMetadataMs += prepared.buildStats.pluginMetadataMs;
|
||||
staticProviderCatalogMs += prepared.buildStats.staticProviderCatalogMs;
|
||||
ambientCredentialsMs += prepared.buildStats.ambientCredentialsMs;
|
||||
agentFactsMs += prepared.buildStats.agentFactsMs;
|
||||
configuredProjectionMs += prepared.buildStats.configuredProjectionMs;
|
||||
for (const agentFacts of prepared.agentFacts) {
|
||||
preparedInputs.set(agentFacts.input, agentFacts);
|
||||
workspaceKeys.set(agentFacts.input, key);
|
||||
}
|
||||
}
|
||||
const workspaceFactsMs = performance.now() - workspaceFactsStartedAt;
|
||||
const catalogSourceStartedAt = performance.now();
|
||||
const catalogSources = new Map<PreparedModelRuntimeInput, PreparedModelRuntimeCatalogSource>();
|
||||
if (catalogMode === "live") {
|
||||
const sourceInputsByAgentDir = new Map<string, PreparedModelRuntimeInput[]>();
|
||||
for (const input of inputs) {
|
||||
const group = sourceInputsByAgentDir.get(input.agentDir);
|
||||
if (group) {
|
||||
group.push(input);
|
||||
} else {
|
||||
sourceInputsByAgentDir.set(input.agentDir, [input]);
|
||||
}
|
||||
}
|
||||
const sourceErrors: unknown[] = [];
|
||||
const sourceBuild = await runTasksWithConcurrency({
|
||||
limit: MAX_CONCURRENT_MODEL_RUNTIME_AGENT_SOURCE_BUILDS,
|
||||
errorMode: "stop",
|
||||
onTaskError: (error) => {
|
||||
sourceErrors.push(error);
|
||||
},
|
||||
tasks: [...sourceInputsByAgentDir.values()].map((sourceInputs) => async () => {
|
||||
// Generated catalogs are agent-directory owned. Preserve write serialization within one
|
||||
// directory while allowing bounded progress across distinct agents.
|
||||
for (const input of sourceInputs) {
|
||||
const prepared = preparedInputs.get(input);
|
||||
const workspaceKey = workspaceKeys.get(input);
|
||||
const facts = workspaceKey ? workspaceFacts.get(workspaceKey) : undefined;
|
||||
if (!prepared) {
|
||||
throw new Error(`prepared model runtime agent facts missing for ${input.agentDir}`);
|
||||
}
|
||||
if (!facts) {
|
||||
throw new Error(`prepared model runtime workspace facts missing for ${input.agentDir}`);
|
||||
}
|
||||
// A replacement waits for this batch's completion. Stop the stale batch before another
|
||||
// same-directory write so a superseded generation cannot overwrite catalog state.
|
||||
assertPreparedModelRuntimeInputCurrent(input, buildGuards);
|
||||
const catalogSource = await prepareAgentCatalogSource(prepared, facts, catalogMode);
|
||||
assertPreparedModelRuntimeInputCurrent(input, buildGuards);
|
||||
catalogSources.set(input, catalogSource);
|
||||
}
|
||||
}),
|
||||
});
|
||||
if (sourceBuild.hasError) {
|
||||
// A superseded owner is lifecycle control flow. Preserve any genuine in-flight sibling
|
||||
// failure so auth refresh diagnostics do not disappear behind that expected cancellation.
|
||||
throw toPreparedModelRuntimeError(
|
||||
sourceErrors.find(
|
||||
(error) => !(error instanceof PreparedModelRuntimePublicationSupersededError),
|
||||
) ?? sourceBuild.firstError,
|
||||
);
|
||||
}
|
||||
}
|
||||
const catalogSourceMs = performance.now() - catalogSourceStartedAt;
|
||||
const preparedCatalogs = new Map<PreparedModelRuntimeInput, PreparedModelRuntimeCatalogFacts>();
|
||||
let runtimeRegistryCount = 0;
|
||||
const registryStartedAt = performance.now();
|
||||
if (catalogMode === "live") {
|
||||
// Explicit live owners still request the complete inventory. Keep those builds sequential
|
||||
// instead of multiplying heap and GC pressure when a command names several agents.
|
||||
for (const input of inputs) {
|
||||
const agentFacts = preparedInputs.get(input);
|
||||
const workspaceKey = workspaceKeys.get(input);
|
||||
const facts = workspaceKey ? workspaceFacts.get(workspaceKey) : undefined;
|
||||
if (!agentFacts || !facts) {
|
||||
throw new Error(`prepared model runtime facts missing for ${input.agentDir}`);
|
||||
}
|
||||
const catalogSource = catalogSources.get(input);
|
||||
if (!catalogSource) {
|
||||
throw new Error(`prepared model runtime catalog source missing for ${input.agentDir}`);
|
||||
}
|
||||
assertPreparedModelRuntimeInputCurrent(input, buildGuards);
|
||||
preparedCatalogs.set(
|
||||
input,
|
||||
await prepareFullCatalogFacts(agentFacts, facts, catalogMode, catalogSource),
|
||||
);
|
||||
assertPreparedModelRuntimeInputCurrent(input, buildGuards);
|
||||
runtimeRegistryCount += 1;
|
||||
}
|
||||
} else {
|
||||
for (const [workspaceKey, group] of groups) {
|
||||
assertPreparedModelRuntimeInputsCurrent(group, buildGuards);
|
||||
const facts = workspaceFacts.get(workspaceKey);
|
||||
if (!facts) {
|
||||
throw new Error(`prepared model runtime workspace facts missing for ${workspaceKey}`);
|
||||
}
|
||||
const batch = prepareConfiguredRuntimeFactsBatch({
|
||||
agentFacts: group.map((input) => {
|
||||
const agentFacts = preparedInputs.get(input);
|
||||
if (!agentFacts) {
|
||||
throw new Error(`prepared model runtime facts missing for ${input.agentDir}`);
|
||||
}
|
||||
return agentFacts;
|
||||
}),
|
||||
workspaceFacts: facts,
|
||||
});
|
||||
runtimeRegistryCount += batch.registryCount;
|
||||
for (const [input, catalogFacts] of batch.catalogs) {
|
||||
preparedCatalogs.set(input, catalogFacts);
|
||||
}
|
||||
assertPreparedModelRuntimeInputsCurrent(group, buildGuards);
|
||||
}
|
||||
}
|
||||
const registryMs = performance.now() - registryStartedAt;
|
||||
const preparedAgentFacts = [...preparedInputs.values()];
|
||||
const configuredRuntimeModelCount = preparedAgentFacts.reduce(
|
||||
(count, facts) => count + facts.configuredRuntimeModels.length,
|
||||
0,
|
||||
);
|
||||
const generatedCatalogPluginCount = new Set(
|
||||
preparedAgentFacts.flatMap((facts) => facts.configuredGeneratedCatalogPluginIds),
|
||||
).size;
|
||||
const generatedCatalogReadCount = preparedAgentFacts.reduce(
|
||||
(count, facts) => count + facts.configuredGeneratedCatalogPluginIds.length,
|
||||
0,
|
||||
);
|
||||
onBuildStats?.({
|
||||
agentCount: inputs.length,
|
||||
workspaceGroupCount: groups.size,
|
||||
configuredFactsGroupCount: groups.size,
|
||||
catalogSourceCount:
|
||||
catalogMode === "live"
|
||||
? [...preparedInputs.values()].filter(({ input }) => !input.readOnly).length
|
||||
: 0,
|
||||
credentialGroupCount: new Set(
|
||||
[...preparedInputs.values()].map((agentFacts) =>
|
||||
fingerprintPreparedRuntimeFacts(agentFacts.credentials),
|
||||
),
|
||||
).size,
|
||||
catalogGroupCount: catalogMode === "live" ? inputs.length : 0,
|
||||
runtimeRegistryCount,
|
||||
configuredRuntimeModelCount,
|
||||
generatedCatalogPluginCount,
|
||||
generatedCatalogReadCount,
|
||||
workspaceFactsMs,
|
||||
runtimePluginMs,
|
||||
pluginMetadataMs,
|
||||
staticProviderCatalogMs,
|
||||
ambientCredentialsMs,
|
||||
agentFactsMs,
|
||||
configuredProjectionMs,
|
||||
catalogSourceMs,
|
||||
registryMs,
|
||||
sourceConcurrencyLimit: MAX_CONCURRENT_MODEL_RUNTIME_AGENT_SOURCE_BUILDS,
|
||||
fullCatalogConcurrencyLimit: MAX_CONCURRENT_FULL_MODEL_CATALOG_BUILDS,
|
||||
});
|
||||
assertPreparedModelRuntimeInputsCurrent(inputs, buildGuards);
|
||||
return inputs.map((input) => {
|
||||
const agentFacts = preparedInputs.get(input);
|
||||
const workspaceKey = workspaceKeys.get(input);
|
||||
const facts = workspaceKey ? workspaceFacts.get(workspaceKey) : undefined;
|
||||
const catalogFacts = preparedCatalogs.get(input);
|
||||
if (!agentFacts || !facts || !catalogFacts) {
|
||||
throw new Error(`prepared model runtime snapshot facts missing for ${input.agentDir}`);
|
||||
}
|
||||
return createSnapshot(
|
||||
agentFacts,
|
||||
facts,
|
||||
catalogFacts,
|
||||
createFullModelCatalogAccess({
|
||||
agentFacts,
|
||||
workspaceFacts: facts,
|
||||
agentBuildCompletions,
|
||||
isCurrent: generationGuards.get(input) ?? (() => false),
|
||||
...(catalogMode === "live" ? { eagerCatalog: catalogFacts.modelCatalog } : {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function startSerializedSnapshotBuildBatch(
|
||||
inputs: readonly PreparedModelRuntimeInput[],
|
||||
agentBuildCompletions: Map<string, Promise<void>>,
|
||||
buildTimeoutMs: number,
|
||||
catalogMode: PreparedModelRuntimeCatalogMode = "live",
|
||||
onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void,
|
||||
generationGuards: ReadonlyMap<PreparedModelRuntimeInput, () => boolean> = new Map(),
|
||||
buildGuards: PreparedModelRuntimeBuildGuards = generationGuards,
|
||||
): {
|
||||
pending: Promise<PreparedModelRuntimeSnapshot[]>;
|
||||
completion: Promise<void>;
|
||||
} {
|
||||
const agentDirs = [...new Set(inputs.map((input) => input.agentDir))];
|
||||
const previousBuildCompletions = [
|
||||
...new Set(
|
||||
agentDirs
|
||||
.map((agentDir) => agentBuildCompletions.get(agentDir))
|
||||
.filter((completion): completion is Promise<void> => completion !== undefined),
|
||||
),
|
||||
];
|
||||
// Lifecycle events may overlap. The timeout covers queueing plus this build, while completion
|
||||
// follows the real work so a timed-out generation can never overlap a replacement.
|
||||
const startBuild = (async () => {
|
||||
if (previousBuildCompletions.length > 0) {
|
||||
await Promise.all(previousBuildCompletions);
|
||||
}
|
||||
return {
|
||||
actualBuild: buildSnapshotBatch(
|
||||
inputs,
|
||||
catalogMode,
|
||||
agentBuildCompletions,
|
||||
generationGuards,
|
||||
buildGuards,
|
||||
onBuildStats,
|
||||
),
|
||||
};
|
||||
})();
|
||||
const completion = startBuild
|
||||
.then(async ({ actualBuild }) => await actualBuild)
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
for (const agentDir of agentDirs) {
|
||||
agentBuildCompletions.set(agentDir, completion);
|
||||
void completion.then(() => {
|
||||
if (agentBuildCompletions.get(agentDir) === completion) {
|
||||
agentBuildCompletions.delete(agentDir);
|
||||
}
|
||||
});
|
||||
}
|
||||
return {
|
||||
pending: withTimeout(
|
||||
async () => {
|
||||
const { actualBuild } = await startBuild;
|
||||
return await actualBuild;
|
||||
},
|
||||
buildTimeoutMs,
|
||||
"prepared model runtime publication",
|
||||
),
|
||||
completion,
|
||||
};
|
||||
}
|
||||
|
||||
export function startSerializedSnapshotBuild(
|
||||
input: PreparedModelRuntimeInput,
|
||||
agentBuildCompletions: Map<string, Promise<void>>,
|
||||
buildTimeoutMs: number,
|
||||
catalogMode: PreparedModelRuntimeCatalogMode = "live",
|
||||
generationGuard: () => boolean = () => true,
|
||||
): {
|
||||
pending: Promise<PreparedModelRuntimeSnapshot>;
|
||||
completion: Promise<void>;
|
||||
} {
|
||||
const build = startSerializedSnapshotBuildBatch(
|
||||
[input],
|
||||
agentBuildCompletions,
|
||||
buildTimeoutMs,
|
||||
catalogMode,
|
||||
undefined,
|
||||
new Map([[input, generationGuard]]),
|
||||
);
|
||||
return {
|
||||
pending: build.pending.then((snapshots) => snapshots[0]!),
|
||||
completion: build.completion,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import {
|
||||
collectConfiguredModelRefs,
|
||||
type ConfiguredModelRef,
|
||||
} from "@openclaw/model-catalog-core/configured-model-refs";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import { MODEL_APIS } from "../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import {
|
||||
normalizePluginDiscoveryResult,
|
||||
type PreparedProviderStaticCatalog,
|
||||
} from "../plugins/provider-discovery.js";
|
||||
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
|
||||
import { resolveAgentEntry } from "./agent-scope-config.js";
|
||||
import { buildInlineProviderModels } from "./embedded-agent-runner/model.inline-provider.js";
|
||||
import { staticModelIdMatches } from "./embedded-agent-runner/model.static-id.js";
|
||||
import type { ModelCatalogEntry } from "./model-catalog.js";
|
||||
import type { AuthStorageData } from "./sessions/auth-storage.js";
|
||||
|
||||
export type PreparedConfiguredRuntimeModel = Readonly<{
|
||||
provider: string;
|
||||
modelId: string;
|
||||
model: ProviderRuntimeModel;
|
||||
}>;
|
||||
|
||||
/** Collects defaults, global refs, and only the selected agent's overrides. */
|
||||
export function collectPreparedModelRuntimeConfiguredRefs(
|
||||
config: OpenClawConfig,
|
||||
agentId: string | undefined,
|
||||
): ConfiguredModelRef[] {
|
||||
if (!agentId) {
|
||||
return collectConfiguredModelRefs(config);
|
||||
}
|
||||
const entry = resolveAgentEntry(config, agentId);
|
||||
return collectConfiguredModelRefs({
|
||||
...config,
|
||||
agents: {
|
||||
...(config.agents?.defaults ? { defaults: config.agents.defaults } : {}),
|
||||
list: entry ? [entry] : [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isCatalogModelApi(
|
||||
value: string | undefined,
|
||||
): value is NonNullable<ModelCatalogEntry["api"]> {
|
||||
return value !== undefined && (MODEL_APIS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
export function toStaticCatalogEntry(model: ProviderRuntimeModel): ModelCatalogEntry {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
provider: model.provider,
|
||||
...(isCatalogModelApi(model.api) ? { api: model.api } : {}),
|
||||
...(model.baseUrl ? { baseUrl: model.baseUrl } : {}),
|
||||
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
|
||||
...(model.contextTokens ? { contextTokens: model.contextTokens } : {}),
|
||||
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
|
||||
...(model.input ? { input: model.input } : {}),
|
||||
...(model.params ? { params: model.params } : {}),
|
||||
...(model.compat ? { compat: model.compat } : {}),
|
||||
...(model.mediaInput ? { mediaInput: model.mediaInput } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function collectPreparedModelRuntimeProviderIds(
|
||||
config: OpenClawConfig,
|
||||
credentials: Readonly<AuthStorageData>,
|
||||
includeCredentialProviders: boolean,
|
||||
configuredModelRefs: readonly ConfiguredModelRef[] = collectConfiguredModelRefs(config),
|
||||
): string[] {
|
||||
const providerIds = new Set<string>();
|
||||
const addProviderId = (value: string) => {
|
||||
const providerId = normalizeProviderId(value);
|
||||
if (providerId) {
|
||||
providerIds.add(providerId);
|
||||
}
|
||||
};
|
||||
if (includeCredentialProviders) {
|
||||
for (const providerId of Object.keys(credentials)) {
|
||||
addProviderId(providerId);
|
||||
}
|
||||
}
|
||||
for (const providerId of Object.keys(config.models?.providers ?? {})) {
|
||||
addProviderId(providerId);
|
||||
}
|
||||
for (const ref of configuredModelRefs) {
|
||||
const separator = ref.value.indexOf("/");
|
||||
if (separator > 0) {
|
||||
addProviderId(ref.value.slice(0, separator));
|
||||
}
|
||||
}
|
||||
return [...providerIds].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function hasConfiguredInlineProviderModel(
|
||||
config: OpenClawConfig,
|
||||
provider: string,
|
||||
modelId: string,
|
||||
): boolean {
|
||||
return Object.entries(config.models?.providers ?? {}).some(
|
||||
([providerId, providerConfig]) =>
|
||||
normalizeProviderId(providerId) === provider &&
|
||||
(providerConfig.models ?? []).some((model) =>
|
||||
staticModelIdMatches({
|
||||
candidateId: model.id,
|
||||
rowProvider: providerId,
|
||||
provider,
|
||||
modelId,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function collectConfiguredProviderIdsNeedingStaticCatalog(params: {
|
||||
config: OpenClawConfig;
|
||||
configuredModelRefs?: readonly ConfiguredModelRef[];
|
||||
resolveStaticCatalogModel: (lookup: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}) => ProviderRuntimeModel | undefined;
|
||||
}): string[] {
|
||||
const providerIds = new Set<string>();
|
||||
for (const { value } of params.configuredModelRefs ?? collectConfiguredModelRefs(params.config)) {
|
||||
const separator = value.indexOf("/");
|
||||
if (separator <= 0 || separator >= value.length - 1) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(value.slice(0, separator));
|
||||
const modelId = value.slice(separator + 1).trim();
|
||||
if (
|
||||
!provider ||
|
||||
!modelId ||
|
||||
hasConfiguredInlineProviderModel(params.config, provider, modelId) ||
|
||||
params.resolveStaticCatalogModel({ provider, modelId })
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
providerIds.add(provider);
|
||||
}
|
||||
return [...providerIds].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
export function prepareConfiguredRuntimeModels(params: {
|
||||
config: OpenClawConfig;
|
||||
configuredModelRefs?: readonly ConfiguredModelRef[];
|
||||
metadataSnapshot: PluginMetadataSnapshot;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerStaticModels: readonly ProviderRuntimeModel[];
|
||||
resolveStaticCatalogModel: (lookup: {
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}) => ProviderRuntimeModel | undefined;
|
||||
}): PreparedConfiguredRuntimeModel[] {
|
||||
const prepared: PreparedConfiguredRuntimeModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
for (const { value } of params.configuredModelRefs ?? collectConfiguredModelRefs(params.config)) {
|
||||
const separator = value.indexOf("/");
|
||||
if (separator <= 0 || separator >= value.length - 1) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(value.slice(0, separator));
|
||||
const modelId = value.slice(separator + 1).trim();
|
||||
if (!provider || !modelId) {
|
||||
continue;
|
||||
}
|
||||
const key = `${provider}\0${modelId.toLowerCase()}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
// Match request-time fallback precedence exactly: manifest/runtime-discovery rows win,
|
||||
// and the provider-static catalog fills only models absent from that surface.
|
||||
const model =
|
||||
params.resolveStaticCatalogModel({ provider, modelId }) ??
|
||||
findPreparedProviderStaticCatalogModel({
|
||||
prepared: params.preparedStaticProviderCatalog,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
provider,
|
||||
modelId,
|
||||
}) ??
|
||||
params.providerStaticModels.find((candidate) =>
|
||||
staticModelIdMatches({
|
||||
candidateId: candidate.id,
|
||||
rowProvider: candidate.provider,
|
||||
provider,
|
||||
modelId,
|
||||
}),
|
||||
);
|
||||
if (model) {
|
||||
prepared.push({ provider, modelId, model });
|
||||
}
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
function findPreparedProviderStaticCatalogModel(params: {
|
||||
prepared: PreparedProviderStaticCatalog | undefined;
|
||||
metadataSnapshot: PluginMetadataSnapshot;
|
||||
provider: string;
|
||||
modelId: string;
|
||||
}): ProviderRuntimeModel | undefined {
|
||||
if (!params.prepared) {
|
||||
return undefined;
|
||||
}
|
||||
for (const { provider, result } of params.prepared.entries) {
|
||||
for (const [providerId, providerConfig] of Object.entries(
|
||||
normalizePluginDiscoveryResult({ provider, result }),
|
||||
)) {
|
||||
const model = (providerConfig.models ?? []).find((candidate) =>
|
||||
staticModelIdMatches({
|
||||
candidateId: candidate.id,
|
||||
rowProvider: providerId,
|
||||
provider: params.provider,
|
||||
modelId: params.modelId,
|
||||
}),
|
||||
);
|
||||
if (!model) {
|
||||
continue;
|
||||
}
|
||||
const [resolved] = buildInlineProviderModels(
|
||||
{ [providerId]: { ...providerConfig, models: [model] } },
|
||||
{ providerMetadataOwners: params.metadataSnapshot.owners },
|
||||
);
|
||||
if (resolved) {
|
||||
return resolved as ProviderRuntimeModel;
|
||||
}
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
export class PreparedModelRuntimeOwnerNotPublishedError extends Error {}
|
||||
|
||||
export class PreparedModelRuntimePublicationSupersededError extends PreparedModelRuntimeOwnerNotPublishedError {}
|
||||
|
||||
export function toPreparedModelRuntimeError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -0,0 +1,688 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type { ConfiguredModelRef } from "@openclaw/model-catalog-core/configured-model-refs";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js";
|
||||
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
|
||||
import { sha256Base64Url } from "../infra/crypto-digest.js";
|
||||
import { prepareMediaCapabilityProviders } from "../plugins/capability-provider-runtime.js";
|
||||
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import {
|
||||
getPreparedMessageToolCatalog,
|
||||
getPreparedMessageToolCatalogForRegistry,
|
||||
} from "../plugins/prepared-message-tool-catalog.js";
|
||||
import type { PreparedProviderStaticCatalog } from "../plugins/provider-discovery.js";
|
||||
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
|
||||
import { resolveRuntimeSyntheticAuthProviderRefs } from "../plugins/synthetic-auth.runtime.js";
|
||||
import type { ProviderPlugin } from "../plugins/types.js";
|
||||
import type { AgentCredentialMap } from "./agent-auth-credentials.js";
|
||||
import { resolveAmbientAgentCredentialsForDiscovery } from "./agent-auth-discovery.js";
|
||||
import {
|
||||
discoverAuthStorage,
|
||||
discoverModels,
|
||||
discoverModelsFromCapturedSources,
|
||||
} from "./agent-model-discovery.js";
|
||||
import {
|
||||
buildInlineProviderModels,
|
||||
type InlineModelEntry,
|
||||
} from "./embedded-agent-runner/model.inline-provider.js";
|
||||
import {
|
||||
createBundledStaticCatalogModelResolver,
|
||||
loadBundledProviderStaticCatalogContextModels,
|
||||
} from "./embedded-agent-runner/model.static-catalog.js";
|
||||
import { buildPreparedModelCatalogSnapshot, type ModelCatalogEntry } from "./model-catalog.js";
|
||||
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
|
||||
import { buildConfiguredModelCatalog } from "./model-selection-shared.js";
|
||||
import { ensureOpenClawModelsJson, planOpenClawModelsJsonSource } from "./models-config.js";
|
||||
import { prepareImplicitProviderStaticCatalog } from "./models-config.providers.implicit.js";
|
||||
import {
|
||||
loadPersistedPluginModelCatalogsReadOnly,
|
||||
resolvePluginModelCatalogOwnerPluginId,
|
||||
type PersistedPluginModelCatalog,
|
||||
} from "./plugin-model-catalog.js";
|
||||
import {
|
||||
collectPreparedModelRuntimeConfiguredRefs,
|
||||
collectConfiguredProviderIdsNeedingStaticCatalog,
|
||||
collectPreparedModelRuntimeProviderIds,
|
||||
prepareConfiguredRuntimeModels,
|
||||
toStaticCatalogEntry,
|
||||
type PreparedConfiguredRuntimeModel,
|
||||
} from "./prepared-model-runtime.configured.js";
|
||||
import type {
|
||||
PreparedModelRuntimeBuildStats,
|
||||
PreparedModelRuntimeCatalogMode,
|
||||
PreparedModelRuntimeInput,
|
||||
} from "./prepared-model-runtime.types.js";
|
||||
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
|
||||
import type { AuthStorage, AuthStorageData } from "./sessions/auth-storage.js";
|
||||
import type { ModelRegistry } from "./sessions/model-registry.js";
|
||||
import { stableStringify } from "./stable-stringify.js";
|
||||
|
||||
const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
type PreparedModelRuntimeAgentBaseFacts = {
|
||||
input: PreparedModelRuntimeInput;
|
||||
env: NodeJS.ProcessEnv;
|
||||
templateAuthStorage: AuthStorage;
|
||||
credentials: Readonly<AuthStorageData>;
|
||||
providerIds: string[];
|
||||
configuredModelRefs: readonly ConfiguredModelRef[];
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeAgentFacts = PreparedModelRuntimeAgentBaseFacts & {
|
||||
configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[];
|
||||
configuredGeneratedCatalogPluginIds: readonly string[];
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeWorkspaceFacts = {
|
||||
pluginMetadataSnapshot: PluginMetadataSnapshot;
|
||||
messageToolCatalog?: PreparedMessageToolCatalog;
|
||||
mediaCapabilityProviders?: ReturnType<typeof prepareMediaCapabilityProviders>;
|
||||
preparedStaticProviderCatalog?: PreparedProviderStaticCatalog;
|
||||
providerStaticModels?: readonly ProviderRuntimeModel[];
|
||||
providerStaticModelsComplete: boolean;
|
||||
inlineProviderModels: readonly InlineModelEntry[];
|
||||
configuredCatalogEntries: readonly ModelCatalogEntry[];
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeCatalogFacts = {
|
||||
templateModelRegistry: ModelRegistry;
|
||||
modelCatalog: ModelCatalogSnapshot;
|
||||
configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[];
|
||||
inlineProviderModels: readonly InlineModelEntry[];
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeCatalogSource = Readonly<{
|
||||
modelsJsonContents: string | null;
|
||||
pluginCatalogs: readonly PersistedPluginModelCatalog[];
|
||||
}>;
|
||||
|
||||
type PreparedConfiguredRegistryGroup = {
|
||||
agentFacts: PreparedModelRuntimeAgentFacts[];
|
||||
modelsJsonContents: string | null;
|
||||
oauthProviders: ReturnType<AuthStorage["getOAuthProviders"]>;
|
||||
pluginCatalogs: readonly PersistedPluginModelCatalog[];
|
||||
};
|
||||
|
||||
function prepareAgentFacts(
|
||||
input: PreparedModelRuntimeInput,
|
||||
catalogMode: PreparedModelRuntimeCatalogMode,
|
||||
ambientCredentials: Readonly<AgentCredentialMap>,
|
||||
): PreparedModelRuntimeAgentBaseFacts {
|
||||
const env = input.env ?? process.env;
|
||||
const templateAuthStorage = discoverAuthStorage(input.agentDir, {
|
||||
config: input.config,
|
||||
// Snapshot construction never initializes, migrates, or externally syncs auth. ModelRegistry
|
||||
// discovery only parses the credential generation captured here.
|
||||
readOnly: true,
|
||||
ambientCredentials,
|
||||
...(input.skipCredentials ? { skipCredentials: true } : {}),
|
||||
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
...(input.env ? { env } : {}),
|
||||
});
|
||||
const credentials = templateAuthStorage.getAll();
|
||||
const configuredModelRefs = collectPreparedModelRuntimeConfiguredRefs(
|
||||
input.config,
|
||||
input.agentId,
|
||||
);
|
||||
return {
|
||||
input,
|
||||
env,
|
||||
templateAuthStorage,
|
||||
credentials,
|
||||
configuredModelRefs,
|
||||
// Gateway startup prepares only providers named by config/model selection. An unrelated
|
||||
// stored credential must not pull that provider's complete catalog into the admission path.
|
||||
providerIds: collectPreparedModelRuntimeProviderIds(
|
||||
input.config,
|
||||
credentials,
|
||||
catalogMode === "live",
|
||||
configuredModelRefs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function listPreparedSyntheticAuthProviderRefs(providers: readonly ProviderPlugin[]): string[] {
|
||||
return [
|
||||
...new Set(
|
||||
providers.flatMap((provider) =>
|
||||
typeof provider.resolveSyntheticAuth === "function"
|
||||
? [provider.id, ...(provider.aliases ?? []), ...(provider.hookAliases ?? [])]
|
||||
: [],
|
||||
),
|
||||
),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function resolvePreparedSyntheticAuth(params: {
|
||||
config: PreparedModelRuntimeInput["config"];
|
||||
provider: string;
|
||||
providers: readonly ProviderPlugin[];
|
||||
}): { apiKey?: string } | undefined {
|
||||
const normalizedProvider = normalizeProviderId(params.provider);
|
||||
const providerPlugin = params.providers.find((candidate) =>
|
||||
[candidate.id, ...(candidate.aliases ?? []), ...(candidate.hookAliases ?? [])].some(
|
||||
(ref) => normalizeProviderId(ref) === normalizedProvider,
|
||||
),
|
||||
);
|
||||
return (
|
||||
providerPlugin?.resolveSyntheticAuth?.({
|
||||
config: params.config,
|
||||
provider: params.provider,
|
||||
providerConfig: Object.entries(params.config.models?.providers ?? {}).find(
|
||||
([providerId]) => normalizeProviderId(providerId) === normalizedProvider,
|
||||
)?.[1],
|
||||
}) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
export function preparedModelRuntimeWorkspaceFactsKey(input: PreparedModelRuntimeInput): string {
|
||||
return JSON.stringify({
|
||||
// Config is the process generation. Agent-specific configured refs are projected after these
|
||||
// workspace/plugin facts are shared.
|
||||
config: hashRuntimeConfigValue(input.config),
|
||||
env: hashRuntimeConfigValue(input.env ?? process.env),
|
||||
readOnly: input.readOnly === true,
|
||||
workspaceDir: input.workspaceDir,
|
||||
});
|
||||
}
|
||||
|
||||
export async function prepareWorkspaceBuildGroup(
|
||||
inputs: readonly PreparedModelRuntimeInput[],
|
||||
catalogMode: PreparedModelRuntimeCatalogMode,
|
||||
): Promise<{
|
||||
agentFacts: PreparedModelRuntimeAgentFacts[];
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts;
|
||||
buildStats: Pick<
|
||||
PreparedModelRuntimeBuildStats,
|
||||
| "runtimePluginMs"
|
||||
| "pluginMetadataMs"
|
||||
| "staticProviderCatalogMs"
|
||||
| "ambientCredentialsMs"
|
||||
| "agentFactsMs"
|
||||
| "configuredProjectionMs"
|
||||
>;
|
||||
}> {
|
||||
const input = inputs[0];
|
||||
if (!input) {
|
||||
throw new Error("prepared model runtime workspace group is empty");
|
||||
}
|
||||
const env = input.env ?? process.env;
|
||||
const runtimePluginStartedAt = performance.now();
|
||||
const runtimePluginRegistry =
|
||||
catalogMode === "live" && !input.readOnly
|
||||
? ensureRuntimePluginsLoaded({
|
||||
config: input.config,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const runtimePluginMs = performance.now() - runtimePluginStartedAt;
|
||||
const pluginMetadataStartedAt = performance.now();
|
||||
const pluginMetadataSnapshot = resolvePluginMetadataSnapshot({
|
||||
config: input.config,
|
||||
env,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const pluginMetadataMs = performance.now() - pluginMetadataStartedAt;
|
||||
const mediaCapabilityProviders =
|
||||
input.readOnly || !runtimePluginRegistry
|
||||
? undefined
|
||||
: prepareMediaCapabilityProviders({
|
||||
cfg: input.config,
|
||||
pluginMetadataSnapshot,
|
||||
registry: runtimePluginRegistry,
|
||||
});
|
||||
const messageToolCatalog = runtimePluginRegistry
|
||||
? getPreparedMessageToolCatalogForRegistry(runtimePluginRegistry)
|
||||
: catalogMode === "live"
|
||||
? getPreparedMessageToolCatalog()
|
||||
: undefined;
|
||||
const resolveManifestStaticCatalogModel = createBundledStaticCatalogModelResolver({
|
||||
cfg: input.config,
|
||||
env,
|
||||
includeRuntimeDiscovery: true,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const configuredManifestModels = new Map<string, ProviderRuntimeModel | undefined>();
|
||||
const resolveConfiguredManifestModel = (lookup: { provider: string; modelId: string }) => {
|
||||
const key = `${normalizeProviderId(lookup.provider)}\0${lookup.modelId.trim().toLowerCase()}`;
|
||||
if (configuredManifestModels.has(key)) {
|
||||
return configuredManifestModels.get(key);
|
||||
}
|
||||
const model = resolveManifestStaticCatalogModel(lookup);
|
||||
configuredManifestModels.set(key, model);
|
||||
return model;
|
||||
};
|
||||
const configuredProviderIds = collectPreparedModelRuntimeProviderIds(input.config, {}, false);
|
||||
const staticCatalogProviderIds = collectConfiguredProviderIdsNeedingStaticCatalog({
|
||||
config: input.config,
|
||||
resolveStaticCatalogModel: resolveConfiguredManifestModel,
|
||||
});
|
||||
const staticProviderCatalogStartedAt = performance.now();
|
||||
const preparedStaticProviderCatalog =
|
||||
catalogMode === "static"
|
||||
? await prepareImplicitProviderStaticCatalog({
|
||||
config: input.config,
|
||||
env,
|
||||
pluginMetadataSnapshot,
|
||||
providerDiscoveryProviderIds: configuredProviderIds,
|
||||
staticCatalogProviderIds,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const staticProviderCatalogMs = performance.now() - staticProviderCatalogStartedAt;
|
||||
const preparedSyntheticAuthProviders = preparedStaticProviderCatalog?.providers ?? [];
|
||||
// Static Gateway publication consumes provider discovery entrypoints without activating plugin
|
||||
// runtimes. The run boundary already owns runtime activation for its exact workspace.
|
||||
const ambientCredentialsStartedAt = performance.now();
|
||||
const ambientCredentials = resolveAmbientAgentCredentialsForDiscovery({
|
||||
config: input.config,
|
||||
env,
|
||||
syntheticAuthProviderRefs:
|
||||
catalogMode === "static"
|
||||
? listPreparedSyntheticAuthProviderRefs(preparedSyntheticAuthProviders)
|
||||
: resolveRuntimeSyntheticAuthProviderRefs({
|
||||
config: input.config,
|
||||
env,
|
||||
index: pluginMetadataSnapshot.index,
|
||||
registryDiagnostics: pluginMetadataSnapshot.registryDiagnostics,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
}),
|
||||
...(catalogMode === "static"
|
||||
? {
|
||||
resolveSyntheticAuth: (provider: string) =>
|
||||
resolvePreparedSyntheticAuth({
|
||||
config: input.config,
|
||||
provider,
|
||||
providers: preparedSyntheticAuthProviders,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const ambientCredentialsMs = performance.now() - ambientCredentialsStartedAt;
|
||||
const agentFactsStartedAt = performance.now();
|
||||
const agentBaseFacts = inputs.map((candidate) =>
|
||||
prepareAgentFacts(candidate, catalogMode, ambientCredentials),
|
||||
);
|
||||
const agentFactsMs = performance.now() - agentFactsStartedAt;
|
||||
const configuredProjectionStartedAt = performance.now();
|
||||
const providerStaticModels =
|
||||
catalogMode === "static"
|
||||
? []
|
||||
: await loadBundledProviderStaticCatalogContextModels({
|
||||
cfg: input.config,
|
||||
env,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
// Provider definitions are process/config facts. Which refs are admitted remains agent-owned.
|
||||
const inlineProviderModels = buildInlineProviderModels(input.config.models?.providers ?? {}, {
|
||||
providerMetadataOwners: pluginMetadataSnapshot.owners,
|
||||
});
|
||||
const configuredCatalogEntries = buildConfiguredModelCatalog({
|
||||
cfg: input.config,
|
||||
manifestPlugins: pluginMetadataSnapshot.plugins,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const agentFacts: PreparedModelRuntimeAgentFacts[] = [];
|
||||
for (const facts of agentBaseFacts) {
|
||||
const configuredRuntimeModels = prepareConfiguredRuntimeModels({
|
||||
config: facts.input.config,
|
||||
configuredModelRefs: facts.configuredModelRefs,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}),
|
||||
providerStaticModels,
|
||||
resolveStaticCatalogModel: resolveConfiguredManifestModel,
|
||||
});
|
||||
const configuredEntryKeys = new Set(configuredCatalogEntries.map(modelCatalogEntryKey));
|
||||
for (const configured of configuredRuntimeModels) {
|
||||
configuredEntryKeys.add(
|
||||
modelCatalogEntryKey({ provider: configured.provider, id: configured.modelId }),
|
||||
);
|
||||
}
|
||||
const configuredGeneratedCatalogPluginIds = [
|
||||
...new Set(
|
||||
facts.configuredModelRefs.flatMap(({ value }) => {
|
||||
const separator = value.indexOf("/");
|
||||
if (separator <= 0 || separator >= value.length - 1) {
|
||||
return [];
|
||||
}
|
||||
const provider = normalizeProviderId(value.slice(0, separator));
|
||||
const modelId = value.slice(separator + 1).trim();
|
||||
if (
|
||||
!provider ||
|
||||
!modelId ||
|
||||
configuredEntryKeys.has(modelCatalogEntryKey({ provider, id: modelId }))
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
const pluginId = resolvePluginModelCatalogOwnerPluginId({
|
||||
providerId: provider,
|
||||
pluginMetadataSnapshot,
|
||||
});
|
||||
return pluginId ? [pluginId] : [];
|
||||
}),
|
||||
),
|
||||
].toSorted((left, right) => left.localeCompare(right));
|
||||
agentFacts.push({
|
||||
...facts,
|
||||
configuredRuntimeModels,
|
||||
configuredGeneratedCatalogPluginIds,
|
||||
});
|
||||
}
|
||||
const configuredProjectionMs = performance.now() - configuredProjectionStartedAt;
|
||||
return {
|
||||
agentFacts,
|
||||
buildStats: {
|
||||
runtimePluginMs,
|
||||
pluginMetadataMs,
|
||||
staticProviderCatalogMs,
|
||||
ambientCredentialsMs,
|
||||
agentFactsMs,
|
||||
configuredProjectionMs,
|
||||
},
|
||||
workspaceFacts: {
|
||||
pluginMetadataSnapshot,
|
||||
messageToolCatalog,
|
||||
providerStaticModelsComplete: catalogMode === "live",
|
||||
inlineProviderModels,
|
||||
configuredCatalogEntries,
|
||||
...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}),
|
||||
...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}),
|
||||
...(providerStaticModels ? { providerStaticModels } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function prepareFullCatalogFacts(
|
||||
agentFacts: PreparedModelRuntimeAgentFacts,
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts,
|
||||
catalogMode: PreparedModelRuntimeCatalogMode,
|
||||
catalogSource?: PreparedModelRuntimeCatalogSource,
|
||||
): Promise<PreparedModelRuntimeCatalogFacts> {
|
||||
const { credentials, env, input, templateAuthStorage } = agentFacts;
|
||||
const { pluginMetadataSnapshot, preparedStaticProviderCatalog } = workspaceFacts;
|
||||
const templateModelRegistry = discoverModels(templateAuthStorage, input.agentDir, {
|
||||
config: input.config,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
pluginMetadataSnapshot,
|
||||
...(catalogMode === "static" ? { normalizeModels: false } : {}),
|
||||
...(catalogSource
|
||||
? {
|
||||
includePluginCatalogs: true,
|
||||
modelsJsonContents: catalogSource.modelsJsonContents,
|
||||
pluginCatalogs: catalogSource.pluginCatalogs,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
const modelCatalog = await buildPreparedModelCatalogSnapshot({
|
||||
agentDir: input.agentDir,
|
||||
authCredentials: credentials,
|
||||
config: input.config,
|
||||
modelRegistry: templateModelRegistry,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
includeProviderPluginAugmentation: catalogMode === "live",
|
||||
...(input.env ? { env } : {}),
|
||||
...(input.readOnly ? { readOnly: true } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const providerStaticModels =
|
||||
(workspaceFacts.providerStaticModelsComplete
|
||||
? workspaceFacts.providerStaticModels
|
||||
: undefined) ??
|
||||
(await loadBundledProviderStaticCatalogContextModels({
|
||||
cfg: input.config,
|
||||
env,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
...(preparedStaticProviderCatalog ? { preparedStaticProviderCatalog } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
}));
|
||||
const configuredRuntimeModels = agentFacts.configuredRuntimeModels;
|
||||
const staticModels = new Map<string, ProviderRuntimeModel>();
|
||||
for (const model of [
|
||||
...configuredRuntimeModels.map((configured) => configured.model),
|
||||
...providerStaticModels,
|
||||
]) {
|
||||
const modelKey = `${normalizeProviderId(model.provider)}\0${model.id.trim().toLowerCase()}`;
|
||||
if (!staticModels.has(modelKey)) {
|
||||
staticModels.set(modelKey, model);
|
||||
}
|
||||
}
|
||||
const staticEntries = [...staticModels.values()].map(toStaticCatalogEntry);
|
||||
return {
|
||||
templateModelRegistry,
|
||||
modelCatalog: { ...modelCatalog, staticEntries },
|
||||
configuredRuntimeModels,
|
||||
inlineProviderModels: workspaceFacts.inlineProviderModels,
|
||||
};
|
||||
}
|
||||
|
||||
function modelCatalogEntryKey(entry: Pick<ModelCatalogEntry, "id" | "provider">): string {
|
||||
return `${normalizeProviderId(entry.provider)}\0${entry.id.trim().toLowerCase()}`;
|
||||
}
|
||||
|
||||
function createConfiguredModelCatalogSnapshot(params: {
|
||||
agentFacts: PreparedModelRuntimeAgentFacts;
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts;
|
||||
templateModelRegistry: ModelRegistry;
|
||||
configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[];
|
||||
}): ModelCatalogSnapshot {
|
||||
const entries = new Map<string, ModelCatalogEntry>();
|
||||
const addEntry = (entry: ModelCatalogEntry) => {
|
||||
const key = modelCatalogEntryKey(entry);
|
||||
if (!entries.has(key)) {
|
||||
entries.set(key, entry);
|
||||
}
|
||||
};
|
||||
for (const entry of params.workspaceFacts.configuredCatalogEntries) {
|
||||
addEntry(entry);
|
||||
}
|
||||
for (const configured of params.configuredRuntimeModels) {
|
||||
addEntry(toStaticCatalogEntry(configured.model));
|
||||
}
|
||||
for (const { value } of params.agentFacts.configuredModelRefs) {
|
||||
const separator = value.indexOf("/");
|
||||
if (separator <= 0 || separator >= value.length - 1) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(value.slice(0, separator));
|
||||
const modelId = value.slice(separator + 1).trim();
|
||||
if (!provider || !modelId) {
|
||||
continue;
|
||||
}
|
||||
const model = params.templateModelRegistry.find(provider, modelId);
|
||||
if (model) {
|
||||
addEntry(toStaticCatalogEntry(model));
|
||||
}
|
||||
}
|
||||
const configuredEntries = [...entries.values()];
|
||||
const staticEntries = params.configuredRuntimeModels.map(({ model }) =>
|
||||
toStaticCatalogEntry(model),
|
||||
);
|
||||
return {
|
||||
entries: configuredEntries,
|
||||
routeVariants: configuredEntries,
|
||||
...(staticEntries.length > 0 ? { staticEntries } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareConfiguredRuntimeFacts(
|
||||
agentFacts: PreparedModelRuntimeAgentFacts,
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts,
|
||||
sharedTemplateModelRegistry: ModelRegistry,
|
||||
): PreparedModelRuntimeCatalogFacts {
|
||||
const { configuredRuntimeModels } = agentFacts;
|
||||
const { inlineProviderModels } = workspaceFacts;
|
||||
const templateModelRegistry = sharedTemplateModelRegistry;
|
||||
return {
|
||||
templateModelRegistry,
|
||||
modelCatalog: createConfiguredModelCatalogSnapshot({
|
||||
agentFacts,
|
||||
workspaceFacts,
|
||||
templateModelRegistry,
|
||||
configuredRuntimeModels,
|
||||
}),
|
||||
configuredRuntimeModels,
|
||||
inlineProviderModels,
|
||||
};
|
||||
}
|
||||
|
||||
function captureModelsJsonContents(agentDir: string): string | null {
|
||||
try {
|
||||
return fs.readFileSync(path.join(agentDir, "models.json"), "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export function fingerprintPreparedRuntimeFacts(value: unknown): string {
|
||||
return sha256Base64Url(stableStringify(value));
|
||||
}
|
||||
|
||||
function hasSameOAuthProviderGeneration(
|
||||
left: ReturnType<AuthStorage["getOAuthProviders"]>,
|
||||
right: ReturnType<AuthStorage["getOAuthProviders"]>,
|
||||
): boolean {
|
||||
// OAuth descriptors carry executable hooks. Match those hooks by identity so equivalent
|
||||
// AuthStorage instances share built-ins without merging distinct closure generations.
|
||||
return (
|
||||
left.length === right.length &&
|
||||
left.every((provider, index) => {
|
||||
const candidate = right[index];
|
||||
return (
|
||||
candidate !== undefined &&
|
||||
provider.id === candidate.id &&
|
||||
provider.name === candidate.name &&
|
||||
provider.usesCallbackServer === candidate.usesCallbackServer &&
|
||||
provider.login === candidate.login &&
|
||||
provider.refreshToken === candidate.refreshToken &&
|
||||
provider.getApiKey === candidate.getApiKey &&
|
||||
provider.modifyModels === candidate.modifyModels
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function groupConfiguredRegistrySources(
|
||||
agentFacts: readonly PreparedModelRuntimeAgentFacts[],
|
||||
): PreparedConfiguredRegistryGroup[] {
|
||||
const groups = new Map<string, PreparedConfiguredRegistryGroup[]>();
|
||||
for (const facts of agentFacts) {
|
||||
const modelsJsonContents = captureModelsJsonContents(facts.input.agentDir);
|
||||
const oauthProviders = facts.templateAuthStorage.getOAuthProviders();
|
||||
// Generated catalogs are agent-owned. Capture only plugins needed by unresolved configured
|
||||
// refs, then group exact bytes and OAuth behavior so publication never mixes generations.
|
||||
const pluginCatalogs = loadPersistedPluginModelCatalogsReadOnly(
|
||||
facts.input.agentDir,
|
||||
facts.configuredGeneratedCatalogPluginIds,
|
||||
);
|
||||
const key = fingerprintPreparedRuntimeFacts({
|
||||
credentials: facts.credentials,
|
||||
modelsJsonContents,
|
||||
pluginCatalogs,
|
||||
});
|
||||
const candidates = groups.get(key) ?? [];
|
||||
const group = candidates.find((candidate) =>
|
||||
hasSameOAuthProviderGeneration(candidate.oauthProviders, oauthProviders),
|
||||
);
|
||||
if (group) {
|
||||
group.agentFacts.push(facts);
|
||||
} else {
|
||||
candidates.push({
|
||||
agentFacts: [facts],
|
||||
modelsJsonContents,
|
||||
oauthProviders,
|
||||
pluginCatalogs,
|
||||
});
|
||||
groups.set(key, candidates);
|
||||
}
|
||||
}
|
||||
return [...groups.values()].flat();
|
||||
}
|
||||
|
||||
export function prepareConfiguredRuntimeFactsBatch(params: {
|
||||
agentFacts: readonly PreparedModelRuntimeAgentFacts[];
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts;
|
||||
}): {
|
||||
catalogs: Map<PreparedModelRuntimeInput, PreparedModelRuntimeCatalogFacts>;
|
||||
registryCount: number;
|
||||
} {
|
||||
const catalogs = new Map<PreparedModelRuntimeInput, PreparedModelRuntimeCatalogFacts>();
|
||||
let registryCount = 0;
|
||||
for (const group of groupConfiguredRegistrySources(params.agentFacts)) {
|
||||
const representative = group.agentFacts[0];
|
||||
if (!representative) {
|
||||
continue;
|
||||
}
|
||||
// Catalog bytes, credentials, and OAuth provider behavior are identical inside this group.
|
||||
// Parse once, then fork request auth without reopening filesystem or SQLite catalog sources.
|
||||
const templateModelRegistry = discoverModelsFromCapturedSources(
|
||||
representative.templateAuthStorage,
|
||||
{
|
||||
config: representative.input.config,
|
||||
includePluginCatalogs: true,
|
||||
modelsJsonContents: group.modelsJsonContents,
|
||||
pluginCatalogs: group.pluginCatalogs,
|
||||
pluginMetadataSnapshot: params.workspaceFacts.pluginMetadataSnapshot,
|
||||
...(representative.input.workspaceDir
|
||||
? { workspaceDir: representative.input.workspaceDir }
|
||||
: {}),
|
||||
},
|
||||
);
|
||||
registryCount += 1;
|
||||
for (const facts of group.agentFacts) {
|
||||
catalogs.set(
|
||||
facts.input,
|
||||
prepareConfiguredRuntimeFacts(facts, params.workspaceFacts, templateModelRegistry),
|
||||
);
|
||||
}
|
||||
}
|
||||
return { catalogs, registryCount };
|
||||
}
|
||||
|
||||
export async function prepareAgentCatalogSource(
|
||||
agentFacts: PreparedModelRuntimeAgentFacts,
|
||||
workspaceFacts: PreparedModelRuntimeWorkspaceFacts,
|
||||
catalogMode: PreparedModelRuntimeCatalogMode,
|
||||
persist = true,
|
||||
): Promise<PreparedModelRuntimeCatalogSource> {
|
||||
const { env, input, providerIds } = agentFacts;
|
||||
const options = {
|
||||
pluginMetadataSnapshot: workspaceFacts.pluginMetadataSnapshot,
|
||||
...(workspaceFacts.preparedStaticProviderCatalog
|
||||
? { preparedStaticProviderCatalog: workspaceFacts.preparedStaticProviderCatalog }
|
||||
: {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
...(input.env ? { env } : {}),
|
||||
...(catalogMode === "static"
|
||||
? {
|
||||
providerDiscoveryEntriesOnly: true as const,
|
||||
providerDiscoveryProviderIds: providerIds,
|
||||
}
|
||||
: { providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS }),
|
||||
};
|
||||
if (!persist) {
|
||||
const source = await planOpenClawModelsJsonSource(input.config, input.agentDir, options);
|
||||
return {
|
||||
modelsJsonContents: source.modelsJsonContents,
|
||||
pluginCatalogs: source.pluginCatalogs,
|
||||
};
|
||||
}
|
||||
if (!input.readOnly) {
|
||||
await ensureOpenClawModelsJson(input.config, input.agentDir, options);
|
||||
}
|
||||
// Capture immediately after the serialized write. Another owner may share this directory and
|
||||
// publish a different workspace generation before full-catalog parsing begins.
|
||||
return {
|
||||
modelsJsonContents: captureModelsJsonContents(input.agentDir),
|
||||
pluginCatalogs: loadPersistedPluginModelCatalogsReadOnly(input.agentDir),
|
||||
};
|
||||
}
|
||||
@@ -4,25 +4,38 @@ type LoadStaticCatalog =
|
||||
typeof import("./embedded-agent-runner/model.static-catalog.js").loadBundledProviderStaticCatalogContextModels;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authStorage: { getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })) },
|
||||
authStorage: {
|
||||
getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
},
|
||||
modelRegistry: {
|
||||
fork: vi.fn((authStorage: unknown) => ({ authStorage })),
|
||||
getAll: vi.fn(() => []),
|
||||
find: vi.fn(() => null),
|
||||
},
|
||||
discoverAuthStorage: vi.fn(),
|
||||
resolveAmbientCredentials: vi.fn((..._args: unknown[]) => ({})),
|
||||
discoverAuthStorage: vi.fn((..._args: unknown[]) => undefined as unknown),
|
||||
discoverModels: vi.fn(),
|
||||
ensureOpenClawModelsJson: vi.fn(async (..._args: unknown[]) => ({
|
||||
agentDir: "/tmp/agent",
|
||||
wrote: false,
|
||||
})),
|
||||
planOpenClawModelsJsonSource: vi.fn(async (...args: unknown[]) => ({
|
||||
agentDir: String(args[1]),
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [],
|
||||
})),
|
||||
buildPreparedModelCatalogSnapshot: vi.fn(async (..._args: unknown[]) => ({
|
||||
entries: [],
|
||||
routeVariants: [],
|
||||
})),
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
loadStaticCatalog: vi.fn<LoadStaticCatalog>(async () => []),
|
||||
prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({ entries: [] })),
|
||||
resolveStaticCatalogModel: vi.fn(() => undefined),
|
||||
configuredAgentIds: [] as string[],
|
||||
configuredAgentDirs: new Map<string, string>(),
|
||||
configuredWorkspaces: new Map<string, string>(),
|
||||
warn: vi.fn(),
|
||||
mutationListener: undefined as
|
||||
| ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void)
|
||||
@@ -34,23 +47,37 @@ vi.mock("./model-catalog.js", () => ({
|
||||
mocks.buildPreparedModelCatalogSnapshot(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-auth-discovery.js", () => ({
|
||||
resolveAmbientAgentCredentialsForDiscovery: (...args: unknown[]) =>
|
||||
mocks.resolveAmbientCredentials(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-model-discovery.js", () => ({
|
||||
discoverAuthStorage: (...args: unknown[]) => {
|
||||
mocks.discoverAuthStorage(...args);
|
||||
return mocks.authStorage;
|
||||
return mocks.discoverAuthStorage(...args) ?? mocks.authStorage;
|
||||
},
|
||||
discoverModels: (...args: unknown[]) => {
|
||||
mocks.discoverModels(...args);
|
||||
return mocks.modelRegistry;
|
||||
},
|
||||
discoverModelsFromCapturedSources: (...args: unknown[]) => {
|
||||
mocks.discoverModels(...args);
|
||||
return mocks.modelRegistry;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/synthetic-auth.runtime.js", () => ({
|
||||
resolveRuntimeSyntheticAuthProviderRefs: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./agent-scope.js", () => ({
|
||||
listAgentIds: () => mocks.configuredAgentIds,
|
||||
resolveAgentDir: (_config: unknown, agentId: string) =>
|
||||
agentId === "default" ? "/tmp/unused-agent" : `/tmp/configured-${agentId}`,
|
||||
mocks.configuredAgentDirs.get(agentId) ??
|
||||
(agentId === "default" ? "/tmp/unused-agent" : `/tmp/configured-${agentId}`),
|
||||
resolveAgentWorkspaceDir: (_config: unknown, agentId: string) =>
|
||||
agentId === "default" ? "/tmp/unused-workspace" : `/tmp/workspace-${agentId}`,
|
||||
mocks.configuredWorkspaces.get(agentId) ??
|
||||
(agentId === "default" ? "/tmp/unused-workspace" : `/tmp/workspace-${agentId}`),
|
||||
resolveDefaultAgentDir: () => "/tmp/unused-agent",
|
||||
resolveDefaultAgentId: () => "default",
|
||||
}));
|
||||
@@ -70,6 +97,11 @@ vi.mock("./model-discovery-context.js", () => ({
|
||||
|
||||
vi.mock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: (...args: unknown[]) => mocks.ensureOpenClawModelsJson(...args),
|
||||
planOpenClawModelsJsonSource: (...args: unknown[]) => mocks.planOpenClawModelsJsonSource(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./models-config.providers.implicit.js", () => ({
|
||||
prepareImplicitProviderStaticCatalog: (...args: unknown[]) => mocks.prepareStaticCatalog(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-plugins.js", () => ({
|
||||
@@ -108,15 +140,28 @@ describe("prepared model runtime snapshots", () => {
|
||||
beforeEach(() => {
|
||||
getTesting().resetPreparedModelRuntimeSnapshotsForTest();
|
||||
mocks.discoverAuthStorage.mockClear();
|
||||
mocks.resolveAmbientCredentials.mockClear();
|
||||
mocks.discoverAuthStorage.mockImplementation(() => mocks.authStorage);
|
||||
mocks.discoverModels.mockClear();
|
||||
mocks.ensureOpenClawModelsJson.mockReset();
|
||||
mocks.ensureOpenClawModelsJson.mockResolvedValue({ agentDir: "/tmp/agent", wrote: false });
|
||||
mocks.planOpenClawModelsJsonSource.mockReset();
|
||||
mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => ({
|
||||
agentDir: String(agentDir),
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [],
|
||||
}));
|
||||
mocks.buildPreparedModelCatalogSnapshot.mockClear();
|
||||
mocks.ensureRuntimePluginsLoaded.mockClear();
|
||||
mocks.loadStaticCatalog.mockClear();
|
||||
mocks.prepareStaticCatalog.mockClear();
|
||||
mocks.resolveStaticCatalogModel.mockClear();
|
||||
mocks.modelRegistry.fork.mockClear();
|
||||
mocks.modelRegistry.find.mockClear();
|
||||
mocks.warn.mockClear();
|
||||
mocks.configuredAgentIds = [];
|
||||
mocks.configuredAgentDirs.clear();
|
||||
mocks.configuredWorkspaces.clear();
|
||||
});
|
||||
|
||||
it("does not discover missing owners from a gateway request", async () => {
|
||||
@@ -754,8 +799,9 @@ describe("prepared model runtime snapshots", () => {
|
||||
it("does not replay an auth mutation that occurs before the first owner is registered", async () => {
|
||||
getTesting().setModelRuntimeBuildTimeoutMsForTest(100);
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
mocks.ensureRuntimePluginsLoaded.mockImplementationOnce(() => {
|
||||
mocks.prepareStaticCatalog.mockImplementationOnce(async () => {
|
||||
mocks.mutationListener?.({ affectsInheritedStores: true });
|
||||
return { entries: [] };
|
||||
});
|
||||
mocks.ensureOpenClawModelsJson
|
||||
.mockResolvedValueOnce({ agentDir: "/tmp/unused-agent", wrote: false })
|
||||
@@ -764,7 +810,10 @@ describe("prepared model runtime snapshots", () => {
|
||||
await expect(
|
||||
refreshPreparedModelRuntimeSnapshots({}, { gatewayLifecycle: true, catalogMode: "static" }),
|
||||
).resolves.toBeUndefined();
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.discoverAuthStorage).toHaveBeenCalledOnce();
|
||||
expect(mocks.discoverModels).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("awaits auth invalidation queued during lifecycle publication", async () => {
|
||||
@@ -843,6 +892,31 @@ describe("prepared model runtime snapshots", () => {
|
||||
expect(mocks.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a deduplicated caller when an auth refresh is superseded", async () => {
|
||||
const config = {};
|
||||
const agentDir = "/tmp/prepared-model-runtime-auth-pending-superseded";
|
||||
await publishPreparedModelRuntimeSnapshot({ config, agentDir });
|
||||
let finishFirstRefresh: (() => void) | undefined;
|
||||
mocks.ensureOpenClawModelsJson.mockImplementationOnce(
|
||||
async () =>
|
||||
await new Promise<{ agentDir: string; wrote: false }>((resolve) => {
|
||||
finishFirstRefresh = () => resolve({ agentDir, wrote: false });
|
||||
}),
|
||||
);
|
||||
|
||||
mocks.mutationListener?.({ agentDir, affectsInheritedStores: false });
|
||||
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
||||
const deduplicated = publishPreparedModelRuntimeSnapshot({ config, agentDir });
|
||||
mocks.mutationListener?.({ agentDir, affectsInheritedStores: false });
|
||||
finishFirstRefresh?.();
|
||||
|
||||
await expect(deduplicated).rejects.toThrow("superseded");
|
||||
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3));
|
||||
await expect(prepareModelRuntimeSnapshot({ config, agentDir })).resolves.toMatchObject({
|
||||
agentDir,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not let a superseded owner hide a genuine sibling refresh failure", async () => {
|
||||
const config = {};
|
||||
const supersededDir = "/tmp/prepared-model-runtime-auth-superseded-sibling";
|
||||
@@ -1012,109 +1086,4 @@ describe("prepared model runtime snapshots", () => {
|
||||
expect.objectContaining({ workspaceDir: "/tmp/explicit-workspace" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("finds the configured gateway owner when request config omits its launch workspace", async () => {
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
const config = {};
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
||||
});
|
||||
const snapshot = await prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/unused-agent",
|
||||
});
|
||||
|
||||
expect(snapshot.workspaceDir).toBe("/tmp/gateway-launch-workspace");
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not substitute a configured owner captured from another environment", async () => {
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
const config = {};
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/unused-agent",
|
||||
env: { ...process.env, OPENCLAW_PREPARED_RUNTIME_TEST_SCOPE: "different" },
|
||||
}),
|
||||
).rejects.toThrow("prepared model runtime owner was not published");
|
||||
});
|
||||
|
||||
it("does not substitute a configured owner for an explicit workspace", async () => {
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
const config = {};
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/other-explicit-workspace",
|
||||
}),
|
||||
).rejects.toThrow("prepared model runtime owner was not published");
|
||||
});
|
||||
|
||||
it("does not choose between configured owners sharing one agent directory", async () => {
|
||||
const config = {};
|
||||
const agentDir = "/tmp/shared-configured-agent";
|
||||
await publishPreparedModelRuntimeSnapshot(
|
||||
{ config, agentDir, workspaceDir: "/tmp/shared-workspace-a" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
await publishPreparedModelRuntimeSnapshot(
|
||||
{ config, agentDir, workspaceDir: "/tmp/shared-workspace-b" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
|
||||
await expect(prepareModelRuntimeSnapshot({ config, agentDir })).rejects.toThrow(
|
||||
"prepared model runtime owner was not published",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects a configured owner by agent id when directories are shared", async () => {
|
||||
const config = {};
|
||||
const agentDir = "/tmp/shared-agent-id-directory";
|
||||
await publishPreparedModelRuntimeSnapshot(
|
||||
{ agentId: "agent-a", config, agentDir, workspaceDir: "/tmp/shared-agent-id-workspace" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
const selected = await publishPreparedModelRuntimeSnapshot(
|
||||
{ agentId: "agent-b", config, agentDir, workspaceDir: "/tmp/shared-agent-id-workspace" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({ agentId: "agent-b", config, agentDir }),
|
||||
).resolves.toBe(selected);
|
||||
});
|
||||
|
||||
it("retires configured owners removed by config reload", async () => {
|
||||
mocks.configuredAgentIds = ["default", "removed"];
|
||||
const config = {};
|
||||
await refreshPreparedModelRuntimeSnapshots(config);
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config);
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/configured-removed",
|
||||
inheritedAuthDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/workspace-removed",
|
||||
}),
|
||||
).rejects.toThrow("prepared model runtime owner was not published");
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,708 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type CreateStaticCatalogResolver =
|
||||
typeof import("./embedded-agent-runner/model.static-catalog.js").createBundledStaticCatalogModelResolver;
|
||||
type StaticCatalogResolver = ReturnType<CreateStaticCatalogResolver>;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authStorage: {
|
||||
getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
},
|
||||
modelRegistry: {
|
||||
fork: vi.fn((authStorage: unknown) => ({ authStorage })),
|
||||
getAll: vi.fn(() => []),
|
||||
find: vi.fn(() => null),
|
||||
},
|
||||
configuredAgentIds: [] as string[],
|
||||
configuredAgentDirs: new Map<string, string>(),
|
||||
configuredWorkspaces: new Map<string, string>(),
|
||||
buildPreparedModelCatalogSnapshot: vi.fn(async (..._args: unknown[]) => ({
|
||||
entries: [],
|
||||
routeVariants: [],
|
||||
})),
|
||||
discoverAuthStorage: vi.fn((..._args: unknown[]) => undefined as unknown),
|
||||
discoverModels: vi.fn(),
|
||||
ensureOpenClawModelsJson: vi.fn(async (..._args: unknown[]) => ({
|
||||
agentDir: "/tmp/agent",
|
||||
wrote: false,
|
||||
})),
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
planOpenClawModelsJsonSource: vi.fn(async (...args: unknown[]) => ({
|
||||
agentDir: String(args[1]),
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [],
|
||||
})),
|
||||
prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({ entries: [] })),
|
||||
resolveAmbientCredentials: vi.fn((..._args: unknown[]) => ({})),
|
||||
resolveStaticCatalogModel: vi.fn<StaticCatalogResolver>(() => undefined),
|
||||
mutationListener: undefined as
|
||||
| ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void)
|
||||
| undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./model-catalog.js", () => ({
|
||||
buildPreparedModelCatalogSnapshot: (...args: unknown[]) =>
|
||||
mocks.buildPreparedModelCatalogSnapshot(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-auth-discovery.js", () => ({
|
||||
resolveAmbientAgentCredentialsForDiscovery: (...args: unknown[]) =>
|
||||
mocks.resolveAmbientCredentials(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-model-discovery.js", () => ({
|
||||
discoverAuthStorage: (...args: unknown[]) =>
|
||||
mocks.discoverAuthStorage(...args) ?? mocks.authStorage,
|
||||
discoverModels: (...args: unknown[]) => {
|
||||
mocks.discoverModels(...args);
|
||||
return mocks.modelRegistry;
|
||||
},
|
||||
discoverModelsFromCapturedSources: (...args: unknown[]) => {
|
||||
mocks.discoverModels(...args);
|
||||
return mocks.modelRegistry;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/synthetic-auth.runtime.js", () => ({
|
||||
resolveRuntimeSyntheticAuthProviderRefs: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./agent-scope.js", () => ({
|
||||
listAgentIds: () => mocks.configuredAgentIds,
|
||||
resolveAgentDir: (_config: unknown, agentId: string) =>
|
||||
mocks.configuredAgentDirs.get(agentId) ??
|
||||
(agentId === "default" ? "/tmp/unused-agent" : `/tmp/configured-${agentId}`),
|
||||
resolveAgentWorkspaceDir: (_config: unknown, agentId: string) =>
|
||||
mocks.configuredWorkspaces.get(agentId) ??
|
||||
(agentId === "default" ? "/tmp/unused-workspace" : `/tmp/workspace-${agentId}`),
|
||||
resolveDefaultAgentDir: () => "/tmp/unused-agent",
|
||||
resolveDefaultAgentId: () => "default",
|
||||
}));
|
||||
|
||||
vi.mock("./auth-profiles/runtime-snapshots.js", () => ({
|
||||
registerRuntimeAuthProfileStoreMutationListener: (
|
||||
listener: (event: { agentDir?: string; affectsInheritedStores: boolean }) => void,
|
||||
) => {
|
||||
mocks.mutationListener = listener;
|
||||
return () => {};
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("./model-discovery-context.js", () => ({
|
||||
resolveModelPluginMetadataSnapshot: () => undefined,
|
||||
}));
|
||||
|
||||
vi.mock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: (...args: unknown[]) => mocks.ensureOpenClawModelsJson(...args),
|
||||
planOpenClawModelsJsonSource: (...args: unknown[]) => mocks.planOpenClawModelsJsonSource(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./models-config.providers.implicit.js", () => ({
|
||||
prepareImplicitProviderStaticCatalog: (...args: unknown[]) => mocks.prepareStaticCatalog(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-plugins.js", () => ({
|
||||
ensureRuntimePluginsLoaded: (...args: unknown[]) => mocks.ensureRuntimePluginsLoaded(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./embedded-agent-runner/model.static-catalog.js", () => ({
|
||||
loadBundledProviderStaticCatalogContextModels: async () => [],
|
||||
createBundledStaticCatalogModelResolver: () => mocks.resolveStaticCatalogModel,
|
||||
}));
|
||||
|
||||
vi.mock("../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => ({ warn: vi.fn() }),
|
||||
}));
|
||||
|
||||
import {
|
||||
getPreparedModelRuntimeSnapshot,
|
||||
prepareModelRuntimeSnapshot,
|
||||
publishPreparedModelRuntimeSnapshot,
|
||||
refreshPreparedModelRuntimeSnapshots,
|
||||
} from "./prepared-model-runtime.js";
|
||||
|
||||
describe("prepared model runtime owner selection", () => {
|
||||
const getTesting = () =>
|
||||
(globalThis as Record<PropertyKey, unknown>)[
|
||||
Symbol.for("openclaw.preparedModelRuntimeTestApi")
|
||||
] as {
|
||||
resetPreparedModelRuntimeSnapshotsForTest: () => void;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
getTesting().resetPreparedModelRuntimeSnapshotsForTest();
|
||||
mocks.configuredAgentIds = [];
|
||||
mocks.configuredAgentDirs.clear();
|
||||
mocks.configuredWorkspaces.clear();
|
||||
mocks.buildPreparedModelCatalogSnapshot.mockClear();
|
||||
mocks.discoverAuthStorage.mockReset();
|
||||
mocks.discoverAuthStorage.mockImplementation(() => mocks.authStorage);
|
||||
mocks.discoverModels.mockClear();
|
||||
mocks.ensureOpenClawModelsJson.mockReset();
|
||||
mocks.ensureOpenClawModelsJson.mockResolvedValue({ agentDir: "/tmp/agent", wrote: false });
|
||||
mocks.ensureRuntimePluginsLoaded.mockClear();
|
||||
mocks.modelRegistry.fork.mockClear();
|
||||
mocks.planOpenClawModelsJsonSource.mockReset();
|
||||
mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => ({
|
||||
agentDir: String(agentDir),
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [],
|
||||
}));
|
||||
mocks.prepareStaticCatalog.mockClear();
|
||||
mocks.resolveAmbientCredentials.mockClear();
|
||||
mocks.resolveStaticCatalogModel.mockClear();
|
||||
});
|
||||
|
||||
it("serializes live catalog sources for owners sharing one agent directory", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-shared-catalog-source-"));
|
||||
try {
|
||||
const agentDir = path.join(rootDir, "agent");
|
||||
fs.mkdirSync(agentDir);
|
||||
mocks.configuredAgentIds = ["agent-a", "agent-b"];
|
||||
mocks.configuredAgentDirs.set("agent-a", agentDir);
|
||||
mocks.configuredAgentDirs.set("agent-b", agentDir);
|
||||
mocks.configuredWorkspaces.set("agent-a", "/tmp/source-workspace-a");
|
||||
mocks.configuredWorkspaces.set("agent-b", "/tmp/source-workspace-b");
|
||||
let activeWrites = 0;
|
||||
let peakActiveWrites = 0;
|
||||
mocks.ensureOpenClawModelsJson.mockImplementation(async (_config, targetDir, options) => {
|
||||
activeWrites += 1;
|
||||
peakActiveWrites = Math.max(peakActiveWrites, activeWrites);
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
const workspaceDir = (options as { workspaceDir?: string }).workspaceDir ?? "unknown";
|
||||
fs.writeFileSync(
|
||||
path.join(String(targetDir), "models.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://models.example/v1",
|
||||
models: [{ id: path.basename(workspaceDir) }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
activeWrites -= 1;
|
||||
return { agentDir: String(targetDir), wrote: true };
|
||||
});
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots({});
|
||||
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2);
|
||||
expect(peakActiveWrites).toBe(1);
|
||||
expect(
|
||||
mocks.discoverModels.mock.calls.map((call) => {
|
||||
const contents = (call[2] as { modelsJsonContents: string }).modelsJsonContents;
|
||||
const parsed = JSON.parse(contents) as {
|
||||
providers: { custom: { models: Array<{ id: string }> } };
|
||||
};
|
||||
return parsed.providers.custom.models[0]?.id;
|
||||
}),
|
||||
).toEqual(["source-workspace-a", "source-workspace-b"]);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("finds the configured gateway owner when request config omits its launch workspace", async () => {
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
const config = {};
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
||||
});
|
||||
const snapshot = await prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/unused-agent",
|
||||
});
|
||||
|
||||
expect(snapshot.workspaceDir).toBe("/tmp/gateway-launch-workspace");
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("does not substitute a configured owner captured from another environment", async () => {
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
const config = {};
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/unused-agent",
|
||||
env: { ...process.env, OPENCLAW_PREPARED_RUNTIME_TEST_SCOPE: "different" },
|
||||
}),
|
||||
).rejects.toThrow("prepared model runtime owner was not published");
|
||||
});
|
||||
|
||||
it("does not substitute a configured owner for an explicit workspace", async () => {
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
const config = {};
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
defaultWorkspaceDir: "/tmp/gateway-launch-workspace",
|
||||
});
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/other-explicit-workspace",
|
||||
}),
|
||||
).rejects.toThrow("prepared model runtime owner was not published");
|
||||
});
|
||||
|
||||
it("does not choose between configured owners sharing one agent directory", async () => {
|
||||
const config = {};
|
||||
const agentDir = "/tmp/shared-configured-agent";
|
||||
await publishPreparedModelRuntimeSnapshot(
|
||||
{ config, agentDir, workspaceDir: "/tmp/shared-workspace-a" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
await publishPreparedModelRuntimeSnapshot(
|
||||
{ config, agentDir, workspaceDir: "/tmp/shared-workspace-b" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
|
||||
await expect(prepareModelRuntimeSnapshot({ config, agentDir })).rejects.toThrow(
|
||||
"prepared model runtime owner was not published",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects a configured owner by agent id when directories are shared", async () => {
|
||||
const config = {};
|
||||
const agentDir = "/tmp/shared-agent-id-directory";
|
||||
await publishPreparedModelRuntimeSnapshot(
|
||||
{ agentId: "agent-a", config, agentDir, workspaceDir: "/tmp/shared-agent-id-workspace" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
const selected = await publishPreparedModelRuntimeSnapshot(
|
||||
{ agentId: "agent-b", config, agentDir, workspaceDir: "/tmp/shared-agent-id-workspace" },
|
||||
{ provenance: "configured" },
|
||||
);
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({ agentId: "agent-b", config, agentDir }),
|
||||
).resolves.toBe(selected);
|
||||
});
|
||||
|
||||
it("retires configured owners removed by config reload", async () => {
|
||||
mocks.configuredAgentIds = ["default", "removed"];
|
||||
const config = {};
|
||||
await refreshPreparedModelRuntimeSnapshots(config);
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config);
|
||||
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: "/tmp/configured-removed",
|
||||
inheritedAuthDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/workspace-removed",
|
||||
}),
|
||||
).rejects.toThrow("prepared model runtime owner was not published");
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("shares static workspace facts without eager per-agent catalog work", async () => {
|
||||
mocks.configuredAgentIds = ["agent-a", "agent-b", "agent-c", "agent-d"];
|
||||
for (const agentId of ["agent-a", "agent-b", "agent-c"]) {
|
||||
mocks.configuredWorkspaces.set(agentId, "/tmp/shared-prepared-runtime-workspace");
|
||||
}
|
||||
mocks.configuredWorkspaces.set("agent-d", "/tmp/distinct-prepared-runtime-workspace");
|
||||
const config = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
||||
let stats:
|
||||
| {
|
||||
agentCount: number;
|
||||
workspaceGroupCount: number;
|
||||
configuredFactsGroupCount: number;
|
||||
catalogSourceCount: number;
|
||||
catalogGroupCount: number;
|
||||
runtimeRegistryCount: number;
|
||||
fullCatalogConcurrencyLimit: number;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
onBuildStats: (value) => {
|
||||
stats = value;
|
||||
},
|
||||
});
|
||||
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.resolveAmbientCredentials).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.resolveStaticCatalogModel).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.buildPreparedModelCatalogSnapshot).not.toHaveBeenCalled();
|
||||
expect(mocks.discoverModels).toHaveBeenCalledTimes(2);
|
||||
expect(stats).toMatchObject({
|
||||
agentCount: 4,
|
||||
workspaceGroupCount: 2,
|
||||
configuredFactsGroupCount: 2,
|
||||
catalogSourceCount: 0,
|
||||
catalogGroupCount: 0,
|
||||
runtimeRegistryCount: 2,
|
||||
fullCatalogConcurrencyLimit: 1,
|
||||
});
|
||||
|
||||
const snapshot = getPreparedModelRuntimeSnapshot({
|
||||
agentId: "agent-a",
|
||||
config,
|
||||
agentDir: "/tmp/configured-agent-a",
|
||||
inheritedAuthDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/shared-prepared-runtime-workspace",
|
||||
});
|
||||
await snapshot?.loadFullModelCatalog?.();
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
|
||||
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("shares workspace facts while isolating each agent's configured model projection", async () => {
|
||||
mocks.configuredAgentIds = ["agent-a", "agent-b"];
|
||||
for (const agentId of mocks.configuredAgentIds) {
|
||||
mocks.configuredWorkspaces.set(agentId, "/tmp/shared-agent-model-workspace");
|
||||
}
|
||||
mocks.resolveStaticCatalogModel.mockImplementation(
|
||||
({ provider, modelId }: { provider: string; modelId: string }) => ({
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
provider,
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://models.example/v1",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
}),
|
||||
);
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: { model: "custom/shared-model" },
|
||||
list: [
|
||||
{ id: "agent-a", model: "custom/model-a" },
|
||||
{ id: "agent-b", model: "custom/model-b" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
});
|
||||
|
||||
for (const agentId of mocks.configuredAgentIds) {
|
||||
const snapshot = getPreparedModelRuntimeSnapshot({
|
||||
agentId,
|
||||
config,
|
||||
agentDir: `/tmp/configured-${agentId}`,
|
||||
inheritedAuthDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/shared-agent-model-workspace",
|
||||
});
|
||||
expect(snapshot?.configuredRuntimeModels.map(({ modelId }) => modelId)).toEqual([
|
||||
"shared-model",
|
||||
agentId === "agent-a" ? "model-a" : "model-b",
|
||||
]);
|
||||
expect(snapshot?.modelCatalog.entries.map(({ id }) => id)).not.toContain(
|
||||
agentId === "agent-a" ? "model-b" : "model-a",
|
||||
);
|
||||
}
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("parses one static registry per exact agent catalog and credential generation", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-prepared-registry-groups-"));
|
||||
try {
|
||||
mocks.configuredAgentIds = ["agent-a", "agent-b", "agent-c"];
|
||||
for (const agentId of mocks.configuredAgentIds) {
|
||||
const agentDir = path.join(rootDir, agentId);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
mocks.configuredAgentDirs.set(agentId, agentDir);
|
||||
mocks.configuredWorkspaces.set(agentId, "/tmp/shared-prepared-runtime-workspace");
|
||||
}
|
||||
const sharedCatalog = JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://models.example/v1",
|
||||
models: [{ id: "shared-model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
fs.writeFileSync(path.join(rootDir, "agent-a", "models.json"), sharedCatalog);
|
||||
fs.writeFileSync(path.join(rootDir, "agent-b", "models.json"), sharedCatalog);
|
||||
fs.writeFileSync(
|
||||
path.join(rootDir, "agent-c", "models.json"),
|
||||
JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://models.example/v1",
|
||||
models: [{ id: "distinct-model" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
let runtimeRegistryCount = 0;
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(
|
||||
{ agents: { defaults: { model: "openai/gpt-5.5" } } },
|
||||
{
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
onBuildStats: (stats) => {
|
||||
runtimeRegistryCount = stats.runtimeRegistryCount;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.discoverModels).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeRegistryCount).toBe(2);
|
||||
expect(
|
||||
mocks.discoverModels.mock.calls.map((call) => {
|
||||
const options = call.length === 2 ? call[1] : call[2];
|
||||
return (options as { modelsJsonContents?: string }).modelsJsonContents;
|
||||
}),
|
||||
).toEqual(expect.arrayContaining([sharedCatalog, expect.stringContaining("distinct-model")]));
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps registry parsing isolated across OAuth provider generations", async () => {
|
||||
const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-prepared-oauth-groups-"));
|
||||
try {
|
||||
mocks.configuredAgentIds = ["agent-a", "agent-b", "agent-c"];
|
||||
const sharedCatalog = JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "https://models.example/v1",
|
||||
models: [{ id: "shared-model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const sharedProvider = {
|
||||
id: "custom",
|
||||
name: "OAuth A",
|
||||
login: vi.fn(),
|
||||
refreshToken: vi.fn(),
|
||||
getApiKey: vi.fn(),
|
||||
};
|
||||
const oauthProviders = {
|
||||
"agent-a": sharedProvider,
|
||||
"agent-b": { ...sharedProvider },
|
||||
"agent-c": { ...sharedProvider, name: "OAuth B", modifyModels: vi.fn() },
|
||||
};
|
||||
for (const agentId of mocks.configuredAgentIds) {
|
||||
const agentDir = path.join(rootDir, agentId);
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(agentDir, "models.json"), sharedCatalog);
|
||||
mocks.configuredAgentDirs.set(agentId, agentDir);
|
||||
mocks.configuredWorkspaces.set(agentId, "/tmp/shared-prepared-runtime-workspace");
|
||||
}
|
||||
mocks.discoverAuthStorage.mockImplementation((agentDir: unknown) => {
|
||||
const agentId = path.basename(String(agentDir)) as keyof typeof oauthProviders;
|
||||
return {
|
||||
getAll: () => ({ custom: { type: "api_key" as const, key: "shared-key" } }),
|
||||
getOAuthProviders: () => [oauthProviders[agentId]],
|
||||
};
|
||||
});
|
||||
let runtimeRegistryCount = 0;
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(
|
||||
{ agents: { defaults: { model: "openai/gpt-5.5" } } },
|
||||
{
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
onBuildStats: (stats) => {
|
||||
runtimeRegistryCount = stats.runtimeRegistryCount;
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(mocks.discoverModels).toHaveBeenCalledTimes(2);
|
||||
expect(runtimeRegistryCount).toBe(2);
|
||||
} finally {
|
||||
fs.rmSync(rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("serializes on-demand full catalogs while preserving agent credentials", async () => {
|
||||
mocks.configuredAgentIds = ["agent-a", "agent-b"];
|
||||
mocks.configuredWorkspaces.set("agent-a", "/tmp/shared-prepared-runtime-workspace");
|
||||
mocks.configuredWorkspaces.set("agent-b", "/tmp/shared-prepared-runtime-workspace");
|
||||
mocks.discoverAuthStorage.mockImplementation((agentDir: unknown) => ({
|
||||
getAll: () => ({
|
||||
custom: { type: "api_key" as const, key: `test-key:${String(agentDir)}` },
|
||||
}),
|
||||
getOAuthProviders: () => [],
|
||||
}));
|
||||
let activePlans = 0;
|
||||
let peakActivePlans = 0;
|
||||
mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => {
|
||||
activePlans += 1;
|
||||
peakActivePlans = Math.max(peakActivePlans, activePlans);
|
||||
await Promise.resolve();
|
||||
activePlans -= 1;
|
||||
return { agentDir: String(agentDir), modelsJsonContents: null, pluginCatalogs: [] };
|
||||
});
|
||||
const config = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
});
|
||||
|
||||
expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledOnce();
|
||||
expect(mocks.discoverModels).toHaveBeenCalledTimes(2);
|
||||
const loadAgentCatalog = (agentId: string) =>
|
||||
getPreparedModelRuntimeSnapshot({
|
||||
agentId,
|
||||
config,
|
||||
agentDir: `/tmp/configured-${agentId}`,
|
||||
inheritedAuthDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/shared-prepared-runtime-workspace",
|
||||
})?.loadFullModelCatalog?.();
|
||||
await Promise.all([loadAgentCatalog("agent-a"), loadAgentCatalog("agent-b")]);
|
||||
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledTimes(2);
|
||||
expect(peakActivePlans).toBe(1);
|
||||
expect(
|
||||
mocks.buildPreparedModelCatalogSnapshot.mock.calls.map(
|
||||
(call) =>
|
||||
(call[0] as { authCredentials: { custom: { key: string } } }).authCredentials.custom.key,
|
||||
),
|
||||
).toEqual(["test-key:/tmp/configured-agent-a", "test-key:/tmp/configured-agent-b"]);
|
||||
});
|
||||
|
||||
it("serializes a lazy catalog plan before a superseding generation", async () => {
|
||||
mocks.configuredAgentIds = ["agent-a"];
|
||||
mocks.configuredWorkspaces.set("agent-a", "/tmp/shared-prepared-runtime-workspace");
|
||||
const initialConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
||||
await refreshPreparedModelRuntimeSnapshots(initialConfig, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
});
|
||||
const snapshot = getPreparedModelRuntimeSnapshot({
|
||||
agentId: "agent-a",
|
||||
config: initialConfig,
|
||||
agentDir: "/tmp/configured-agent-a",
|
||||
inheritedAuthDir: "/tmp/unused-agent",
|
||||
workspaceDir: "/tmp/shared-prepared-runtime-workspace",
|
||||
});
|
||||
let releaseLazyPlan: (() => void) | undefined;
|
||||
mocks.planOpenClawModelsJsonSource.mockImplementation(async (_config, agentDir) => {
|
||||
if (!releaseLazyPlan) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseLazyPlan = resolve;
|
||||
});
|
||||
}
|
||||
return { agentDir: String(agentDir), modelsJsonContents: null, pluginCatalogs: [] };
|
||||
});
|
||||
|
||||
const staleCatalogLoad = snapshot?.loadFullModelCatalog?.();
|
||||
await vi.waitFor(() => expect(releaseLazyPlan).toBeTypeOf("function"));
|
||||
const replacement = refreshPreparedModelRuntimeSnapshots(
|
||||
{ agents: { defaults: { model: "openai/gpt-5.6" } } },
|
||||
{ gatewayLifecycle: true, catalogMode: "live" },
|
||||
);
|
||||
await Promise.resolve();
|
||||
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
|
||||
releaseLazyPlan?.();
|
||||
await expect(staleCatalogLoad).rejects.toThrow("superseded");
|
||||
await replacement;
|
||||
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("stops a superseded same-directory batch before another catalog write", async () => {
|
||||
mocks.configuredAgentIds = ["agent-a", "agent-b"];
|
||||
for (const agentId of mocks.configuredAgentIds) {
|
||||
mocks.configuredAgentDirs.set(agentId, "/tmp/shared-catalog-agent-dir");
|
||||
mocks.configuredWorkspaces.set(agentId, `/tmp/catalog-workspace-${agentId}`);
|
||||
}
|
||||
const staleConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
||||
const latestConfig = { agents: { defaults: { model: "openai/gpt-5.6" } } };
|
||||
let releaseStaleWrite: (() => void) | undefined;
|
||||
mocks.ensureOpenClawModelsJson.mockImplementation(async (config) => {
|
||||
if (config === staleConfig && !releaseStaleWrite) {
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStaleWrite = resolve;
|
||||
});
|
||||
}
|
||||
return { agentDir: "/tmp/shared-catalog-agent-dir", wrote: false };
|
||||
});
|
||||
|
||||
const stale = refreshPreparedModelRuntimeSnapshots(staleConfig);
|
||||
await vi.waitFor(() => expect(releaseStaleWrite).toBeTypeOf("function"));
|
||||
const latest = refreshPreparedModelRuntimeSnapshots(latestConfig);
|
||||
releaseStaleWrite?.();
|
||||
|
||||
await expect(stale).rejects.toThrow("superseded");
|
||||
await latest;
|
||||
expect(
|
||||
mocks.ensureOpenClawModelsJson.mock.calls.filter(([config]) => config === staleConfig),
|
||||
).toHaveLength(1);
|
||||
expect(
|
||||
mocks.ensureOpenClawModelsJson.mock.calls.filter(([config]) => config === latestConfig),
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("publishes a current sibling when another auth owner is superseded", async () => {
|
||||
const config = {};
|
||||
const supersededDir = "/tmp/prepared-model-runtime-auth-retry-superseded";
|
||||
const siblingDir = "/tmp/prepared-model-runtime-auth-retry-sibling";
|
||||
await publishPreparedModelRuntimeSnapshot({ config, agentDir: supersededDir });
|
||||
const firstSibling = await publishPreparedModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: siblingDir,
|
||||
});
|
||||
let releaseSupersededRefresh: (() => void) | undefined;
|
||||
let blockedSupersededRefresh = true;
|
||||
mocks.ensureOpenClawModelsJson.mockImplementation(async (_config, agentDir) => {
|
||||
if (agentDir === supersededDir && blockedSupersededRefresh) {
|
||||
blockedSupersededRefresh = false;
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseSupersededRefresh = resolve;
|
||||
});
|
||||
}
|
||||
return { agentDir: String(agentDir), wrote: false };
|
||||
});
|
||||
|
||||
mocks.mutationListener?.({ affectsInheritedStores: true });
|
||||
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(4));
|
||||
const siblingPending = publishPreparedModelRuntimeSnapshot({
|
||||
config,
|
||||
agentDir: siblingDir,
|
||||
});
|
||||
mocks.mutationListener?.({ agentDir: supersededDir, affectsInheritedStores: false });
|
||||
releaseSupersededRefresh?.();
|
||||
|
||||
await expect(siblingPending).resolves.not.toBe(firstSibling);
|
||||
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(6));
|
||||
await expect(
|
||||
prepareModelRuntimeSnapshot({ config, agentDir: supersededDir }),
|
||||
).resolves.toMatchObject({ agentDir: supersededDir });
|
||||
});
|
||||
});
|
||||
@@ -1,22 +1,7 @@
|
||||
import path from "node:path";
|
||||
import { collectConfiguredModelRefs } from "@openclaw/model-catalog-core/configured-model-refs";
|
||||
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
|
||||
import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js";
|
||||
import { hashRuntimeConfigValue } from "../config/runtime-snapshot.js";
|
||||
import { MODEL_APIS } from "../config/types.models.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { withTimeout } from "../node-host/with-timeout.js";
|
||||
import { prepareMediaCapabilityProviders } from "../plugins/capability-provider-runtime.js";
|
||||
import { resolvePluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import {
|
||||
EMPTY_PREPARED_MESSAGE_TOOL_CATALOG,
|
||||
getPreparedMessageToolCatalog,
|
||||
getPreparedMessageToolCatalogForRegistry,
|
||||
} from "../plugins/prepared-message-tool-catalog.js";
|
||||
import type { ProviderRuntimeModel } from "../plugins/provider-runtime-model.types.js";
|
||||
import { isReservedSystemAgentId } from "../system-agent/agent-id.js";
|
||||
import { discoverAuthStorage, discoverModels } from "./agent-model-discovery.js";
|
||||
import {
|
||||
listAgentIds,
|
||||
resolveAgentDir,
|
||||
@@ -25,101 +10,34 @@ import {
|
||||
resolveDefaultAgentId,
|
||||
} from "./agent-scope.js";
|
||||
import {
|
||||
buildInlineProviderModels,
|
||||
type InlineModelEntry,
|
||||
} from "./embedded-agent-runner/model.inline-provider.js";
|
||||
startSerializedSnapshotBuild,
|
||||
startSerializedSnapshotBuildBatch,
|
||||
} from "./prepared-model-runtime.build.js";
|
||||
import {
|
||||
createBundledStaticCatalogModelResolver,
|
||||
loadBundledProviderStaticCatalogContextModels,
|
||||
} from "./embedded-agent-runner/model.static-catalog.js";
|
||||
import { staticModelIdMatches } from "./embedded-agent-runner/model.static-id.js";
|
||||
import { buildPreparedModelCatalogSnapshot, type ModelCatalogEntry } from "./model-catalog.js";
|
||||
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
|
||||
import { ensureOpenClawModelsJson } from "./models-config.js";
|
||||
import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js";
|
||||
import { AuthStorage, type AuthStorageData } from "./sessions/auth-storage.js";
|
||||
import type { ModelRegistry } from "./sessions/model-registry.js";
|
||||
PreparedModelRuntimeOwnerNotPublishedError,
|
||||
PreparedModelRuntimePublicationSupersededError,
|
||||
toPreparedModelRuntimeError,
|
||||
} from "./prepared-model-runtime.errors.js";
|
||||
import type {
|
||||
PreparedModelRuntimeCatalogMode,
|
||||
PreparedModelRuntimeInput,
|
||||
PreparedModelRuntimeOwner,
|
||||
PreparedModelRuntimeReplacement,
|
||||
PreparedModelRuntimeSnapshot,
|
||||
} from "./prepared-model-runtime.types.js";
|
||||
|
||||
const MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS = 5_000;
|
||||
|
||||
type PreparedModelRuntimeCatalogMode = "live" | "static";
|
||||
|
||||
export type PreparedModelRuntimeSnapshot = Readonly<{
|
||||
agentId?: string;
|
||||
agentDir: string;
|
||||
inheritedAuthDir?: string;
|
||||
workspaceDir?: string;
|
||||
/** Run-prepared repository root; null means discovery completed without a match. */
|
||||
repoRoot?: string | null;
|
||||
/** Stable identity derived from repoRoot; null means the run is outside a repository. */
|
||||
projectKey?: string | null;
|
||||
/** Session active project set, ordered most-recent first; empty before run binding. */
|
||||
activeProjectKeys: readonly string[];
|
||||
config: OpenClawConfig;
|
||||
metadataSnapshot: PluginMetadataSnapshot;
|
||||
messageToolCatalog?: PreparedMessageToolCatalog;
|
||||
mediaCapabilityProviders?: ReturnType<typeof prepareMediaCapabilityProviders>;
|
||||
modelCatalog: ModelCatalogSnapshot;
|
||||
/** Full static models for configured refs, resolved once at the lifecycle boundary. */
|
||||
configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[];
|
||||
/** Inline provider projection prepared once for all resolutions owned by this snapshot. */
|
||||
inlineProviderModels: readonly InlineModelEntry[];
|
||||
createStores: () => PreparedModelRuntimeStores;
|
||||
}>;
|
||||
|
||||
type PreparedConfiguredRuntimeModel = Readonly<{
|
||||
provider: string;
|
||||
modelId: string;
|
||||
model: ProviderRuntimeModel;
|
||||
}>;
|
||||
|
||||
export type PreparedModelRuntimeStores = {
|
||||
authStorage: AuthStorage;
|
||||
modelRegistry: ModelRegistry;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeInput = {
|
||||
agentId?: string;
|
||||
agentDir: string;
|
||||
inheritedAuthDir?: string;
|
||||
workspaceDir?: string;
|
||||
preserveWorkspaceDirOnRefresh?: boolean;
|
||||
readOnly?: boolean;
|
||||
skipCredentials?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config: OpenClawConfig;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeLease = Readonly<{
|
||||
snapshot: PreparedModelRuntimeSnapshot;
|
||||
release: () => void;
|
||||
}>;
|
||||
|
||||
export type PreparedModelRuntimePublicationOptions = {
|
||||
force?: boolean;
|
||||
provenance?: PreparedModelRuntimeOwner["provenance"];
|
||||
catalogMode?: PreparedModelRuntimeCatalogMode;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeRefreshOptions = {
|
||||
gatewayLifecycle?: boolean;
|
||||
defaultWorkspaceDir?: string;
|
||||
catalogMode?: PreparedModelRuntimeCatalogMode;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeOwner = {
|
||||
input: PreparedModelRuntimeInput;
|
||||
environmentFingerprint: string;
|
||||
catalogMode: PreparedModelRuntimeCatalogMode;
|
||||
provenance: "configured" | "standalone" | "explicit" | "run" | "ephemeral";
|
||||
generation: number;
|
||||
needsRefresh: boolean;
|
||||
refreshError?: Error;
|
||||
snapshot?: PreparedModelRuntimeSnapshot;
|
||||
pending?: Promise<PreparedModelRuntimeSnapshot>;
|
||||
buildCompletion?: Promise<void>;
|
||||
leaseCount?: number;
|
||||
};
|
||||
export { startSerializedSnapshotBuildBatch };
|
||||
export type {
|
||||
PreparedModelRuntimeInput,
|
||||
PreparedModelRuntimeLease,
|
||||
PreparedModelRuntimeOwner,
|
||||
PreparedModelRuntimePublicationOptions,
|
||||
PreparedModelRuntimeRefreshOptions,
|
||||
PreparedModelRuntimeReplacement,
|
||||
PreparedModelRuntimeReplacementGateId,
|
||||
PreparedModelRuntimeSnapshot,
|
||||
PreparedModelRuntimeStores,
|
||||
} from "./prepared-model-runtime.types.js";
|
||||
|
||||
export function createPreparedModelRuntimeOwner(
|
||||
input: PreparedModelRuntimeInput,
|
||||
@@ -136,16 +54,10 @@ export function createPreparedModelRuntimeOwner(
|
||||
};
|
||||
}
|
||||
|
||||
export type PreparedModelRuntimeReplacement = {
|
||||
gateId: PreparedModelRuntimeReplacementGateId;
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
export {
|
||||
PreparedModelRuntimeOwnerNotPublishedError,
|
||||
PreparedModelRuntimePublicationSupersededError,
|
||||
};
|
||||
export type PreparedModelRuntimeReplacementGateId = symbol;
|
||||
export class PreparedModelRuntimeOwnerNotPublishedError extends Error {}
|
||||
|
||||
export class PreparedModelRuntimePublicationSupersededError extends PreparedModelRuntimeOwnerNotPublishedError {}
|
||||
|
||||
function findConfiguredOwnerCandidates(
|
||||
owners: Map<string, PreparedModelRuntimeOwner>,
|
||||
@@ -264,107 +176,6 @@ export function effectiveEnvironmentFingerprint(input: PreparedModelRuntimeInput
|
||||
return hashRuntimeConfigValue(input.env ?? process.env);
|
||||
}
|
||||
|
||||
function isCatalogModelApi(
|
||||
value: string | undefined,
|
||||
): value is NonNullable<ModelCatalogEntry["api"]> {
|
||||
return value !== undefined && (MODEL_APIS as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function toStaticCatalogEntry(
|
||||
model: Awaited<ReturnType<typeof loadBundledProviderStaticCatalogContextModels>>[number],
|
||||
): ModelCatalogEntry {
|
||||
return {
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
provider: model.provider,
|
||||
...(isCatalogModelApi(model.api) ? { api: model.api } : {}),
|
||||
...(model.baseUrl ? { baseUrl: model.baseUrl } : {}),
|
||||
...(model.contextWindow ? { contextWindow: model.contextWindow } : {}),
|
||||
...(model.contextTokens ? { contextTokens: model.contextTokens } : {}),
|
||||
...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
|
||||
...(model.input ? { input: model.input } : {}),
|
||||
...(model.params ? { params: model.params } : {}),
|
||||
...(model.compat ? { compat: model.compat } : {}),
|
||||
...(model.mediaInput ? { mediaInput: model.mediaInput } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function collectPreparedModelRuntimeProviderIds(
|
||||
config: OpenClawConfig,
|
||||
credentials: Readonly<AuthStorageData>,
|
||||
): string[] {
|
||||
const providerIds = new Set<string>();
|
||||
const addProviderId = (value: string) => {
|
||||
const providerId = normalizeProviderId(value);
|
||||
if (providerId) {
|
||||
providerIds.add(providerId);
|
||||
}
|
||||
};
|
||||
for (const providerId of Object.keys(credentials)) {
|
||||
addProviderId(providerId);
|
||||
}
|
||||
for (const providerId of Object.keys(config.models?.providers ?? {})) {
|
||||
addProviderId(providerId);
|
||||
}
|
||||
for (const ref of collectConfiguredModelRefs(config)) {
|
||||
const separator = ref.value.indexOf("/");
|
||||
if (separator > 0) {
|
||||
addProviderId(ref.value.slice(0, separator));
|
||||
}
|
||||
}
|
||||
return [...providerIds].toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function prepareConfiguredRuntimeModels(params: {
|
||||
config: OpenClawConfig;
|
||||
env: NodeJS.ProcessEnv;
|
||||
metadataSnapshot: PluginMetadataSnapshot;
|
||||
providerStaticModels: readonly ProviderRuntimeModel[];
|
||||
workspaceDir?: string;
|
||||
}): PreparedConfiguredRuntimeModel[] {
|
||||
const prepared: PreparedConfiguredRuntimeModel[] = [];
|
||||
const seen = new Set<string>();
|
||||
const resolveStaticCatalogModel = createBundledStaticCatalogModelResolver({
|
||||
cfg: params.config,
|
||||
env: params.env,
|
||||
includeRuntimeDiscovery: true,
|
||||
metadataSnapshot: params.metadataSnapshot,
|
||||
...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}),
|
||||
});
|
||||
for (const { value } of collectConfiguredModelRefs(params.config)) {
|
||||
const separator = value.indexOf("/");
|
||||
if (separator <= 0 || separator >= value.length - 1) {
|
||||
continue;
|
||||
}
|
||||
const provider = normalizeProviderId(value.slice(0, separator));
|
||||
const modelId = value.slice(separator + 1).trim();
|
||||
if (!provider || !modelId) {
|
||||
continue;
|
||||
}
|
||||
const key = `${provider}\0${modelId.toLowerCase()}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
// Match request-time fallback precedence exactly: manifest/runtime-discovery rows win,
|
||||
// and the provider-static catalog fills only models absent from that surface.
|
||||
const model =
|
||||
resolveStaticCatalogModel({ provider, modelId }) ??
|
||||
params.providerStaticModels.find((candidate) =>
|
||||
staticModelIdMatches({
|
||||
candidateId: candidate.id,
|
||||
rowProvider: candidate.provider,
|
||||
provider,
|
||||
modelId,
|
||||
}),
|
||||
);
|
||||
if (model) {
|
||||
prepared.push({ provider, modelId, model });
|
||||
}
|
||||
}
|
||||
return prepared;
|
||||
}
|
||||
|
||||
export function ownerKey(input: PreparedModelRuntimeInput): string {
|
||||
return JSON.stringify({
|
||||
agentId: input.agentId,
|
||||
@@ -424,7 +235,7 @@ export function hasSameLifecycleInput(
|
||||
}
|
||||
|
||||
export function toError(error: unknown): Error {
|
||||
return error instanceof Error ? error : new Error(String(error));
|
||||
return toPreparedModelRuntimeError(error);
|
||||
}
|
||||
|
||||
export function createPreparedModelRuntimeReplacement(): PreparedModelRuntimeReplacement {
|
||||
@@ -464,166 +275,136 @@ export function listConfiguredOwnerInputs(
|
||||
});
|
||||
}
|
||||
|
||||
async function buildSnapshot(
|
||||
input: PreparedModelRuntimeInput,
|
||||
catalogMode: PreparedModelRuntimeCatalogMode,
|
||||
): Promise<PreparedModelRuntimeSnapshot> {
|
||||
const env = input.env ?? process.env;
|
||||
const runtimePluginRegistry = !input.readOnly
|
||||
? ensureRuntimePluginsLoaded({
|
||||
config: input.config,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
})
|
||||
: undefined;
|
||||
const pluginMetadataSnapshot = resolvePluginMetadataSnapshot({
|
||||
config: input.config,
|
||||
env,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
export async function publishPreparedModelRuntimeOwnerBatch(params: {
|
||||
entries: Array<{
|
||||
owner: PreparedModelRuntimeOwner;
|
||||
input: PreparedModelRuntimeInput;
|
||||
}>;
|
||||
owners: Map<string, PreparedModelRuntimeOwner>;
|
||||
agentBuildCompletions: Map<string, Promise<void>>;
|
||||
buildTimeoutMs: number;
|
||||
}): Promise<void> {
|
||||
const candidates = params.entries.map(({ owner, input }) => {
|
||||
owner.input = input;
|
||||
owner.environmentFingerprint = effectiveEnvironmentFingerprint(input);
|
||||
owner.generation += 1;
|
||||
owner.needsRefresh = true;
|
||||
owner.refreshError = undefined;
|
||||
const generation = owner.generation;
|
||||
const key = ownerKey(input);
|
||||
return {
|
||||
catalogMode: owner.catalogMode,
|
||||
input,
|
||||
isCurrent: () => owner.generation === generation && params.owners.get(key) === owner,
|
||||
owner,
|
||||
};
|
||||
});
|
||||
const mediaCapabilityProviders = input.readOnly
|
||||
? undefined
|
||||
: prepareMediaCapabilityProviders({
|
||||
cfg: input.config,
|
||||
pluginMetadataSnapshot,
|
||||
registry: runtimePluginRegistry,
|
||||
});
|
||||
const messageToolCatalog =
|
||||
(runtimePluginRegistry
|
||||
? getPreparedMessageToolCatalogForRegistry(runtimePluginRegistry)
|
||||
: getPreparedMessageToolCatalog()) ?? EMPTY_PREPARED_MESSAGE_TOOL_CATALOG;
|
||||
const templateAuthStorage = discoverAuthStorage(input.agentDir, {
|
||||
config: input.config,
|
||||
// Snapshot construction never initializes, migrates, or externally syncs auth. ModelRegistry
|
||||
// discovery only parses the credential generation captured here.
|
||||
readOnly: true,
|
||||
...(input.skipCredentials ? { skipCredentials: true } : {}),
|
||||
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
...(input.env ? { env } : {}),
|
||||
});
|
||||
const credentials = templateAuthStorage.getAll();
|
||||
const providerIds = collectPreparedModelRuntimeProviderIds(input.config, credentials);
|
||||
if (!input.readOnly) {
|
||||
await ensureOpenClawModelsJson(input.config, input.agentDir, {
|
||||
pluginMetadataSnapshot,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
...(input.env ? { env } : {}),
|
||||
...(catalogMode === "static"
|
||||
? {
|
||||
providerDiscoveryEntriesOnly: true,
|
||||
providerDiscoveryProviderIds: providerIds,
|
||||
const groups = new Map<PreparedModelRuntimeOwner["catalogMode"], typeof candidates>();
|
||||
for (const candidate of candidates) {
|
||||
const group = groups.get(candidate.catalogMode);
|
||||
if (group) {
|
||||
group.push(candidate);
|
||||
} else {
|
||||
groups.set(candidate.catalogMode, [candidate]);
|
||||
}
|
||||
}
|
||||
const snapshots = new Map<PreparedModelRuntimeOwner, PreparedModelRuntimeSnapshot>();
|
||||
const publication = (async () => {
|
||||
try {
|
||||
while (true) {
|
||||
const attempt = candidates.filter(
|
||||
(candidate) => candidate.isCurrent() && !snapshots.has(candidate.owner),
|
||||
);
|
||||
if (attempt.length === 0) {
|
||||
break;
|
||||
}
|
||||
try {
|
||||
// Auth events can touch live and static owners together. Build mode groups in sequence
|
||||
// so one mutation cannot reintroduce broad plugin/catalog fanout on constrained hosts.
|
||||
for (const [catalogMode, group] of groups) {
|
||||
const currentGroup = group.filter(
|
||||
(candidate) => candidate.isCurrent() && !snapshots.has(candidate.owner),
|
||||
);
|
||||
if (currentGroup.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const build = startSerializedSnapshotBuildBatch(
|
||||
currentGroup.map(({ input }) => input),
|
||||
params.agentBuildCompletions,
|
||||
params.buildTimeoutMs,
|
||||
catalogMode,
|
||||
undefined,
|
||||
new Map(currentGroup.map((candidate) => [candidate.input, candidate.isCurrent])),
|
||||
);
|
||||
for (const candidate of currentGroup) {
|
||||
candidate.owner.buildCompletion = build.completion;
|
||||
void build.completion.then(() => {
|
||||
if (candidate.owner.buildCompletion === build.completion) {
|
||||
candidate.owner.buildCompletion = undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
const built = await build.pending;
|
||||
for (const [index, candidate] of currentGroup.entries()) {
|
||||
snapshots.set(candidate.owner, built[index]!);
|
||||
}
|
||||
}
|
||||
: { providerDiscoveryTimeoutMs: MODEL_RUNTIME_PROVIDER_DISCOVERY_TIMEOUT_MS }),
|
||||
});
|
||||
}
|
||||
const templateModelRegistry = discoverModels(templateAuthStorage, input.agentDir, {
|
||||
config: input.config,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
...(pluginMetadataSnapshot ? { pluginMetadataSnapshot } : {}),
|
||||
...(catalogMode === "static" ? { normalizeModels: false } : {}),
|
||||
});
|
||||
const modelCatalog = await buildPreparedModelCatalogSnapshot({
|
||||
agentDir: input.agentDir,
|
||||
authCredentials: credentials,
|
||||
config: input.config,
|
||||
modelRegistry: templateModelRegistry,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
includeProviderPluginAugmentation: catalogMode === "live",
|
||||
...(input.env ? { env } : {}),
|
||||
...(input.readOnly ? { readOnly: true } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const providerStaticModels = await loadBundledProviderStaticCatalogContextModels({
|
||||
cfg: input.config,
|
||||
env,
|
||||
...(catalogMode === "static" ? { providerIds } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const configuredRuntimeModels = prepareConfiguredRuntimeModels({
|
||||
config: input.config,
|
||||
env,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
providerStaticModels,
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
});
|
||||
const staticModels = new Map<string, ProviderRuntimeModel>();
|
||||
for (const model of [
|
||||
...configuredRuntimeModels.map((configured) => configured.model),
|
||||
...providerStaticModels,
|
||||
]) {
|
||||
const modelKey = `${normalizeProviderId(model.provider)}\0${model.id.trim().toLowerCase()}`;
|
||||
if (!staticModels.has(modelKey)) {
|
||||
staticModels.set(modelKey, model);
|
||||
break;
|
||||
} catch (error) {
|
||||
const refreshError = toError(error);
|
||||
const lostCandidate = attempt.some((candidate) => !candidate.isCurrent());
|
||||
if (
|
||||
!(refreshError instanceof PreparedModelRuntimePublicationSupersededError) ||
|
||||
!lostCandidate
|
||||
) {
|
||||
throw refreshError;
|
||||
}
|
||||
// Supersession belongs to one owner generation. Retry only still-current siblings so
|
||||
// an agent-local mutation cannot discard an inherited-auth refresh built for others.
|
||||
}
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.isCurrent()) {
|
||||
continue;
|
||||
}
|
||||
const snapshot = snapshots.get(candidate.owner);
|
||||
if (!snapshot) {
|
||||
throw new Error(
|
||||
`prepared model runtime snapshot missing after auth refresh for ${candidate.input.agentDir}`,
|
||||
);
|
||||
}
|
||||
candidate.owner.snapshot = snapshot;
|
||||
candidate.owner.pending = undefined;
|
||||
candidate.owner.needsRefresh = false;
|
||||
}
|
||||
} catch (error) {
|
||||
const refreshError = toError(error);
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.isCurrent()) {
|
||||
continue;
|
||||
}
|
||||
candidate.owner.pending = undefined;
|
||||
candidate.owner.needsRefresh = true;
|
||||
candidate.owner.refreshError = refreshError;
|
||||
}
|
||||
throw refreshError;
|
||||
}
|
||||
}
|
||||
const staticEntries = [...staticModels.values()].map(toStaticCatalogEntry);
|
||||
// Config reload publishes a replacement snapshot. Keep the synchronous inline projection
|
||||
// at that lifecycle boundary instead of rebuilding it on every model resolution in a turn.
|
||||
const inlineProviderModels = buildInlineProviderModels(input.config.models?.providers ?? {});
|
||||
const createStores = (): PreparedModelRuntimeStores => {
|
||||
// Runtime API keys and session extensions mutate these objects. Fork them per run while the
|
||||
// credential map and parsed catalog remain owned by the lifecycle snapshot.
|
||||
const authStorage = AuthStorage.inMemory(credentials);
|
||||
return { authStorage, modelRegistry: templateModelRegistry.fork(authStorage) };
|
||||
};
|
||||
return Object.freeze({
|
||||
...(input.agentId ? { agentId: input.agentId } : {}),
|
||||
agentDir: input.agentDir,
|
||||
activeProjectKeys: [],
|
||||
...(input.inheritedAuthDir ? { inheritedAuthDir: input.inheritedAuthDir } : {}),
|
||||
...(input.workspaceDir ? { workspaceDir: input.workspaceDir } : {}),
|
||||
config: input.config,
|
||||
metadataSnapshot: pluginMetadataSnapshot,
|
||||
messageToolCatalog,
|
||||
...(mediaCapabilityProviders ? { mediaCapabilityProviders } : {}),
|
||||
modelCatalog: { ...modelCatalog, staticEntries },
|
||||
configuredRuntimeModels,
|
||||
inlineProviderModels,
|
||||
createStores,
|
||||
});
|
||||
}
|
||||
|
||||
export function startSerializedSnapshotBuild(
|
||||
input: PreparedModelRuntimeInput,
|
||||
agentBuildCompletions: Map<string, Promise<void>>,
|
||||
buildTimeoutMs: number,
|
||||
catalogMode: PreparedModelRuntimeCatalogMode = "live",
|
||||
): {
|
||||
pending: Promise<PreparedModelRuntimeSnapshot>;
|
||||
completion: Promise<void>;
|
||||
} {
|
||||
const previousBuildCompletion = agentBuildCompletions.get(input.agentDir);
|
||||
// Lifecycle events may overlap. The timeout covers queueing plus this build, while completion
|
||||
// follows the real work so a timed-out generation can never overlap a replacement.
|
||||
const startBuild = (async () => {
|
||||
if (previousBuildCompletion) {
|
||||
await previousBuildCompletion;
|
||||
}
|
||||
return { actualBuild: buildSnapshot(input, catalogMode) };
|
||||
})();
|
||||
const completion = startBuild
|
||||
.then(async ({ actualBuild }) => await actualBuild)
|
||||
.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
agentBuildCompletions.set(input.agentDir, completion);
|
||||
void completion.then(() => {
|
||||
if (agentBuildCompletions.get(input.agentDir) === completion) {
|
||||
agentBuildCompletions.delete(input.agentDir);
|
||||
}
|
||||
});
|
||||
return {
|
||||
pending: withTimeout(
|
||||
async () => {
|
||||
const { actualBuild } = await startBuild;
|
||||
return await actualBuild;
|
||||
},
|
||||
buildTimeoutMs,
|
||||
"prepared model runtime publication",
|
||||
),
|
||||
completion,
|
||||
};
|
||||
for (const candidate of candidates) {
|
||||
const pending = publication.then(() => {
|
||||
// A newer auth publication may win while this batch finishes. Reject deduplicated callers
|
||||
// at the owner boundary so the stale snapshot cannot escape despite being skipped at commit.
|
||||
if (!candidate.isCurrent()) {
|
||||
throw new PreparedModelRuntimePublicationSupersededError(
|
||||
`prepared model runtime publication was superseded for ${candidate.input.agentDir}`,
|
||||
);
|
||||
}
|
||||
return snapshots.get(candidate.owner)!;
|
||||
});
|
||||
candidate.owner.pending = pending;
|
||||
void pending.catch(() => undefined);
|
||||
}
|
||||
await publication;
|
||||
}
|
||||
|
||||
export async function publishModelRuntimeSnapshot(
|
||||
@@ -650,6 +431,7 @@ export async function publishModelRuntimeSnapshot(
|
||||
agentBuildCompletions,
|
||||
buildTimeoutMs,
|
||||
catalogMode,
|
||||
() => owner.generation === generation && owners.get(key) === owner,
|
||||
);
|
||||
owner.buildCompletion = build.completion;
|
||||
void build.completion.then(() => {
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type CreateStaticCatalogResolver =
|
||||
typeof import("./embedded-agent-runner/model.static-catalog.js").createBundledStaticCatalogModelResolver;
|
||||
type StaticCatalogResolver = ReturnType<CreateStaticCatalogResolver>;
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const metadataSnapshot = {
|
||||
plugins: [],
|
||||
pluginIds: [],
|
||||
index: { plugins: [] },
|
||||
index: { plugins: [{ pluginId: "openai", enabled: true }] },
|
||||
manifestRegistry: { plugins: [], diagnostics: [] },
|
||||
owners: {
|
||||
channels: new Map(),
|
||||
@@ -19,22 +23,73 @@ const mocks = vi.hoisted(() => {
|
||||
};
|
||||
const authStorage = {
|
||||
getAll: vi.fn(() => ({ openai: { type: "api_key" as const, key: "test-openai-key" } })),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
};
|
||||
const modelRegistry = {
|
||||
fork: vi.fn((nextAuthStorage: unknown) => ({ authStorage: nextAuthStorage })),
|
||||
getAll: vi.fn(() => []),
|
||||
find: vi.fn(() => null),
|
||||
};
|
||||
const resolveSyntheticAuth = vi.fn(() => ({
|
||||
apiKey: "synthetic-openai-key",
|
||||
source: "test",
|
||||
mode: "api-key" as const,
|
||||
}));
|
||||
return {
|
||||
authStorage,
|
||||
modelRegistry,
|
||||
metadataSnapshot,
|
||||
resolveAmbientCredentials: vi.fn((..._args: unknown[]) => ({})),
|
||||
discoverAuthStorage: vi.fn(() => authStorage),
|
||||
discoverModels: vi.fn(() => modelRegistry),
|
||||
ensureOpenClawModelsJson: vi.fn(async () => ({ agentDir: "/tmp/agent", wrote: false })),
|
||||
ensureOpenClawModelsJson: vi.fn(
|
||||
async (_config: unknown, _agentDir: unknown, _options?: unknown) => ({
|
||||
agentDir: "/tmp/agent",
|
||||
wrote: false,
|
||||
}),
|
||||
),
|
||||
planOpenClawModelsJsonSource: vi.fn(async (_config: unknown, agentDir: unknown) => ({
|
||||
agentDir: String(agentDir),
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [],
|
||||
})),
|
||||
buildPreparedModelCatalogSnapshot: vi.fn(async () => ({ entries: [], routeVariants: [] })),
|
||||
ensureRuntimePluginsLoaded: vi.fn(),
|
||||
loadStaticCatalog: vi.fn(async () => []),
|
||||
resolveStaticCatalogModel: vi.fn(() => undefined),
|
||||
prepareStaticCatalog: vi.fn(async (..._args: unknown[]) => ({
|
||||
providers: [
|
||||
{
|
||||
id: "openai",
|
||||
label: "OpenAI",
|
||||
auth: [],
|
||||
resolveSyntheticAuth,
|
||||
},
|
||||
],
|
||||
entries: [
|
||||
{
|
||||
provider: { id: "openai", label: "OpenAI", auth: [] },
|
||||
result: {
|
||||
provider: {
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
api: "openai-responses",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
})),
|
||||
resolveStaticCatalogModel: vi.fn<StaticCatalogResolver>(() => undefined),
|
||||
resolveSyntheticAuth,
|
||||
mutationListener: undefined as
|
||||
| ((event: { agentDir?: string; affectsInheritedStores: boolean }) => void)
|
||||
| undefined,
|
||||
@@ -42,12 +97,22 @@ const mocks = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
|
||||
loadPluginMetadataSnapshot: () => mocks.metadataSnapshot,
|
||||
resolvePluginMetadataSnapshot: () => mocks.metadataSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("./agent-auth-discovery.js", () => ({
|
||||
resolveAmbientAgentCredentialsForDiscovery: mocks.resolveAmbientCredentials,
|
||||
}));
|
||||
|
||||
vi.mock("./agent-model-discovery.js", () => ({
|
||||
discoverAuthStorage: mocks.discoverAuthStorage,
|
||||
discoverModels: mocks.discoverModels,
|
||||
discoverModelsFromCapturedSources: mocks.discoverModels,
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/synthetic-auth.runtime.js", () => ({
|
||||
resolveRuntimeSyntheticAuthProviderRefs: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./agent-scope.js", () => ({
|
||||
@@ -73,6 +138,11 @@ vi.mock("./model-catalog.js", () => ({
|
||||
|
||||
vi.mock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: mocks.ensureOpenClawModelsJson,
|
||||
planOpenClawModelsJsonSource: mocks.planOpenClawModelsJsonSource,
|
||||
}));
|
||||
|
||||
vi.mock("./models-config.providers.implicit.js", () => ({
|
||||
prepareImplicitProviderStaticCatalog: mocks.prepareStaticCatalog,
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-plugins.js", () => ({
|
||||
@@ -88,17 +158,65 @@ vi.mock("../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => ({ warn: vi.fn() }),
|
||||
}));
|
||||
|
||||
const { refreshPreparedModelRuntimeSnapshots } = await import("./prepared-model-runtime.js");
|
||||
const { getPreparedModelRuntimeSnapshot, refreshPreparedModelRuntimeSnapshots } =
|
||||
await import("./prepared-model-runtime.js");
|
||||
const { resetPreparedModelRuntimeSnapshotsForTest } =
|
||||
await import("./prepared-model-runtime.test-support.js");
|
||||
|
||||
beforeEach(() => {
|
||||
resetPreparedModelRuntimeSnapshotsForTest();
|
||||
vi.clearAllMocks();
|
||||
mocks.resolveStaticCatalogModel.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe("prepared model runtime Gateway catalog mode", () => {
|
||||
it("keeps startup and auth refreshes on configured static provider facts", async () => {
|
||||
it("does not publish a static catalog generation superseded while its hook is running", async () => {
|
||||
const staleConfig = { agents: { defaults: { model: "openai/gpt-5.5" } } };
|
||||
const latestConfig = { agents: { defaults: { model: "openai/gpt-5.6" } } };
|
||||
const defaultPrepareStaticCatalog = mocks.prepareStaticCatalog.getMockImplementation();
|
||||
let releaseStaleHook: (() => void) | undefined;
|
||||
let staleHookStarted!: () => void;
|
||||
const staleHookPending = new Promise<void>((resolve) => {
|
||||
staleHookStarted = resolve;
|
||||
});
|
||||
mocks.prepareStaticCatalog.mockImplementationOnce(async (...args: unknown[]) => {
|
||||
staleHookStarted();
|
||||
await new Promise<void>((resolve) => {
|
||||
releaseStaleHook = resolve;
|
||||
});
|
||||
if (!defaultPrepareStaticCatalog) {
|
||||
throw new Error("expected default static catalog implementation");
|
||||
}
|
||||
return await defaultPrepareStaticCatalog(...args);
|
||||
});
|
||||
|
||||
const stale = refreshPreparedModelRuntimeSnapshots(staleConfig, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
});
|
||||
await staleHookPending;
|
||||
const latest = refreshPreparedModelRuntimeSnapshots(latestConfig, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
});
|
||||
releaseStaleHook?.();
|
||||
|
||||
await expect(stale).rejects.toThrow("superseded");
|
||||
await latest;
|
||||
expect(
|
||||
getPreparedModelRuntimeSnapshot({
|
||||
agentId: "default",
|
||||
config: latestConfig,
|
||||
agentDir: "/tmp/prepared-static-agent",
|
||||
inheritedAuthDir: "/tmp/prepared-static-agent",
|
||||
workspaceDir: "/tmp/prepared-static-workspace",
|
||||
})?.config,
|
||||
).toBe(latestConfig);
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.discoverModels).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("publishes configured turn facts without eagerly building a full catalog", async () => {
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
@@ -108,42 +226,138 @@ describe("prepared model runtime Gateway catalog mode", () => {
|
||||
},
|
||||
};
|
||||
|
||||
let configuredRuntimeModelCount = 0;
|
||||
let generatedCatalogReadCount = -1;
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
onBuildStats: (stats) => {
|
||||
configuredRuntimeModelCount = stats.configuredRuntimeModelCount;
|
||||
generatedCatalogReadCount = stats.generatedCatalogReadCount;
|
||||
},
|
||||
});
|
||||
|
||||
const expectedStaticOptions = expect.objectContaining({
|
||||
pluginMetadataSnapshot: mocks.metadataSnapshot,
|
||||
providerDiscoveryEntriesOnly: true,
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
});
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenLastCalledWith(
|
||||
config,
|
||||
"/tmp/prepared-static-agent",
|
||||
expectedStaticOptions,
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.ensureRuntimePluginsLoaded).not.toHaveBeenCalled();
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
staticCatalogProviderIds: ["openai"],
|
||||
}),
|
||||
);
|
||||
expect(mocks.resolveAmbientCredentials).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
syntheticAuthProviderRefs: ["openai"],
|
||||
resolveSyntheticAuth: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
const ambientOptions = mocks.resolveAmbientCredentials.mock.calls[0]?.[0] as
|
||||
| { resolveSyntheticAuth?: (provider: string) => { apiKey?: string } | undefined }
|
||||
| undefined;
|
||||
expect(ambientOptions?.resolveSyntheticAuth?.("openai")).toMatchObject({
|
||||
apiKey: "synthetic-openai-key",
|
||||
});
|
||||
expect(mocks.resolveSyntheticAuth).toHaveBeenCalledWith({
|
||||
config,
|
||||
provider: "openai",
|
||||
providerConfig: undefined,
|
||||
});
|
||||
expect(mocks.discoverModels).toHaveBeenLastCalledWith(
|
||||
mocks.authStorage,
|
||||
expect.objectContaining({
|
||||
includePluginCatalogs: true,
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [],
|
||||
pluginMetadataSnapshot: mocks.metadataSnapshot,
|
||||
workspaceDir: "/tmp/prepared-static-workspace",
|
||||
}),
|
||||
);
|
||||
expect(mocks.buildPreparedModelCatalogSnapshot).not.toHaveBeenCalled();
|
||||
expect(mocks.loadStaticCatalog).not.toHaveBeenCalled();
|
||||
expect(configuredRuntimeModelCount).toBe(1);
|
||||
expect(generatedCatalogReadCount).toBe(0);
|
||||
const snapshot = getPreparedModelRuntimeSnapshot({
|
||||
agentId: "default",
|
||||
config,
|
||||
agentDir: "/tmp/prepared-static-agent",
|
||||
inheritedAuthDir: "/tmp/prepared-static-agent",
|
||||
workspaceDir: "/tmp/prepared-static-workspace",
|
||||
});
|
||||
expect(snapshot?.configuredRuntimeModels).toHaveLength(1);
|
||||
expect(snapshot?.messageToolCatalog).toBeUndefined();
|
||||
expect(snapshot?.mediaCapabilityProviders).toBeUndefined();
|
||||
const fullCatalog = await snapshot?.loadFullModelCatalog?.();
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledWith(
|
||||
config,
|
||||
"/tmp/prepared-static-agent",
|
||||
expect.objectContaining({ normalizeModels: false }),
|
||||
expect.objectContaining({
|
||||
pluginMetadataSnapshot: mocks.metadataSnapshot,
|
||||
providerDiscoveryTimeoutMs: 5_000,
|
||||
}),
|
||||
);
|
||||
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ includeProviderPluginAugmentation: false }),
|
||||
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ includeProviderPluginAugmentation: true }),
|
||||
);
|
||||
expect(mocks.loadStaticCatalog).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ providerIds: ["openai"] }),
|
||||
expect(mocks.ensureRuntimePluginsLoaded).toHaveBeenCalledOnce();
|
||||
expect(mocks.ensureRuntimePluginsLoaded.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mocks.buildPreparedModelCatalogSnapshot.mock.invocationCallOrder[0]!,
|
||||
);
|
||||
expect(mocks.loadStaticCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ metadataSnapshot: mocks.metadataSnapshot }),
|
||||
);
|
||||
|
||||
mocks.mutationListener?.({
|
||||
agentDir: "/tmp/prepared-static-agent",
|
||||
affectsInheritedStores: false,
|
||||
});
|
||||
await vi.waitFor(() => expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenLastCalledWith(
|
||||
config,
|
||||
"/tmp/prepared-static-agent",
|
||||
expectedStaticOptions,
|
||||
await expect(snapshot?.loadFullModelCatalog?.()).resolves.toBe(fullCatalog);
|
||||
await vi.waitFor(() => expect(mocks.prepareStaticCatalog).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.ensureOpenClawModelsJson).not.toHaveBeenCalled();
|
||||
expect(mocks.planOpenClawModelsJsonSource).toHaveBeenCalledOnce();
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.discoverModels).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("does not request a static provider hook when manifest facts resolve the configured model", async () => {
|
||||
mocks.resolveStaticCatalogModel.mockReturnValue({
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
provider: "openai",
|
||||
api: "openai-responses",
|
||||
baseUrl: "https://api.openai.com/v1",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
});
|
||||
const config = {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/gpt-5.5" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await refreshPreparedModelRuntimeSnapshots(config, {
|
||||
gatewayLifecycle: true,
|
||||
catalogMode: "static",
|
||||
});
|
||||
|
||||
expect(mocks.prepareStaticCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
providerDiscoveryProviderIds: ["openai"],
|
||||
staticCatalogProviderIds: [],
|
||||
}),
|
||||
);
|
||||
const snapshot = getPreparedModelRuntimeSnapshot({
|
||||
agentId: "default",
|
||||
config,
|
||||
agentDir: "/tmp/prepared-static-agent",
|
||||
inheritedAuthDir: "/tmp/prepared-static-agent",
|
||||
workspaceDir: "/tmp/prepared-static-workspace",
|
||||
});
|
||||
expect(snapshot?.configuredRuntimeModels).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,17 +7,27 @@ type CreateStaticCatalogResolver =
|
||||
type StaticCatalogResolver = ReturnType<CreateStaticCatalogResolver>;
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
authStorage: { getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })) },
|
||||
authStorage: {
|
||||
getAll: vi.fn(() => ({ custom: { type: "api_key", key: "test-key" } })),
|
||||
getOAuthProviders: vi.fn(() => []),
|
||||
},
|
||||
modelRegistry: {
|
||||
fork: vi.fn((authStorage: unknown) => ({ authStorage })),
|
||||
getAll: vi.fn(() => []),
|
||||
find: vi.fn(() => null),
|
||||
},
|
||||
resolveAmbientCredentials: vi.fn((..._args: unknown[]) => ({})),
|
||||
discoverAuthStorage: vi.fn(),
|
||||
discoverModels: vi.fn(),
|
||||
ensureOpenClawModelsJson: vi.fn(async (..._args: unknown[]) => ({
|
||||
agentDir: "/tmp/agent",
|
||||
wrote: false,
|
||||
})),
|
||||
planOpenClawModelsJsonSource: vi.fn(async (...args: unknown[]) => ({
|
||||
agentDir: String(args[1]),
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [],
|
||||
})),
|
||||
buildPreparedModelCatalogSnapshot: vi.fn(async (..._args: unknown[]) => ({
|
||||
entries: [],
|
||||
routeVariants: [],
|
||||
@@ -37,6 +47,11 @@ vi.mock("./model-catalog.js", () => ({
|
||||
mocks.buildPreparedModelCatalogSnapshot(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-auth-discovery.js", () => ({
|
||||
resolveAmbientAgentCredentialsForDiscovery: (...args: unknown[]) =>
|
||||
mocks.resolveAmbientCredentials(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./agent-model-discovery.js", () => ({
|
||||
discoverAuthStorage: (...args: unknown[]) => {
|
||||
mocks.discoverAuthStorage(...args);
|
||||
@@ -46,6 +61,14 @@ vi.mock("./agent-model-discovery.js", () => ({
|
||||
mocks.discoverModels(...args);
|
||||
return mocks.modelRegistry;
|
||||
},
|
||||
discoverModelsFromCapturedSources: (...args: unknown[]) => {
|
||||
mocks.discoverModels(...args);
|
||||
return mocks.modelRegistry;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("../plugins/synthetic-auth.runtime.js", () => ({
|
||||
resolveRuntimeSyntheticAuthProviderRefs: () => [],
|
||||
}));
|
||||
|
||||
vi.mock("./agent-scope.js", () => ({
|
||||
@@ -73,6 +96,7 @@ vi.mock("./model-discovery-context.js", () => ({
|
||||
|
||||
vi.mock("./models-config.js", () => ({
|
||||
ensureOpenClawModelsJson: (...args: unknown[]) => mocks.ensureOpenClawModelsJson(...args),
|
||||
planOpenClawModelsJsonSource: (...args: unknown[]) => mocks.planOpenClawModelsJsonSource(...args),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-plugins.js", () => ({
|
||||
@@ -90,6 +114,7 @@ vi.mock("../logging/subsystem.js", () => ({
|
||||
createSubsystemLogger: () => ({ warn: vi.fn() }),
|
||||
}));
|
||||
|
||||
import { startSerializedSnapshotBuild } from "./prepared-model-runtime.build.js";
|
||||
import {
|
||||
acquireReadOnlyPreparedModelRuntime,
|
||||
activateStandalonePreparedModelRuntime,
|
||||
@@ -114,6 +139,7 @@ describe("prepared model runtime snapshots", () => {
|
||||
beforeEach(() => {
|
||||
getTesting().resetPreparedModelRuntimeSnapshotsForTest();
|
||||
mocks.discoverAuthStorage.mockClear();
|
||||
mocks.resolveAmbientCredentials.mockClear();
|
||||
mocks.discoverModels.mockClear();
|
||||
mocks.ensureOpenClawModelsJson.mockClear();
|
||||
mocks.buildPreparedModelCatalogSnapshot.mockClear();
|
||||
@@ -126,6 +152,21 @@ describe("prepared model runtime snapshots", () => {
|
||||
mocks.configuredAgentIds = [];
|
||||
});
|
||||
|
||||
it("allows a direct serialized build without a lifecycle generation guard", async () => {
|
||||
const input = {
|
||||
config: {},
|
||||
agentDir: "/tmp/direct-prepared-model-runtime-build",
|
||||
readOnly: true,
|
||||
};
|
||||
const build = startSerializedSnapshotBuild(input, new Map(), 1_000, "static");
|
||||
|
||||
await expect(build.pending).resolves.toMatchObject({
|
||||
agentDir: input.agentDir,
|
||||
config: input.config,
|
||||
});
|
||||
await expect(build.completion).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps an isolated setup probe exact after a gateway replacement", async () => {
|
||||
mocks.configuredAgentIds = ["default"];
|
||||
const stagedConfig = { agents: { defaults: { model: "openai/gpt-5.6" } } };
|
||||
@@ -258,11 +299,14 @@ describe("prepared model runtime snapshots", () => {
|
||||
workspaceDir: "/tmp/prepared-model-runtime-static-workspace",
|
||||
});
|
||||
|
||||
expect(mocks.loadStaticCatalog).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
env: process.env,
|
||||
workspaceDir: "/tmp/prepared-model-runtime-static-workspace",
|
||||
});
|
||||
expect(mocks.loadStaticCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: {},
|
||||
env: process.env,
|
||||
metadataSnapshot: snapshot.metadataSnapshot,
|
||||
workspaceDir: "/tmp/prepared-model-runtime-static-workspace",
|
||||
}),
|
||||
);
|
||||
expect(snapshot.modelCatalog.staticEntries).toEqual([
|
||||
{
|
||||
provider: "nvidia",
|
||||
@@ -310,11 +354,14 @@ describe("prepared model runtime snapshots", () => {
|
||||
workspaceDir: "/tmp/prepared-model-runtime-manifest-workspace",
|
||||
});
|
||||
|
||||
expect(mocks.loadStaticCatalog).toHaveBeenCalledWith({
|
||||
cfg: config,
|
||||
env: process.env,
|
||||
workspaceDir: "/tmp/prepared-model-runtime-manifest-workspace",
|
||||
});
|
||||
expect(mocks.loadStaticCatalog).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
cfg: config,
|
||||
env: process.env,
|
||||
metadataSnapshot: snapshot.metadataSnapshot,
|
||||
workspaceDir: "/tmp/prepared-model-runtime-manifest-workspace",
|
||||
}),
|
||||
);
|
||||
expect(mocks.createStaticCatalogResolver).toHaveBeenCalledOnce();
|
||||
expect(mocks.createStaticCatalogResolver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -613,6 +660,7 @@ describe("prepared model runtime snapshots", () => {
|
||||
expect(Object.isFrozen(first)).toBe(true);
|
||||
expect(mocks.ensureOpenClawModelsJson).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.discoverAuthStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.resolveAmbientCredentials).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.discoverModels).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.buildPreparedModelCatalogSnapshot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ authCredentials: mocks.authStorage.getAll() }),
|
||||
|
||||
@@ -16,10 +16,11 @@ import {
|
||||
normalizePreparedModelRuntimeInput,
|
||||
ownerKey,
|
||||
preparedModelRuntimeConfigsMatch,
|
||||
publishPreparedModelRuntimeOwnerBatch,
|
||||
publishModelRuntimeSnapshot,
|
||||
rebindInputToCommittedConfiguredOwner,
|
||||
resolvePublishedOwner,
|
||||
startSerializedSnapshotBuild,
|
||||
startSerializedSnapshotBuildBatch,
|
||||
toError,
|
||||
type PreparedModelRuntimeOwner,
|
||||
type PreparedModelRuntimeInput,
|
||||
@@ -490,12 +491,11 @@ export function rejectPendingPreparedModelRuntimeReplacement(
|
||||
/** Rebuilds active owners after config/plugin runtime publication. */
|
||||
async function refreshPreparedModelRuntimeSnapshotsNow(
|
||||
config: OpenClawConfig,
|
||||
options: PreparedModelRuntimeRefreshOptions = {},
|
||||
options: PreparedModelRuntimeRefreshOptions,
|
||||
publicationEpoch: number,
|
||||
): Promise<void> {
|
||||
const catalogMode = options.catalogMode ?? "live";
|
||||
if (options.gatewayLifecycle) {
|
||||
gatewayLifecycleActive = true;
|
||||
}
|
||||
gatewayLifecycleActive ||= options.gatewayLifecycle === true;
|
||||
const staleError = new Error("prepared model runtime owner is stale after config publication");
|
||||
for (const owner of owners.values()) {
|
||||
// Invalidate every prior generation before starting any replacement. A failed reload must
|
||||
@@ -552,26 +552,35 @@ async function refreshPreparedModelRuntimeSnapshotsNow(
|
||||
owner.needsRefresh = true;
|
||||
owner.refreshError = undefined;
|
||||
const generation = owner.generation;
|
||||
const build = startSerializedSnapshotBuild(
|
||||
input,
|
||||
agentBuildCompletions,
|
||||
modelRuntimeBuildTimeoutMs,
|
||||
catalogMode,
|
||||
);
|
||||
owner.buildCompletion = build.completion;
|
||||
owners.set(ownerKey(input), owner);
|
||||
const isCurrent = () =>
|
||||
publicationEpoch === refreshRequestEpoch &&
|
||||
owner.generation === generation &&
|
||||
owners.get(ownerKey(input)) === owner;
|
||||
return { input, isCurrent, owner };
|
||||
});
|
||||
const build = startSerializedSnapshotBuildBatch(
|
||||
candidates.map(({ input }) => input),
|
||||
agentBuildCompletions,
|
||||
modelRuntimeBuildTimeoutMs,
|
||||
catalogMode,
|
||||
options.onBuildStats,
|
||||
new Map(candidates.map((candidate) => [candidate.input, candidate.isCurrent])),
|
||||
() => publicationEpoch === refreshRequestEpoch,
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
owners.set(ownerKey(candidate.input), candidate.owner);
|
||||
candidate.owner.buildCompletion = build.completion;
|
||||
void build.completion.then(() => {
|
||||
if (owner.buildCompletion === build.completion) {
|
||||
owner.buildCompletion = undefined;
|
||||
if (candidate.owner.buildCompletion === build.completion) {
|
||||
candidate.owner.buildCompletion = undefined;
|
||||
}
|
||||
});
|
||||
return { build, generation, owner };
|
||||
});
|
||||
}
|
||||
const publication = (async () => {
|
||||
try {
|
||||
const snapshots = await Promise.all(candidates.map(({ build }) => build.pending));
|
||||
const snapshots = await build.pending;
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
if (candidate.owner.generation !== candidate.generation) {
|
||||
if (!candidate.isCurrent()) {
|
||||
continue;
|
||||
}
|
||||
candidate.owner.snapshot = snapshots[index]!;
|
||||
@@ -581,9 +590,8 @@ async function refreshPreparedModelRuntimeSnapshotsNow(
|
||||
return snapshots;
|
||||
} catch (error) {
|
||||
const refreshError = toError(error);
|
||||
await Promise.allSettled(candidates.map(({ build }) => build.pending));
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.owner.generation !== candidate.generation) {
|
||||
if (!candidate.isCurrent()) {
|
||||
continue;
|
||||
}
|
||||
candidate.owner.pending = undefined;
|
||||
@@ -594,7 +602,16 @@ async function refreshPreparedModelRuntimeSnapshotsNow(
|
||||
}
|
||||
})();
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
const pending = publication.then((snapshots) => snapshots[index]!);
|
||||
const pending = publication.then((snapshots) => {
|
||||
// Config publication is atomic, including callers deduplicated against an individual owner.
|
||||
// A superseded batch must not leak its unpublished snapshot through that pending promise.
|
||||
if (!candidate.isCurrent()) {
|
||||
throw new PreparedModelRuntimePublicationSupersededError(
|
||||
`prepared model runtime publication was superseded for ${candidate.input.agentDir}`,
|
||||
);
|
||||
}
|
||||
return snapshots[index]!;
|
||||
});
|
||||
candidate.owner.pending = pending;
|
||||
void pending.catch(() => undefined);
|
||||
}
|
||||
@@ -614,7 +631,7 @@ export function refreshPreparedModelRuntimeSnapshots(
|
||||
if (requestEpoch !== refreshRequestEpoch) {
|
||||
return;
|
||||
}
|
||||
await refreshPreparedModelRuntimeSnapshotsNow(config, options);
|
||||
await refreshPreparedModelRuntimeSnapshotsNow(config, options, requestEpoch);
|
||||
if (requestEpoch !== refreshRequestEpoch) {
|
||||
return;
|
||||
}
|
||||
@@ -686,29 +703,12 @@ async function drainPendingAuthMutations(): Promise<void> {
|
||||
entries.push({ owner, input: owner.input });
|
||||
}
|
||||
}
|
||||
const results = await Promise.allSettled(
|
||||
entries.map(
|
||||
async ({ owner, input }) =>
|
||||
await publishPreparedModelRuntimeSnapshot(input, {
|
||||
force: true,
|
||||
provenance: owner.provenance,
|
||||
}),
|
||||
),
|
||||
);
|
||||
// Supersession belongs to one owner generation. Wait for every sibling refresh before
|
||||
// deciding the batch outcome so an expected race cannot hide a genuine owner failure.
|
||||
const failures = results.flatMap((result) =>
|
||||
result.status === "rejected" &&
|
||||
!(result.reason instanceof PreparedModelRuntimePublicationSupersededError)
|
||||
? [result.reason]
|
||||
: [],
|
||||
);
|
||||
if (failures.length === 1) {
|
||||
throw failures[0];
|
||||
}
|
||||
if (failures.length > 1) {
|
||||
throw new AggregateError(failures, `${failures.length} model runtime owner refreshes failed`);
|
||||
}
|
||||
await publishPreparedModelRuntimeOwnerBatch({
|
||||
entries,
|
||||
owners,
|
||||
agentBuildCompletions,
|
||||
buildTimeoutMs: modelRuntimeBuildTimeoutMs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { PreparedMessageToolCatalog } from "../channels/plugins/message-action-discovery.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import type { prepareMediaCapabilityProviders } from "../plugins/capability-provider-runtime.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import type { InlineModelEntry } from "./embedded-agent-runner/model.inline-provider.js";
|
||||
import type { ModelCatalogSnapshot } from "./model-catalog.types.js";
|
||||
import type { PreparedConfiguredRuntimeModel } from "./prepared-model-runtime.configured.js";
|
||||
import type { AuthStorage } from "./sessions/auth-storage.js";
|
||||
import type { ModelRegistry } from "./sessions/model-registry.js";
|
||||
|
||||
export type PreparedModelRuntimeCatalogMode = "live" | "static";
|
||||
|
||||
export type PreparedModelRuntimeSnapshot = Readonly<{
|
||||
agentId?: string;
|
||||
agentDir: string;
|
||||
inheritedAuthDir?: string;
|
||||
workspaceDir?: string;
|
||||
/** Run-prepared repository root; null means discovery completed without a match. */
|
||||
repoRoot?: string | null;
|
||||
/** Stable identity derived from repoRoot; null means the run is outside a repository. */
|
||||
projectKey?: string | null;
|
||||
/** Session active project set, ordered most-recent first; empty before run binding. */
|
||||
activeProjectKeys: readonly string[];
|
||||
config: OpenClawConfig;
|
||||
metadataSnapshot: PluginMetadataSnapshot;
|
||||
messageToolCatalog?: PreparedMessageToolCatalog;
|
||||
mediaCapabilityProviders?: ReturnType<typeof prepareMediaCapabilityProviders>;
|
||||
/**
|
||||
* Configured model projection used by turn admission and synchronous callers.
|
||||
* Full inventory discovery is deliberately outside the startup publication boundary.
|
||||
*/
|
||||
modelCatalog: ModelCatalogSnapshot;
|
||||
/** Builds this generation's full control-plane catalog without replacing turn facts. */
|
||||
loadFullModelCatalog?: () => Promise<ModelCatalogSnapshot>;
|
||||
/** Full static models for configured refs, resolved once at the lifecycle boundary. */
|
||||
configuredRuntimeModels: readonly PreparedConfiguredRuntimeModel[];
|
||||
/** Inline provider projection prepared once for all resolutions owned by this snapshot. */
|
||||
inlineProviderModels: readonly InlineModelEntry[];
|
||||
createStores: () => PreparedModelRuntimeStores;
|
||||
}>;
|
||||
|
||||
export type PreparedModelRuntimeStores = {
|
||||
authStorage: AuthStorage;
|
||||
modelRegistry: ModelRegistry;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeInput = {
|
||||
agentId?: string;
|
||||
agentDir: string;
|
||||
inheritedAuthDir?: string;
|
||||
workspaceDir?: string;
|
||||
preserveWorkspaceDirOnRefresh?: boolean;
|
||||
readOnly?: boolean;
|
||||
skipCredentials?: boolean;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
config: OpenClawConfig;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeLease = Readonly<{
|
||||
snapshot: PreparedModelRuntimeSnapshot;
|
||||
release: () => void;
|
||||
}>;
|
||||
|
||||
export type PreparedModelRuntimePublicationOptions = {
|
||||
force?: boolean;
|
||||
provenance?: PreparedModelRuntimeOwner["provenance"];
|
||||
catalogMode?: PreparedModelRuntimeCatalogMode;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeRefreshOptions = {
|
||||
gatewayLifecycle?: boolean;
|
||||
defaultWorkspaceDir?: string;
|
||||
catalogMode?: PreparedModelRuntimeCatalogMode;
|
||||
onBuildStats?: (stats: PreparedModelRuntimeBuildStats) => void;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeBuildStats = Readonly<{
|
||||
agentCount: number;
|
||||
workspaceGroupCount: number;
|
||||
configuredFactsGroupCount: number;
|
||||
catalogSourceCount: number;
|
||||
credentialGroupCount: number;
|
||||
catalogGroupCount: number;
|
||||
runtimeRegistryCount: number;
|
||||
configuredRuntimeModelCount: number;
|
||||
generatedCatalogPluginCount: number;
|
||||
generatedCatalogReadCount: number;
|
||||
workspaceFactsMs: number;
|
||||
runtimePluginMs: number;
|
||||
pluginMetadataMs: number;
|
||||
staticProviderCatalogMs: number;
|
||||
ambientCredentialsMs: number;
|
||||
agentFactsMs: number;
|
||||
configuredProjectionMs: number;
|
||||
catalogSourceMs: number;
|
||||
registryMs: number;
|
||||
sourceConcurrencyLimit: number;
|
||||
fullCatalogConcurrencyLimit: number;
|
||||
}>;
|
||||
|
||||
export type PreparedModelRuntimeOwner = {
|
||||
input: PreparedModelRuntimeInput;
|
||||
environmentFingerprint: string;
|
||||
catalogMode: PreparedModelRuntimeCatalogMode;
|
||||
provenance: "configured" | "standalone" | "explicit" | "run" | "ephemeral";
|
||||
generation: number;
|
||||
needsRefresh: boolean;
|
||||
refreshError?: Error;
|
||||
snapshot?: PreparedModelRuntimeSnapshot;
|
||||
pending?: Promise<PreparedModelRuntimeSnapshot>;
|
||||
buildCompletion?: Promise<void>;
|
||||
leaseCount?: number;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeReplacement = {
|
||||
gateId: PreparedModelRuntimeReplacementGateId;
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
};
|
||||
|
||||
export type PreparedModelRuntimeReplacementGateId = symbol;
|
||||
@@ -287,6 +287,42 @@ describe("provider attribution", () => {
|
||||
expect(loadPluginMetadataSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses explicitly prepared provider facts without reading process metadata", () => {
|
||||
providerMetadataState.pluginIdScoped = true;
|
||||
providerMetadataState.snapshot = undefined;
|
||||
const providerMetadataOwners = {
|
||||
channels: new Map(),
|
||||
channelConfigs: new Map(),
|
||||
providers: new Map(),
|
||||
modelCatalogProviders: new Map(),
|
||||
cliBackends: new Map(),
|
||||
setupProviders: new Map(),
|
||||
commandAliases: new Map(),
|
||||
contracts: new Map(),
|
||||
providerEndpoints: [
|
||||
{
|
||||
endpointClass: "anthropic-public" as const,
|
||||
hosts: ["prepared.example"],
|
||||
hostSuffixes: [],
|
||||
baseUrls: [],
|
||||
},
|
||||
],
|
||||
providerRequests: new Map([["prepared", { family: "prepared-family" }]]),
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveProviderRequestPolicy({
|
||||
provider: "prepared",
|
||||
baseUrl: "https://prepared.example",
|
||||
providerMetadataOwners,
|
||||
}),
|
||||
).toMatchObject({
|
||||
endpointClass: "anthropic-public",
|
||||
knownProviderFamily: "prepared-family",
|
||||
});
|
||||
expect(loadPluginMetadataSnapshot).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves the canonical OpenClaw product and runtime version", () => {
|
||||
const identity = resolveProviderAttributionIdentity({
|
||||
OPENCLAW_VERSION: "2026.3.99",
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
} from "../plugins/manifest.js";
|
||||
import { normalizePluginProviderBaseUrl } from "../plugins/plugin-metadata-provider-facts.js";
|
||||
import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import type { PluginMetadataSnapshotOwnerMaps } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import { asBoolean } from "../utils/boolean.js";
|
||||
import type { RuntimeVersionEnv } from "../version.js";
|
||||
import { resolveRuntimeServiceVersion } from "../version.js";
|
||||
@@ -89,6 +90,7 @@ export type ProviderRequestPolicyInput = {
|
||||
baseUrl?: string | null;
|
||||
transport?: ProviderRequestTransport;
|
||||
capability?: ProviderRequestCapability;
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
|
||||
};
|
||||
|
||||
/** Provider policy facts consumed by transports before constructing a request. */
|
||||
@@ -177,7 +179,15 @@ type ProviderMetadataOwners = {
|
||||
providerRequests: ReadonlyMap<string, PluginManifestProviderRequestProvider>;
|
||||
};
|
||||
|
||||
function resolveProviderMetadataOwners(): ProviderMetadataOwners {
|
||||
function resolveProviderMetadataOwners(
|
||||
prepared?: PluginMetadataSnapshotOwnerMaps,
|
||||
): ProviderMetadataOwners {
|
||||
if (prepared) {
|
||||
return {
|
||||
providerEndpoints: prepared.providerEndpoints ?? [],
|
||||
providerRequests: prepared.providerRequests ?? new Map(),
|
||||
};
|
||||
}
|
||||
const current = getCurrentPluginMetadataSnapshot({
|
||||
allowWorkspaceScopedSnapshot: true,
|
||||
});
|
||||
@@ -194,10 +204,15 @@ function resolveProviderMetadataOwners(): ProviderMetadataOwners {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveManifestProviderRequest(
|
||||
provider: string | undefined,
|
||||
): PluginManifestProviderRequestProvider | undefined {
|
||||
return provider ? resolveProviderMetadataOwners().providerRequests.get(provider) : undefined;
|
||||
function resolveManifestProviderRequest(params: {
|
||||
provider: string | undefined;
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
|
||||
}): PluginManifestProviderRequestProvider | undefined {
|
||||
return params.provider
|
||||
? resolveProviderMetadataOwners(params.providerMetadataOwners).providerRequests.get(
|
||||
params.provider,
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function hostMatchesSuffix(host: string, suffix: string): boolean {
|
||||
@@ -227,8 +242,10 @@ function buildManifestEndpointResolution(
|
||||
function resolveManifestProviderEndpoint(params: {
|
||||
host: string;
|
||||
normalizedBaseUrl?: string;
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
|
||||
}): ProviderEndpointResolution | undefined {
|
||||
for (const endpoint of resolveProviderMetadataOwners().providerEndpoints) {
|
||||
for (const endpoint of resolveProviderMetadataOwners(params.providerMetadataOwners)
|
||||
.providerEndpoints) {
|
||||
if ((endpoint.hosts ?? []).includes(params.host)) {
|
||||
return buildManifestEndpointResolution(endpoint, params.host);
|
||||
}
|
||||
@@ -253,6 +270,7 @@ function isLocalEndpointHost(host: string): boolean {
|
||||
|
||||
export function resolveProviderEndpoint(
|
||||
baseUrl: string | null | undefined,
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps,
|
||||
): ProviderEndpointResolution {
|
||||
if (typeof baseUrl !== "string" || !baseUrl.trim()) {
|
||||
return { endpointClass: "default" };
|
||||
@@ -263,7 +281,11 @@ export function resolveProviderEndpoint(
|
||||
return { endpointClass: "invalid" };
|
||||
}
|
||||
const normalizedBaseUrl = normalizePluginProviderBaseUrl(baseUrl);
|
||||
const manifestEndpoint = resolveManifestProviderEndpoint({ host, normalizedBaseUrl });
|
||||
const manifestEndpoint = resolveManifestProviderEndpoint({
|
||||
host,
|
||||
normalizedBaseUrl,
|
||||
...(providerMetadataOwners ? { providerMetadataOwners } : {}),
|
||||
});
|
||||
if (manifestEndpoint) {
|
||||
return manifestEndpoint;
|
||||
}
|
||||
@@ -273,8 +295,14 @@ export function resolveProviderEndpoint(
|
||||
return { endpointClass: "custom", hostname: host };
|
||||
}
|
||||
|
||||
function resolveKnownProviderFamily(provider: string | undefined): string {
|
||||
const manifestFamily = resolveManifestProviderRequest(provider)?.family;
|
||||
function resolveKnownProviderFamily(
|
||||
provider: string | undefined,
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps,
|
||||
): string {
|
||||
const manifestFamily = resolveManifestProviderRequest({
|
||||
provider,
|
||||
...(providerMetadataOwners ? { providerMetadataOwners } : {}),
|
||||
})?.family;
|
||||
if (manifestFamily) {
|
||||
return manifestFamily;
|
||||
}
|
||||
@@ -469,7 +497,7 @@ export function resolveProviderRequestPolicy(
|
||||
): ProviderRequestPolicyResolution {
|
||||
const provider = normalizeProviderId(input.provider ?? "");
|
||||
const policy = resolveProviderAttributionPolicy(provider, env);
|
||||
const endpointResolution = resolveProviderEndpoint(input.baseUrl);
|
||||
const endpointResolution = resolveProviderEndpoint(input.baseUrl, input.providerMetadataOwners);
|
||||
const endpointClass = endpointResolution.endpointClass;
|
||||
const usesConfiguredBaseUrl = endpointClass !== "default";
|
||||
const usesKnownNativeOpenAIEndpoint =
|
||||
@@ -517,7 +545,10 @@ export function resolveProviderRequestPolicy(
|
||||
policy: attributionPolicy ?? policy,
|
||||
endpointClass,
|
||||
usesConfiguredBaseUrl,
|
||||
knownProviderFamily: resolveKnownProviderFamily(provider || undefined),
|
||||
knownProviderFamily: resolveKnownProviderFamily(
|
||||
provider || undefined,
|
||||
input.providerMetadataOwners,
|
||||
),
|
||||
attributionProvider,
|
||||
attributionHeaders,
|
||||
allowsHiddenAttribution:
|
||||
@@ -565,7 +596,12 @@ export function resolveProviderRequestCapabilities(
|
||||
endpointClass === "google-generative-ai" ||
|
||||
endpointClass === "google-vertex";
|
||||
|
||||
const manifestProviderRequest = resolveManifestProviderRequest(provider);
|
||||
const manifestProviderRequest = resolveManifestProviderRequest({
|
||||
provider,
|
||||
...(input.providerMetadataOwners
|
||||
? { providerMetadataOwners: input.providerMetadataOwners }
|
||||
: {}),
|
||||
});
|
||||
const compatibilityFamily = manifestProviderRequest?.compatibilityFamily;
|
||||
|
||||
const isResponsesApi = isOpenAIResponsesApi(api);
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { ConfiguredProviderRequest } from "../config/types.provider-request
|
||||
import type { SecretRef } from "../config/types.secrets.js";
|
||||
import {
|
||||
applyPreparedRuntimeAuthToModel,
|
||||
attachModelProviderMetadataOwners,
|
||||
buildProviderRequestDispatcherPolicy,
|
||||
getModelProviderMetadataOwners,
|
||||
inheritModelProviderMetadataOwners,
|
||||
mergeModelProviderRequestOverrides,
|
||||
resolveProviderRequestPolicyConfig,
|
||||
resolveProviderRequestConfig,
|
||||
@@ -14,6 +17,35 @@ import {
|
||||
} from "./provider-request-config.js";
|
||||
|
||||
describe("provider request config", () => {
|
||||
it("carries lifecycle plugin metadata ownership through model projections", () => {
|
||||
const owners = {
|
||||
channels: new Map(),
|
||||
channelConfigs: new Map(),
|
||||
providers: new Map(),
|
||||
modelCatalogProviders: new Map(),
|
||||
cliBackends: new Map(),
|
||||
setupProviders: new Map(),
|
||||
commandAliases: new Map(),
|
||||
contracts: new Map(),
|
||||
providerEndpoints: [],
|
||||
providerRequests: new Map([["prepared", { family: "prepared-family" }]]),
|
||||
};
|
||||
const prepared = attachModelProviderMetadataOwners({ id: "prepared-model" }, owners);
|
||||
const projected = inheritModelProviderMetadataOwners(prepared, {
|
||||
...prepared,
|
||||
id: "projected-model",
|
||||
});
|
||||
|
||||
expect(getModelProviderMetadataOwners(prepared)).toBe(owners);
|
||||
expect(getModelProviderMetadataOwners(projected)).toBe(owners);
|
||||
expect(
|
||||
resolveProviderRequestPolicyConfig({
|
||||
provider: "prepared",
|
||||
providerMetadataOwners: getModelProviderMetadataOwners(projected),
|
||||
}).policy.knownProviderFamily,
|
||||
).toBe("prepared-family");
|
||||
});
|
||||
|
||||
it("applies prepared runtime auth without retaining stale credential headers", () => {
|
||||
const model = {
|
||||
provider: "microsoft-foundry",
|
||||
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
import { assertSecretInputResolved } from "../config/types.secrets.js";
|
||||
import type { PinnedDispatcherPolicy } from "../infra/net/ssrf.js";
|
||||
import type { Api } from "../llm/types.js";
|
||||
import type { PluginMetadataSnapshotOwnerMaps } from "../plugins/plugin-metadata-snapshot.types.js";
|
||||
import type {
|
||||
ProviderRequestCapabilities,
|
||||
ProviderRequestCapability,
|
||||
@@ -168,6 +169,7 @@ type ResolveProviderRequestPolicyConfigParams = {
|
||||
provider?: string;
|
||||
api?: RequestApi;
|
||||
baseUrl?: string;
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
|
||||
defaultBaseUrl?: string;
|
||||
capability?: ProviderRequestCapability;
|
||||
transport?: ProviderRequestTransport;
|
||||
@@ -689,6 +691,9 @@ export function resolveProviderRequestPolicyConfig(
|
||||
provider: params.provider,
|
||||
api: params.api,
|
||||
baseUrl,
|
||||
...(params.providerMetadataOwners
|
||||
? { providerMetadataOwners: params.providerMetadataOwners }
|
||||
: {}),
|
||||
capability,
|
||||
transport,
|
||||
} satisfies Parameters<typeof resolveProviderRequestPolicy>[0];
|
||||
@@ -751,6 +756,7 @@ export function resolveProviderRequestConfig(params: {
|
||||
provider: string;
|
||||
api?: RequestApi;
|
||||
baseUrl?: string;
|
||||
providerMetadataOwners?: PluginMetadataSnapshotOwnerMaps;
|
||||
capability?: ProviderRequestCapability;
|
||||
transport?: ProviderRequestTransport;
|
||||
discoveredHeaders?: Record<string, string>;
|
||||
@@ -803,10 +809,14 @@ export function resolveProviderRequestHeaders(params: {
|
||||
const MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL = Symbol.for(
|
||||
"openclaw.modelProviderRequestTransport",
|
||||
);
|
||||
const MODEL_PROVIDER_METADATA_OWNERS_SYMBOL = Symbol.for("openclaw.modelProviderMetadataOwners");
|
||||
|
||||
type ModelWithProviderRequestTransport = {
|
||||
[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL]?: ModelProviderRequestTransportOverrides;
|
||||
};
|
||||
type ModelWithProviderMetadataOwners = {
|
||||
[MODEL_PROVIDER_METADATA_OWNERS_SYMBOL]?: PluginMetadataSnapshotOwnerMaps;
|
||||
};
|
||||
|
||||
/** Attaches model-scoped provider request transport metadata without mutating the model. */
|
||||
export function attachModelProviderRequestTransport<TModel extends object>(
|
||||
@@ -827,4 +837,32 @@ export function getModelProviderRequestTransport(
|
||||
): ModelProviderRequestTransportOverrides | undefined {
|
||||
return (model as ModelWithProviderRequestTransport)[MODEL_PROVIDER_REQUEST_TRANSPORT_SYMBOL];
|
||||
}
|
||||
|
||||
/** Attaches the lifecycle-owned plugin metadata generation used for request policy. */
|
||||
export function attachModelProviderMetadataOwners<TModel extends object>(
|
||||
model: TModel,
|
||||
owners: PluginMetadataSnapshotOwnerMaps | undefined,
|
||||
): TModel {
|
||||
if (!owners) {
|
||||
return model;
|
||||
}
|
||||
const next = { ...model } as TModel & ModelWithProviderMetadataOwners;
|
||||
next[MODEL_PROVIDER_METADATA_OWNERS_SYMBOL] = owners;
|
||||
return next;
|
||||
}
|
||||
|
||||
/** Reads the plugin metadata generation attached to a prepared model. */
|
||||
export function getModelProviderMetadataOwners(
|
||||
model: object,
|
||||
): PluginMetadataSnapshotOwnerMaps | undefined {
|
||||
return (model as ModelWithProviderMetadataOwners)[MODEL_PROVIDER_METADATA_OWNERS_SYMBOL];
|
||||
}
|
||||
|
||||
/** Carries request-policy ownership across provider/transport model projections. */
|
||||
export function inheritModelProviderMetadataOwners<TModel extends object>(
|
||||
source: object,
|
||||
target: TModel,
|
||||
): TModel {
|
||||
return attachModelProviderMetadataOwners(target, getModelProviderMetadataOwners(source));
|
||||
}
|
||||
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|
||||
|
||||
@@ -93,6 +93,7 @@ vi.mock("./provider-local-service.js", () => ({
|
||||
|
||||
vi.mock("./provider-request-config.js", () => ({
|
||||
buildProviderRequestDispatcherPolicy: buildProviderRequestDispatcherPolicyMock,
|
||||
getModelProviderMetadataOwners: vi.fn(() => undefined),
|
||||
getModelProviderRequestTransport: vi.fn(() => undefined),
|
||||
mergeModelProviderRequestOverrides: mergeModelProviderRequestOverridesMock,
|
||||
resolveProviderRequestPolicyConfig: resolveProviderRequestPolicyConfigMock,
|
||||
|
||||
@@ -45,6 +45,7 @@ import {
|
||||
} from "./provider-local-service.js";
|
||||
import {
|
||||
buildProviderRequestDispatcherPolicy,
|
||||
getModelProviderMetadataOwners,
|
||||
getModelProviderRequestTransport,
|
||||
mergeModelProviderRequestOverrides,
|
||||
resolveProviderRequestPolicyConfig,
|
||||
@@ -590,10 +591,12 @@ function resolveModelRequestPolicy(model: Model) {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
const providerMetadataOwners = getModelProviderMetadataOwners(model);
|
||||
return resolveProviderRequestPolicyConfig({
|
||||
provider: model.provider,
|
||||
api: model.api,
|
||||
baseUrl: model.baseUrl,
|
||||
...(providerMetadataOwners ? { providerMetadataOwners } : {}),
|
||||
capability: "llm",
|
||||
transport: "stream",
|
||||
request,
|
||||
|
||||
@@ -383,6 +383,97 @@ describe("ModelRegistry models.json auth", () => {
|
||||
expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1");
|
||||
});
|
||||
|
||||
it("can parse authored models without opening generated plugin catalogs", () => {
|
||||
const modelsPath = writeModelsJsonWithPluginCatalog({
|
||||
root: {
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://models.example/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "authored-model", name: "Authored Model" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
pluginRelativePath: join("plugins", "zai", PLUGIN_MODEL_CATALOG_FILE),
|
||||
pluginCatalog: {
|
||||
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
|
||||
providers: {
|
||||
zai: {
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "glm-5.1", name: "GLM 5.1" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath, {
|
||||
includePluginCatalogs: false,
|
||||
pluginMetadataSnapshot: pluginOwnerSnapshot("zai", "zai"),
|
||||
});
|
||||
|
||||
expect(registry.find("custom", "authored-model")?.name).toBe("Authored Model");
|
||||
expect(registry.find("zai", "glm-5.1")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("can parse a lifecycle-captured models.json source without rereading the path", () => {
|
||||
const modelsPath = writeModelsJson({ providers: {} });
|
||||
writeFileSync(modelsPath, "not valid json");
|
||||
const captured = JSON.stringify({
|
||||
providers: {
|
||||
custom: {
|
||||
baseUrl: "https://models.example/v1",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "captured-model", name: "Captured Model" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath, {
|
||||
includePluginCatalogs: false,
|
||||
modelsJsonContents: captured,
|
||||
});
|
||||
|
||||
expect(registry.getError()).toBeUndefined();
|
||||
expect(registry.find("custom", "captured-model")?.name).toBe("Captured Model");
|
||||
});
|
||||
|
||||
it("loads only lifecycle-captured generated catalogs when the root catalog is absent", () => {
|
||||
const modelsPath = writeModelsJson({ providers: {} });
|
||||
rmSync(modelsPath);
|
||||
const capturedCatalog = {
|
||||
pluginId: "zai",
|
||||
contents: JSON.stringify({
|
||||
generatedBy: PLUGIN_MODEL_CATALOG_GENERATED_BY,
|
||||
providers: {
|
||||
zai: {
|
||||
baseUrl: "https://api.z.ai/api/paas/v4",
|
||||
api: "openai-completions",
|
||||
models: [{ id: "glm-5.1", name: "GLM 5.1" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
const pluginMetadataSnapshot = pluginOwnerSnapshotEntries([
|
||||
{ providerId: "zai", pluginId: "zai" },
|
||||
{ providerId: "other", pluginId: "other" },
|
||||
]);
|
||||
const registry = ModelRegistry.create(AuthStorage.inMemory(), modelsPath, {
|
||||
includePluginCatalogs: true,
|
||||
modelsJsonContents: null,
|
||||
pluginCatalogs: [capturedCatalog],
|
||||
pluginMetadataSnapshot,
|
||||
});
|
||||
const fork = registry.fork(AuthStorage.inMemory());
|
||||
|
||||
expect(registry.getError()).toBeUndefined();
|
||||
expect(registry.find("zai", "glm-5.1")?.name).toBe("GLM 5.1");
|
||||
expect(registry.find("other", "unrelated-model")).toBeUndefined();
|
||||
expect(registry.getProviderMetadataOwners()).toBe(pluginMetadataSnapshot.owners);
|
||||
expect(fork.getProviderMetadataOwners()).toBe(pluginMetadataSnapshot.owners);
|
||||
});
|
||||
|
||||
it("reports an unreadable legacy catalog while preserving healthy provider models", () => {
|
||||
if (process.getuid?.() === 0) {
|
||||
return;
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
filterGeneratedPluginModelCatalogProviders,
|
||||
isGeneratedPluginModelCatalog,
|
||||
loadPersistedPluginModelCatalogs,
|
||||
type PersistedPluginModelCatalog,
|
||||
type PluginModelCatalogMetadataSnapshot,
|
||||
} from "../plugin-model-catalog.js";
|
||||
import { getAuthStorageOAuthProviderRegistry } from "./auth-storage-oauth-registry.js";
|
||||
@@ -264,6 +265,9 @@ function emptyCustomModelsResult(error?: string): CustomModelsResult {
|
||||
}
|
||||
|
||||
type ModelRegistryOptions = {
|
||||
includePluginCatalogs?: boolean;
|
||||
modelsJsonContents?: string | null;
|
||||
pluginCatalogs?: readonly PersistedPluginModelCatalog[];
|
||||
pluginMetadataSnapshot?: PluginModelCatalogMetadataSnapshot;
|
||||
sourceSnapshot?: ModelRegistry;
|
||||
workspaceDir?: string;
|
||||
@@ -328,7 +332,10 @@ export class ModelRegistry {
|
||||
private loadError: string | undefined = undefined;
|
||||
readonly authStorage: AuthStorage;
|
||||
private modelsJsonPath: string | undefined;
|
||||
private modelsJsonContents: string | null | undefined;
|
||||
private pluginCatalogs: readonly PersistedPluginModelCatalog[] | undefined;
|
||||
private pluginMetadataSnapshot: PluginModelCatalogMetadataSnapshot | undefined;
|
||||
private includePluginCatalogs = true;
|
||||
private baseCatalogSnapshot: ModelRegistryCatalogSnapshot | undefined;
|
||||
private sourceSnapshot: ModelRegistryCatalogSnapshot | undefined;
|
||||
|
||||
@@ -338,6 +345,7 @@ export class ModelRegistry {
|
||||
options: ModelRegistryOptions = {},
|
||||
) {
|
||||
this.authStorage = authStorage;
|
||||
this.includePluginCatalogs = options.includePluginCatalogs !== false;
|
||||
initializeModelRegistryRuntime(this);
|
||||
if (options.sourceSnapshot) {
|
||||
const source = options.sourceSnapshot;
|
||||
@@ -358,6 +366,8 @@ export class ModelRegistry {
|
||||
return;
|
||||
}
|
||||
this.modelsJsonPath = modelsJsonPath;
|
||||
this.modelsJsonContents = options.modelsJsonContents;
|
||||
this.pluginCatalogs = options.pluginCatalogs;
|
||||
this.pluginMetadataSnapshot = resolveModelPluginMetadataSnapshot({
|
||||
...(options.pluginMetadataSnapshot
|
||||
? { pluginMetadataSnapshot: options.pluginMetadataSnapshot }
|
||||
@@ -447,20 +457,36 @@ export class ModelRegistry {
|
||||
return this.loadError;
|
||||
}
|
||||
|
||||
/** Returns the exact plugin metadata generation captured with this registry. */
|
||||
getProviderMetadataOwners() {
|
||||
return this.pluginMetadataSnapshot?.owners;
|
||||
}
|
||||
|
||||
private loadModels(): void {
|
||||
// Keep authored models.json separate from rebuildable provider catalogs
|
||||
// owned by the agent SQLite cache.
|
||||
const { models: customModels, error } = this.modelsJsonPath
|
||||
? this.loadCustomModels(this.modelsJsonPath)
|
||||
: emptyCustomModelsResult();
|
||||
const customResult =
|
||||
this.modelsJsonPath && this.modelsJsonContents !== null
|
||||
? this.loadCustomModels(this.modelsJsonPath, {
|
||||
...(this.modelsJsonContents !== undefined ? { contents: this.modelsJsonContents } : {}),
|
||||
includePluginCatalogs: this.includePluginCatalogs && this.pluginCatalogs === undefined,
|
||||
})
|
||||
: emptyCustomModelsResult();
|
||||
const capturedPluginResult =
|
||||
this.includePluginCatalogs && this.pluginCatalogs !== undefined
|
||||
? this.loadCapturedPluginCatalogs(this.pluginCatalogs)
|
||||
: emptyCustomModelsResult();
|
||||
const errors = [customResult.error, capturedPluginResult.error].filter(
|
||||
(error): error is string => Boolean(error),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
this.loadError = error;
|
||||
log.warn(`model catalog load issue: ${error}`);
|
||||
if (errors.length > 0) {
|
||||
this.loadError = errors.join("\n\n");
|
||||
log.warn(`model catalog load issue: ${this.loadError}`);
|
||||
// Plugin catalog failures can return salvaged models; root failures return empty.
|
||||
}
|
||||
|
||||
let combined = customModels;
|
||||
let combined = [...customResult.models, ...capturedPluginResult.models];
|
||||
|
||||
// Let OAuth providers modify their models (e.g., update baseUrl)
|
||||
for (const oauthProvider of this.authStorage.getOAuthProviders()) {
|
||||
@@ -473,6 +499,29 @@ export class ModelRegistry {
|
||||
this.models = combined;
|
||||
}
|
||||
|
||||
private loadCapturedPluginCatalogs(
|
||||
pluginCatalogs: readonly PersistedPluginModelCatalog[],
|
||||
): CustomModelsResult {
|
||||
const models: Model[] = [];
|
||||
const errors: string[] = [];
|
||||
for (const pluginCatalog of pluginCatalogs) {
|
||||
const result = this.loadCustomModels(
|
||||
`sqlite:plugin-model-catalog/${pluginCatalog.pluginId}`,
|
||||
{
|
||||
catalogPluginId: pluginCatalog.pluginId,
|
||||
contents: pluginCatalog.contents,
|
||||
includePluginCatalogs: false,
|
||||
requireGeneratedCatalog: true,
|
||||
},
|
||||
);
|
||||
models.push(...result.models);
|
||||
if (result.error) {
|
||||
errors.push(result.error);
|
||||
}
|
||||
}
|
||||
return { models, error: errors.join("\n\n") || undefined };
|
||||
}
|
||||
|
||||
private loadCustomModels(
|
||||
modelsJsonPath: string,
|
||||
options: {
|
||||
@@ -538,11 +587,15 @@ export class ModelRegistry {
|
||||
);
|
||||
const pluginCatalogErrors: string[] = [];
|
||||
if (options.includePluginCatalogs !== false) {
|
||||
let pluginCatalogs: ReturnType<typeof loadPersistedPluginModelCatalogs>["catalogs"] = [];
|
||||
let pluginCatalogs: readonly PersistedPluginModelCatalog[] = [];
|
||||
try {
|
||||
const loaded = loadPersistedPluginModelCatalogs(dirname(modelsJsonPath));
|
||||
pluginCatalogs = loaded.catalogs;
|
||||
pluginCatalogErrors.push(...loaded.warnings);
|
||||
if (this.pluginCatalogs) {
|
||||
pluginCatalogs = this.pluginCatalogs;
|
||||
} else {
|
||||
const loaded = loadPersistedPluginModelCatalogs(dirname(modelsJsonPath));
|
||||
pluginCatalogs = loaded.catalogs;
|
||||
pluginCatalogErrors.push(...loaded.warnings);
|
||||
}
|
||||
} catch (error) {
|
||||
pluginCatalogErrors.push(
|
||||
`Failed to load generated plugin model catalogs: ${
|
||||
|
||||
@@ -192,7 +192,7 @@ export async function emitSubagentEndedHookOnce(params: {
|
||||
outcome?: SubagentLifecycleEndedOutcome;
|
||||
error?: string;
|
||||
inFlightRunIds: Set<string>;
|
||||
persist: () => void;
|
||||
persist: (...runIds: string[]) => void;
|
||||
}) {
|
||||
const runId = params.entry.runId.trim();
|
||||
if (!runId) {
|
||||
@@ -234,7 +234,7 @@ export async function emitSubagentEndedHookOnce(params: {
|
||||
);
|
||||
}
|
||||
params.entry.endedHookEmittedAt = Date.now();
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
return true;
|
||||
} catch (err) {
|
||||
log.warn(
|
||||
|
||||
@@ -23,7 +23,7 @@ import type {
|
||||
|
||||
export function createSubagentRegistryContextCleanup(config: {
|
||||
deps: () => SubagentRegistryDeps;
|
||||
persist: () => void;
|
||||
persist: (...runIds: string[]) => void;
|
||||
warn: (message: string, meta?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const { deps, persist, warn } = config;
|
||||
@@ -93,7 +93,7 @@ export function createSubagentRegistryContextCleanup(config: {
|
||||
]);
|
||||
if (!contextAlreadyEnded && contextEnded) {
|
||||
entry.contextEngineCleanupCompletedAt = Date.now();
|
||||
persist();
|
||||
persist(entry.runId);
|
||||
}
|
||||
return internalEffectsRemoved && attachmentsRemoved && contextEnded;
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ export function createSubagentRegistryLifecycleBookkeeping(
|
||||
// Preserve only the collector result tombstone for waits and group caps.
|
||||
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
|
||||
cleanupParams.entry.requesterSettleWake = undefined;
|
||||
params.persist();
|
||||
params.persist(cleanupParams.runId);
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
return;
|
||||
}
|
||||
@@ -99,7 +99,7 @@ export function createSubagentRegistryLifecycleBookkeeping(
|
||||
}
|
||||
if (cleanupParams.skipRequesterSettleWake) {
|
||||
params.runs.delete(cleanupParams.runId);
|
||||
params.persist();
|
||||
params.persist(cleanupParams.runId);
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
return;
|
||||
}
|
||||
@@ -117,7 +117,7 @@ export function createSubagentRegistryLifecycleBookkeeping(
|
||||
});
|
||||
} else {
|
||||
cleanupParams.entry.cleanupCompletedAt = cleanupParams.completedAt;
|
||||
params.persist();
|
||||
params.persist(cleanupParams.runId);
|
||||
}
|
||||
retryDeferredCompletedAnnounces(cleanupParams.runId);
|
||||
if (!cleanupParams.skipRequesterSettleWake) {
|
||||
|
||||
@@ -53,7 +53,7 @@ export function createSubagentRegistryLifecycleCleanupBase(
|
||||
return;
|
||||
}
|
||||
entry.cleanupHandled = false;
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
}
|
||||
params.resumedRuns.delete(runId);
|
||||
params.resumeSubagentRun(runId);
|
||||
@@ -104,7 +104,7 @@ export function createSubagentRegistryLifecycleCleanupBase(
|
||||
}
|
||||
current.cleanupHandled = false;
|
||||
params.resumedRuns.delete(args.runId);
|
||||
params.persist();
|
||||
params.persist(args.runId);
|
||||
}
|
||||
}).catch((err: unknown) => {
|
||||
defaultRuntime.log(
|
||||
@@ -155,7 +155,7 @@ export function createSubagentRegistryLifecycleCleanupBase(
|
||||
logAnnounceGiveUp(args.entry, args.reason);
|
||||
markRequesterSettleWakePending(args.entry);
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(args.runId);
|
||||
} catch (error) {
|
||||
const mutableEntry = args.entry as unknown as Record<string, unknown>;
|
||||
for (const key of Object.keys(mutableEntry)) {
|
||||
@@ -179,7 +179,7 @@ export function createSubagentRegistryLifecycleCleanupBase(
|
||||
}
|
||||
entry.cleanupHandled = true;
|
||||
cleanupGenerations.set(entry, (cleanupGenerations.get(entry) ?? 0) + 1);
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -209,7 +209,7 @@ export function createSubagentRegistryLifecycleCleanupBase(
|
||||
// Cleanup can yield to attachment, mirror, or announce work. A successor
|
||||
// registered while it was suspended owns every session-scoped side effect.
|
||||
await params.retireSupersededRun(runId, entry);
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
@@ -232,7 +232,7 @@ export function createSubagentRegistryLifecycleCleanup(
|
||||
delivery.announcedAt = delivery.announcedAt ?? deliveredAt;
|
||||
if (!options?.skipAnnounce) {
|
||||
delivery.announcedAt = deliveredAt;
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
}
|
||||
}
|
||||
clearPendingFinalDelivery(entry);
|
||||
@@ -294,7 +294,7 @@ export function createSubagentRegistryLifecycleCleanup(
|
||||
entry.wakeOnDescendantSettle = true;
|
||||
entry.cleanupHandled = false;
|
||||
params.resumedRuns.delete(runId);
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
scheduleResumeSubagentRun(runId, entry, deferredDecision.delayMs);
|
||||
return;
|
||||
}
|
||||
@@ -361,7 +361,7 @@ export function createSubagentRegistryLifecycleCleanup(
|
||||
});
|
||||
entry.cleanupHandled = false;
|
||||
params.resumedRuns.delete(runId);
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
if (deferredDecision.resumeDelayMs == null) {
|
||||
return;
|
||||
}
|
||||
@@ -414,7 +414,7 @@ export function createSubagentRegistryLifecycleCleanup(
|
||||
// This durable boundary prevents a late yield from reviving a run
|
||||
// after deletion may already have reached the gateway.
|
||||
entry.deleteCleanupDispatchedAt ??= Date.now();
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
await deleteSubagentSessionForCleanup({
|
||||
callGateway: params.callGateway,
|
||||
childSessionKey: entry.childSessionKey,
|
||||
@@ -496,7 +496,7 @@ export function createSubagentRegistryLifecycleCleanup(
|
||||
// Announce owns delete submission; fence late yields at the
|
||||
// exact handoff instead of when cleanup merely starts.
|
||||
entry.deleteCleanupDispatchedAt ??= Date.now();
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
return true;
|
||||
}
|
||||
: undefined,
|
||||
@@ -510,7 +510,7 @@ export function createSubagentRegistryLifecycleCleanup(
|
||||
const deliveryState = ensureDeliveryState(entry);
|
||||
if (deliveryState.lastError !== undefined) {
|
||||
deliveryState.lastError = undefined;
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
}
|
||||
latestDeliveryError = undefined;
|
||||
return;
|
||||
@@ -521,7 +521,7 @@ export function createSubagentRegistryLifecycleCleanup(
|
||||
latestDeliveryError = formatAnnounceDeliveryError(delivery);
|
||||
if (ensureDeliveryState(entry).lastError !== latestDeliveryError) {
|
||||
ensureDeliveryState(entry).lastError = latestDeliveryError;
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -163,7 +163,7 @@ export function createSubagentRegistryLifecycleCompletion(
|
||||
entry.terminalOwner = "interrupted-recovery";
|
||||
mutated = true;
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(completeParams.runId);
|
||||
} catch (error) {
|
||||
restoreEntrySnapshot(entrySnapshot);
|
||||
throw error;
|
||||
@@ -479,7 +479,7 @@ export function createSubagentRegistryLifecycleCompletion(
|
||||
restoreEntrySnapshot(entry);
|
||||
entry = currentEntry;
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(completeParams.runId);
|
||||
} catch (error) {
|
||||
restoreEntrySnapshot(liveBeforeCommit);
|
||||
throw error;
|
||||
@@ -490,7 +490,7 @@ export function createSubagentRegistryLifecycleCompletion(
|
||||
} else {
|
||||
try {
|
||||
if (mutated) {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(completeParams.runId);
|
||||
}
|
||||
} catch (error) {
|
||||
restoreEntrySnapshot(entrySnapshot);
|
||||
@@ -517,7 +517,7 @@ export function createSubagentRegistryLifecycleCompletion(
|
||||
const retireSupersededSession = async (currentEntry: SubagentRunRecord) => {
|
||||
if (completionReason !== SUBAGENT_ENDED_REASON_KILLED) {
|
||||
await params.retireSupersededRun(completeParams.runId, currentEntry);
|
||||
params.persist();
|
||||
params.persist(completeParams.runId);
|
||||
}
|
||||
};
|
||||
sessionSuperseded = sessionSuperseded || newerGenerationOwnsSession(entry);
|
||||
|
||||
@@ -15,8 +15,8 @@ export type SubagentRegistryLifecycleParams = {
|
||||
resumedRuns: Set<string>;
|
||||
subagentAnnounceTimeoutMs: number;
|
||||
getRuntimeConfig(): OpenClawConfig;
|
||||
persist(): void;
|
||||
persistOrThrow(): void;
|
||||
persist(...runIds: string[]): void;
|
||||
persistOrThrow(...runIds: string[]): void;
|
||||
clearPendingLifecycleError(runId: string): void;
|
||||
countPendingDescendantRuns(rootSessionKey: string): number;
|
||||
suppressAnnounceForSteerRestart(entry?: SubagentRunRecord): boolean;
|
||||
|
||||
@@ -383,7 +383,7 @@ export function createSubagentRegistryLifecycleDelivery(
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
params.persist();
|
||||
params.persist(...candidates.map((entry) => entry.runId));
|
||||
}
|
||||
return changed;
|
||||
};
|
||||
|
||||
@@ -32,6 +32,9 @@ export function createSubagentRegistryLifecycleRequesterWake(
|
||||
Boolean(entry?.requesterSettleWake) &&
|
||||
entry?.requesterSettleWake?.rearmGeneration === state.rearmGeneration,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const previousStates = entries.map((entry) => structuredClone(entry.requesterSettleWake));
|
||||
for (const entry of entries) {
|
||||
entry.requesterSettleWake = {
|
||||
@@ -42,7 +45,7 @@ export function createSubagentRegistryLifecycleRequesterWake(
|
||||
};
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(...entries.map((entry) => entry.runId));
|
||||
} catch (error) {
|
||||
entries.forEach((entry, index) => {
|
||||
entry.requesterSettleWake = previousStates[index];
|
||||
@@ -62,6 +65,9 @@ export function createSubagentRegistryLifecycleRequesterWake(
|
||||
Boolean(pair[1]?.requesterSettleWake) &&
|
||||
pair[1]?.requesterSettleWake?.rearmGeneration === rearmGeneration,
|
||||
);
|
||||
if (entries.length === 0) {
|
||||
return;
|
||||
}
|
||||
const requesterSessionKeys = new Set(entries.map(([, entry]) => entry.requesterSessionKey));
|
||||
const previousStates = entries.map(([, entry]) => ({
|
||||
requesterSettleWake: structuredClone(entry.requesterSettleWake),
|
||||
@@ -82,7 +88,7 @@ export function createSubagentRegistryLifecycleRequesterWake(
|
||||
}
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(...entries.map(([runId]) => runId));
|
||||
} catch (error) {
|
||||
entries.forEach(([runId, entry], index) => {
|
||||
const previous = previousStates[index];
|
||||
@@ -144,7 +150,7 @@ export function createSubagentRegistryLifecycleRequesterWake(
|
||||
}
|
||||
markRequesterSettleWakePending(entry, options);
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(entry.runId);
|
||||
} catch (error) {
|
||||
entry.cleanupCompletedAt = previousCleanupCompletedAt;
|
||||
entry.requesterSettleWake = previousWake;
|
||||
|
||||
@@ -71,7 +71,7 @@ export function createSubagentRegistryLifecycleController(params: SubagentRegist
|
||||
settleRequesterTurnAfterSessionSpawns({
|
||||
...args,
|
||||
runs: params.runs,
|
||||
persistOrThrow: () => params.persistOrThrow(),
|
||||
persistOrThrow: (...runIds) => params.persistOrThrow(...runIds),
|
||||
schedule: (runId, entry) => {
|
||||
if (state.scheduledRequesterSettleWakeRuns.has(runId)) {
|
||||
state.pendingRequesterSettleWakeRearms.add(runId);
|
||||
|
||||
@@ -20,7 +20,7 @@ export function createSubagentRegistryListener(config: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
pendingLifecycle: ReturnType<typeof createPendingLifecycleScheduler>;
|
||||
onAgentEvent: (listener: (event: AgentEventPayload) => void) => () => void;
|
||||
persist: () => void;
|
||||
persist: (...runIds: string[]) => void;
|
||||
refreshFrozenResultFromSession: (sessionKey: string) => Promise<unknown>;
|
||||
completeSubagentRunWithRecovery: (
|
||||
params: SubagentCompletionRequest,
|
||||
@@ -73,7 +73,7 @@ export function createSubagentRegistryListener(config: {
|
||||
entry.sessionStartedAt = startedAt;
|
||||
}
|
||||
entry.execution = { ...entry.execution, status: "running", startedAt };
|
||||
persist();
|
||||
persist(entry.runId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -101,7 +101,7 @@ export function createSubagentRegistryListener(config: {
|
||||
startedAt: startedAt ?? entry.startedAt,
|
||||
})
|
||||
) {
|
||||
persist();
|
||||
persist(entry.runId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import type { SubagentRunRecord, SwarmStructuredOutputState } from "./subagent-r
|
||||
export function createSubagentRegistryPublicApi(config: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
deps: () => SubagentRegistryDeps;
|
||||
persist: () => void;
|
||||
persistOrThrow: () => void;
|
||||
persist: (...runIds: string[]) => void;
|
||||
persistOrThrow: (...runIds: string[]) => void;
|
||||
restoreOnce: () => void;
|
||||
startAnnounceCleanup: (runId: string, entry: SubagentRunRecord) => boolean;
|
||||
settleRequesterTurn: ReturnType<
|
||||
@@ -52,7 +52,7 @@ export function createSubagentRegistryPublicApi(config: {
|
||||
now: params.now,
|
||||
});
|
||||
if (leased) {
|
||||
persist();
|
||||
persist(...leased.runIds);
|
||||
}
|
||||
return leased;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ export function createSubagentRegistryPublicApi(config: {
|
||||
now: params.now,
|
||||
});
|
||||
if (updated > 0) {
|
||||
persist();
|
||||
persist(...params.runIds);
|
||||
for (const runId of params.runIds) {
|
||||
const entry = runs.get(runId);
|
||||
if (!entry || typeof entry.cleanupCompletedAt === "number") {
|
||||
@@ -94,7 +94,7 @@ export function createSubagentRegistryPublicApi(config: {
|
||||
error: params.error,
|
||||
});
|
||||
if (updated > 0) {
|
||||
persist();
|
||||
persist(...params.runIds);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
@@ -143,7 +143,7 @@ export function createSubagentRegistryPublicApi(config: {
|
||||
entry.collectorLaunchCleanupPending = false;
|
||||
entry.cleanupCompletedAt = Date.now();
|
||||
entry.contextEngineCleanupCompletedAt ??= entry.cleanupCompletedAt;
|
||||
persist();
|
||||
persist(entry.runId);
|
||||
}
|
||||
|
||||
function recordSwarmStructuredOutput(
|
||||
@@ -168,7 +168,7 @@ export function createSubagentRegistryPublicApi(config: {
|
||||
const previous = entry.structuredOutput;
|
||||
entry.structuredOutput = structuredClone(state);
|
||||
try {
|
||||
persistOrThrow();
|
||||
persistOrThrow(entry.runId);
|
||||
} catch (error) {
|
||||
entry.structuredOutput = previous;
|
||||
throw error;
|
||||
|
||||
@@ -7,7 +7,7 @@ export function markRequesterTurnYieldedInRuns(params: {
|
||||
requesterSessionKey: string;
|
||||
requesterTurnRunId: string;
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
persistOrThrow(): void;
|
||||
persistOrThrow(...runIds: string[]): void;
|
||||
}): number {
|
||||
const requesterSessionKey = params.requesterSessionKey.trim();
|
||||
const requesterTurnRunId = params.requesterTurnRunId.trim();
|
||||
@@ -27,7 +27,7 @@ export function markRequesterTurnYieldedInRuns(params: {
|
||||
entry.requesterTurnYielded = true;
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(...entries.map((entry) => entry.runId));
|
||||
} catch (error) {
|
||||
entries.forEach((entry, index) => {
|
||||
entry.requesterTurnYielded = previous[index];
|
||||
@@ -43,7 +43,7 @@ export function settleRequesterTurnAfterSessionSpawns(params: {
|
||||
requesterYielded: boolean;
|
||||
acceptedSessionSpawns: readonly AcceptedSessionSpawn[];
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
persistOrThrow(): void;
|
||||
persistOrThrow(...runIds: string[]): void;
|
||||
schedule(runId: string, entry: SubagentRunRecord): void;
|
||||
}): boolean {
|
||||
const requesterSessionKey = params.requesterSessionKey.trim();
|
||||
@@ -126,7 +126,7 @@ export function settleRequesterTurnAfterSessionSpawns(params: {
|
||||
}
|
||||
}
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(...entries.map((entry) => entry.runId));
|
||||
} catch (error) {
|
||||
entries.forEach((entry, index) => {
|
||||
const previous = previousStates[index];
|
||||
|
||||
@@ -24,7 +24,7 @@ export function createSubagentRegistryRestorer(config: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
resumedRuns: Set<string>;
|
||||
deps: () => SubagentRegistryDeps;
|
||||
persist: () => void;
|
||||
persist: (...runIds: string[]) => void;
|
||||
settleRequesterTurn: ReturnType<
|
||||
typeof createSubagentRegistryLifecycleController
|
||||
>["settleRequesterTurnAfterSessionSpawns"];
|
||||
|
||||
@@ -241,8 +241,8 @@ export type RegisterSubagentRunParams = {
|
||||
export function createSubagentRunManager(params: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
resumedRuns: Set<string>;
|
||||
persist(): void;
|
||||
persistOrThrow(): void;
|
||||
persist(...runIds: string[]): void;
|
||||
persistOrThrow(...runIds: string[]): void;
|
||||
callGateway: typeof callGateway;
|
||||
getRuntimeConfig: typeof getRuntimeConfig;
|
||||
ensureListener(): void;
|
||||
@@ -384,7 +384,7 @@ export function createSubagentRunManager(params: {
|
||||
endedAt: wait.endedAt,
|
||||
})
|
||||
) {
|
||||
params.persist();
|
||||
params.persist(entry.runId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -478,7 +478,7 @@ export function createSubagentRunManager(params: {
|
||||
if (typeof entry.sessionStartedAt !== "number") {
|
||||
entry.sessionStartedAt = observedStartedAt;
|
||||
}
|
||||
params.persist();
|
||||
params.persist(entry.runId);
|
||||
}
|
||||
scheduleWaitRetry(
|
||||
entry,
|
||||
@@ -571,7 +571,7 @@ export function createSubagentRunManager(params: {
|
||||
return true;
|
||||
}
|
||||
entry.suppressAnnounceReason = "steer-restart";
|
||||
params.persist();
|
||||
params.persist(entry.runId);
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -622,7 +622,7 @@ export function createSubagentRunManager(params: {
|
||||
}
|
||||
}
|
||||
entry.suppressAnnounceReason = undefined;
|
||||
params.persist();
|
||||
params.persist(entry.runId);
|
||||
// If the interrupted run already finished while suppression was active, retry
|
||||
// cleanup now so completion output is not lost when restart dispatch fails.
|
||||
params.resumedRuns.delete(key);
|
||||
@@ -744,9 +744,14 @@ export function createSubagentRunManager(params: {
|
||||
params.runs.delete(previousRunId);
|
||||
}
|
||||
params.runs.set(nextRunId, next);
|
||||
markOlderKillReconciliationsSuperseded(next);
|
||||
const killReconciliationSnapshots = markOlderKillReconciliationsSuperseded(next);
|
||||
const changedRunIds = [
|
||||
previousRunId,
|
||||
nextRunId,
|
||||
...[...killReconciliationSnapshots.keys()].map((entry) => entry.runId),
|
||||
];
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(...changedRunIds);
|
||||
} catch (error) {
|
||||
// The gateway has already started nextRunId. Keep its in-memory owner
|
||||
// authoritative and retry best-effort persistence; rolling back here
|
||||
@@ -756,7 +761,7 @@ export function createSubagentRunManager(params: {
|
||||
previousRunId,
|
||||
nextRunId,
|
||||
});
|
||||
params.persist();
|
||||
params.persist(...changedRunIds);
|
||||
}
|
||||
if (previousRunId !== nextRunId) {
|
||||
params.clearPendingLifecycleError(previousRunId);
|
||||
@@ -866,7 +871,10 @@ export function createSubagentRunManager(params: {
|
||||
params.runs.set(runId, entry);
|
||||
const killReconciliationSnapshots = markOlderKillReconciliationsSuperseded(entry);
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(
|
||||
runId,
|
||||
...[...killReconciliationSnapshots.keys()].map((candidate) => candidate.runId),
|
||||
);
|
||||
} catch (error) {
|
||||
params.runs.delete(runId);
|
||||
restoreKillReconciliationSnapshots(killReconciliationSnapshots);
|
||||
@@ -971,7 +979,7 @@ export function createSubagentRunManager(params: {
|
||||
entry.swarmLaunchPending = false;
|
||||
entry.queuedLaunch = undefined;
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(previousRunId, nextRunId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (previousRunId !== nextRunId) {
|
||||
@@ -1010,7 +1018,7 @@ export function createSubagentRunManager(params: {
|
||||
entry.queuedLaunch = undefined;
|
||||
let persistedRunning = false;
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(previousRunId, nextRunId);
|
||||
persistedRunning = true;
|
||||
startTaskRunByRunId({
|
||||
runId: entry.taskRunId ?? entry.runId,
|
||||
@@ -1034,7 +1042,7 @@ export function createSubagentRunManager(params: {
|
||||
entry.swarmLaunchPending = previousSwarmLaunchPending;
|
||||
if (persistedRunning) {
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(previousRunId, nextRunId);
|
||||
} catch (rollbackError) {
|
||||
// The failure callback terminalizes this in-memory queued row next.
|
||||
log.warn("failed to persist collector start rollback", {
|
||||
@@ -1073,7 +1081,7 @@ export function createSubagentRunManager(params: {
|
||||
entry.completion = { required: false, resultText: error, capturedAt: endedAt };
|
||||
updateSwarmCollectorCompletion(entry, params.getRuntimeConfig());
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(entry.runId);
|
||||
} catch (persistError) {
|
||||
const target = entry as unknown as Record<string, unknown>;
|
||||
for (const property of Object.keys(target)) {
|
||||
@@ -1134,7 +1142,7 @@ export function createSubagentRunManager(params: {
|
||||
};
|
||||
updateSwarmCollectorCompletion(entry, params.getRuntimeConfig());
|
||||
try {
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(entry.runId);
|
||||
} catch (persistError) {
|
||||
const target = entry as unknown as Record<string, unknown>;
|
||||
for (const property of Object.keys(target)) {
|
||||
@@ -1162,7 +1170,7 @@ export function createSubagentRunManager(params: {
|
||||
}
|
||||
const didDelete = params.runs.delete(runId);
|
||||
if (didDelete) {
|
||||
params.persist();
|
||||
params.persist(runId);
|
||||
}
|
||||
if (params.runs.size === 0) {
|
||||
params.stopSweeper();
|
||||
@@ -1302,7 +1310,7 @@ export function createSubagentRunManager(params: {
|
||||
try {
|
||||
// The registry tombstone is the recovery source for the provisional
|
||||
// task marker. It must commit first so the sweeper can always finish it.
|
||||
params.persistOrThrow();
|
||||
params.persistOrThrow(...[...entrySnapshots.keys()].map((entry) => entry.runId));
|
||||
} catch (error) {
|
||||
for (const [entry, snapshot] of entrySnapshots) {
|
||||
const target = entry as unknown as Record<string, unknown>;
|
||||
|
||||
@@ -16,6 +16,8 @@ const mocks = vi.hoisted(() => ({
|
||||
loadSubagentRunsForControllerFromSqlite:
|
||||
vi.fn<(controllerSessionKey: string) => SubagentRunRecord[]>(),
|
||||
loadSubagentRegistryFromSqlite: vi.fn<() => Map<string, SubagentRunRecord>>(),
|
||||
saveSubagentRegistryChangesToSqlite:
|
||||
vi.fn<(runs: Map<string, SubagentRunRecord>, changedRunIds: readonly string[]) => void>(),
|
||||
saveSubagentRegistryToSqlite: vi.fn<(runs: Map<string, SubagentRunRecord>) => void>(),
|
||||
}));
|
||||
|
||||
@@ -23,6 +25,7 @@ vi.mock("./subagent-registry.store.sqlite.js", () => ({
|
||||
loadSubagentRunsForChildSessionFromSqlite: mocks.loadSubagentRunsForChildSessionFromSqlite,
|
||||
loadSubagentRunsForControllerFromSqlite: mocks.loadSubagentRunsForControllerFromSqlite,
|
||||
loadSubagentRegistryFromSqlite: mocks.loadSubagentRegistryFromSqlite,
|
||||
saveSubagentRegistryChangesToSqlite: mocks.saveSubagentRegistryChangesToSqlite,
|
||||
saveSubagentRegistryToSqlite: mocks.saveSubagentRegistryToSqlite,
|
||||
}));
|
||||
|
||||
@@ -50,6 +53,7 @@ describe("subagent registry state read cache", () => {
|
||||
mocks.loadSubagentRunsForChildSessionFromSqlite.mockReset();
|
||||
mocks.loadSubagentRunsForControllerFromSqlite.mockReset();
|
||||
mocks.loadSubagentRegistryFromSqlite.mockReset();
|
||||
mocks.saveSubagentRegistryChangesToSqlite.mockReset();
|
||||
mocks.saveSubagentRegistryToSqlite.mockReset();
|
||||
});
|
||||
|
||||
@@ -94,6 +98,51 @@ describe("subagent registry state read cache", () => {
|
||||
expect(mocks.loadSubagentRegistryFromSqlite).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("updates only named runs in the local read cache", () => {
|
||||
const changed = createRun("changed");
|
||||
const untouched = createRun("untouched");
|
||||
mocks.loadSubagentRegistryFromSqlite.mockReturnValue(
|
||||
new Map([
|
||||
[changed.runId, changed],
|
||||
[untouched.runId, untouched],
|
||||
]),
|
||||
);
|
||||
expect([...getSubagentRunsSnapshotForRead(new Map()).keys()]).toEqual(["changed", "untouched"]);
|
||||
|
||||
changed.task = "updated";
|
||||
const runs = new Map([
|
||||
[changed.runId, changed],
|
||||
[untouched.runId, untouched],
|
||||
]);
|
||||
persistSubagentRunsToDisk(runs, [changed.runId]);
|
||||
|
||||
expect(mocks.saveSubagentRegistryChangesToSqlite).toHaveBeenCalledWith(runs, [changed.runId]);
|
||||
expect(mocks.saveSubagentRegistryToSqlite).not.toHaveBeenCalled();
|
||||
expect(getSubagentRunsSnapshotForRead(new Map()).get(changed.runId)?.task).toBe("updated");
|
||||
expect(getSubagentRunsSnapshotForRead(new Map()).get(untouched.runId)?.task).toBe(
|
||||
untouched.task,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps an exact deletion authoritative after a best-effort write failure", () => {
|
||||
const retained = createRun("retained");
|
||||
const removed = createRun("removed");
|
||||
mocks.loadSubagentRegistryFromSqlite.mockReturnValue(
|
||||
new Map([
|
||||
[retained.runId, retained],
|
||||
[removed.runId, removed],
|
||||
]),
|
||||
);
|
||||
expect([...getSubagentRunsSnapshotForRead(new Map()).keys()]).toEqual(["retained", "removed"]);
|
||||
mocks.saveSubagentRegistryChangesToSqlite.mockImplementationOnce(() => {
|
||||
throw new Error("disk unavailable");
|
||||
});
|
||||
|
||||
persistSubagentRunsToDisk(new Map([[retained.runId, retained]]), [removed.runId]);
|
||||
|
||||
expect([...getSubagentRunsSnapshotForRead(new Map()).keys()]).toEqual(["retained"]);
|
||||
});
|
||||
|
||||
it("wakes local readers when a best-effort write fails", () => {
|
||||
const staleRun = createRun("stale");
|
||||
const updatedRun = createRun("updated");
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
loadSubagentRunsForChildSessionFromSqlite,
|
||||
loadSubagentRunsForControllerFromSqlite,
|
||||
loadSubagentRegistryFromSqlite,
|
||||
saveSubagentRegistryChangesToSqlite,
|
||||
saveSubagentRegistryToSqlite,
|
||||
} from "./subagent-registry.store.sqlite.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
@@ -56,6 +57,25 @@ function rememberPersistedSubagentRunsSnapshot(runs: Map<string, SubagentRunReco
|
||||
};
|
||||
}
|
||||
|
||||
function rememberPersistedSubagentRunChanges(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
changedRunIds: readonly string[],
|
||||
): void {
|
||||
if (!persistedSubagentRunsReadCache) {
|
||||
rememberPersistedSubagentRunsSnapshot(runs);
|
||||
return;
|
||||
}
|
||||
for (const runId of new Set(changedRunIds)) {
|
||||
const entry = runs.get(runId);
|
||||
if (entry) {
|
||||
persistedSubagentRunsReadCache.runs.set(runId, structuredClone(entry));
|
||||
} else {
|
||||
persistedSubagentRunsReadCache.runs.delete(runId);
|
||||
}
|
||||
}
|
||||
persistedSubagentRunsReadCache.loadedAtMs = Date.now();
|
||||
}
|
||||
|
||||
function shouldReadPersistedSubagentRuns(): boolean {
|
||||
return !isVitestRuntimeEnv() || process.env.OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_SQLITE === "1";
|
||||
}
|
||||
@@ -108,21 +128,42 @@ export function clearSubagentRunsReadCacheForTest(): void {
|
||||
persistedSubagentRunsReadCache = undefined;
|
||||
}
|
||||
|
||||
export function persistSubagentRunsToDisk(runs: Map<string, SubagentRunRecord>) {
|
||||
export function persistSubagentRunsToDisk(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
// Undefined replaces the complete snapshot; an array applies exact row mutations.
|
||||
changedRunIds?: readonly string[],
|
||||
) {
|
||||
try {
|
||||
saveSubagentRegistryToSqlite(runs);
|
||||
if (changedRunIds) {
|
||||
saveSubagentRegistryChangesToSqlite(runs, changedRunIds);
|
||||
} else {
|
||||
saveSubagentRegistryToSqlite(runs);
|
||||
}
|
||||
} catch {
|
||||
// ignore persistence failures
|
||||
} finally {
|
||||
// In-process readers must observe the authoritative memory snapshot before the wake.
|
||||
rememberPersistedSubagentRunsSnapshot(runs);
|
||||
if (changedRunIds) {
|
||||
rememberPersistedSubagentRunChanges(runs, changedRunIds);
|
||||
} else {
|
||||
rememberPersistedSubagentRunsSnapshot(runs);
|
||||
}
|
||||
emitSubagentRegistryPersisted();
|
||||
}
|
||||
}
|
||||
|
||||
export function persistSubagentRunsToDiskOrThrow(runs: Map<string, SubagentRunRecord>) {
|
||||
saveSubagentRegistryToSqlite(runs);
|
||||
rememberPersistedSubagentRunsSnapshot(runs);
|
||||
export function persistSubagentRunsToDiskOrThrow(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
// Undefined replaces the complete snapshot; an array applies exact row mutations.
|
||||
changedRunIds?: readonly string[],
|
||||
) {
|
||||
if (changedRunIds) {
|
||||
saveSubagentRegistryChangesToSqlite(runs, changedRunIds);
|
||||
rememberPersistedSubagentRunChanges(runs, changedRunIds);
|
||||
} else {
|
||||
saveSubagentRegistryToSqlite(runs);
|
||||
rememberPersistedSubagentRunsSnapshot(runs);
|
||||
}
|
||||
emitSubagentRegistryPersisted();
|
||||
}
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ export async function retireSupersededSubagentRun(params: {
|
||||
export function createSubagentRegistrySweeper(params: {
|
||||
runs: Map<string, SubagentRunRecord>;
|
||||
resumedRuns: Set<string>;
|
||||
persist: () => void;
|
||||
persist: (...runIds: string[]) => void;
|
||||
clearPendingLifecycleError: (runId: string) => void;
|
||||
clearPendingLifecycleTimeout: (runId: string) => void;
|
||||
sweepPendingLifecycle: (now: number) => void;
|
||||
@@ -233,6 +233,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
const now = Date.now();
|
||||
const storeCache: SubagentSessionStoreCache = new Map();
|
||||
let mutated = false;
|
||||
const mutatedRunIds = new Set<string>();
|
||||
const archivedCollectorGroups = new Set<string>();
|
||||
const suspendedEntries = [...runs.entries()].filter(([, entry]) =>
|
||||
isSuspendedPendingFinalDelivery(entry),
|
||||
@@ -272,6 +273,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
expired ? "expired" : "pressure-pruned",
|
||||
);
|
||||
mutated = true;
|
||||
mutatedRunIds.add(runId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -292,6 +294,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
})
|
||||
) {
|
||||
mutated = true;
|
||||
mutatedRunIds.add(runId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -345,18 +348,21 @@ export function createSubagentRegistrySweeper(params: {
|
||||
}
|
||||
|
||||
if (entry.killReconciliation) {
|
||||
mutated =
|
||||
(await reconcileProvisionalSubagentKill({
|
||||
runId,
|
||||
entry,
|
||||
now,
|
||||
runs,
|
||||
storeCache,
|
||||
completeSubagentRunWithRecovery: params.completeSubagentRunWithRecovery,
|
||||
retireSupersededRun: params.retireSupersededRun,
|
||||
startSubagentAnnounceCleanupFlow: params.startSubagentAnnounceCleanupFlow,
|
||||
warn: params.warn,
|
||||
})) || mutated;
|
||||
const reconciled = await reconcileProvisionalSubagentKill({
|
||||
runId,
|
||||
entry,
|
||||
now,
|
||||
runs,
|
||||
storeCache,
|
||||
completeSubagentRunWithRecovery: params.completeSubagentRunWithRecovery,
|
||||
retireSupersededRun: params.retireSupersededRun,
|
||||
startSubagentAnnounceCleanupFlow: params.startSubagentAnnounceCleanupFlow,
|
||||
warn: params.warn,
|
||||
});
|
||||
if (reconciled) {
|
||||
mutated = true;
|
||||
mutatedRunIds.add(runId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (entry.collect && entry.collectorCompletion) {
|
||||
@@ -382,6 +388,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
entry.collectorLaunchCleanupPending = false;
|
||||
entry.cleanupCompletedAt = now;
|
||||
mutated = true;
|
||||
mutatedRunIds.add(runId);
|
||||
}
|
||||
const groupId = entry.groupId?.trim();
|
||||
const swarmRequesterSessionKey =
|
||||
@@ -455,7 +462,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
try {
|
||||
await params.runContextEngineSubagentEnded(sweptContext(candidate));
|
||||
candidate.contextEngineCleanupCompletedAt = Date.now();
|
||||
params.persist();
|
||||
params.persist(candidateRunId);
|
||||
} catch (error) {
|
||||
params.warn(
|
||||
"context-engine cleanup failed during collector group sweep; keeping group",
|
||||
@@ -476,6 +483,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
for (const [candidateRunId] of groupEntries) {
|
||||
params.clearPendingLifecycleError(candidateRunId);
|
||||
runs.delete(candidateRunId);
|
||||
mutatedRunIds.add(candidateRunId);
|
||||
}
|
||||
archivedCollectorGroups.add(groupKey);
|
||||
mutated = true;
|
||||
@@ -495,6 +503,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
});
|
||||
runs.delete(runId);
|
||||
mutated = true;
|
||||
mutatedRunIds.add(runId);
|
||||
if (!entry.retainAttachmentsOnKeep) {
|
||||
await safeRemoveAttachmentsDir(entry);
|
||||
}
|
||||
@@ -517,6 +526,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
}
|
||||
runs.delete(runId);
|
||||
mutated = true;
|
||||
mutatedRunIds.add(runId);
|
||||
await safeRemoveAttachmentsDir(entry);
|
||||
runCleanupTail(runId, "context-engine cleanup", async () => {
|
||||
await params.notifyContextEngineSubagentEnded(sweptContext(entry));
|
||||
@@ -525,7 +535,7 @@ export function createSubagentRegistrySweeper(params: {
|
||||
params.sweepPendingLifecycle(now);
|
||||
|
||||
if (mutated) {
|
||||
params.persist();
|
||||
params.persist(...mutatedRunIds);
|
||||
}
|
||||
if (runs.size === 0) {
|
||||
stop();
|
||||
|
||||
@@ -21,6 +21,7 @@ const mocks = vi.hoisted(() => ({
|
||||
runSubagentAnnounceFlow: vi.fn().mockResolvedValue(false),
|
||||
captureSubagentCompletionReply: vi.fn(),
|
||||
loadSubagentRegistryFromSqlite: vi.fn(() => new Map()),
|
||||
saveSubagentRegistryChangesToSqlite: vi.fn(),
|
||||
saveSubagentRegistryToSqlite: vi.fn(),
|
||||
resolveAgentTimeoutMs: vi.fn(() => 60_000),
|
||||
scheduleOrphanRecovery: vi.fn(),
|
||||
@@ -68,6 +69,7 @@ vi.mock("../infra/agent-events.js", () => ({
|
||||
|
||||
vi.mock("./subagent-registry.store.sqlite.js", () => ({
|
||||
loadSubagentRegistryFromSqlite: mocks.loadSubagentRegistryFromSqlite,
|
||||
saveSubagentRegistryChangesToSqlite: mocks.saveSubagentRegistryChangesToSqlite,
|
||||
saveSubagentRegistryToSqlite: mocks.saveSubagentRegistryToSqlite,
|
||||
}));
|
||||
|
||||
@@ -131,6 +133,7 @@ describe("announce loop guard (#18264)", () => {
|
||||
mocks.runSubagentAnnounceFlow.mockReset();
|
||||
mocks.runSubagentAnnounceFlow.mockResolvedValue(false);
|
||||
mocks.scheduleOrphanRecovery.mockClear();
|
||||
mocks.saveSubagentRegistryChangesToSqlite.mockClear();
|
||||
mocks.saveSubagentRegistryToSqlite.mockClear();
|
||||
mocks.updateSessionStore.mockClear();
|
||||
registry.resetSubagentRegistryForTests({ persist: false });
|
||||
@@ -221,6 +224,9 @@ describe("announce loop guard (#18264)", () => {
|
||||
|
||||
expect(mocks.runSubagentAnnounceFlow).not.toHaveBeenCalled();
|
||||
expect(entry.cleanupCompletedAt).toBeGreaterThanOrEqual(beforeInit);
|
||||
expect(mocks.saveSubagentRegistryChangesToSqlite).toHaveBeenCalledWith(expect.any(Map), [
|
||||
entry.runId,
|
||||
]);
|
||||
});
|
||||
|
||||
test("expired completion-message entries are still resumed for announce", async () => {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Subagent registry SQLite store tests cover canonical whole-snapshot persistence.
|
||||
// Subagent registry SQLite store tests cover canonical snapshot and exact-row persistence.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
loadSubagentRunsForChildSessionFromSqlite,
|
||||
loadSubagentRunsForControllerFromSqlite,
|
||||
loadSubagentRegistryFromSqlite,
|
||||
saveSubagentRegistryChangesToSqlite,
|
||||
saveSubagentRegistryToSqlite,
|
||||
} from "./subagent-registry.store.sqlite.js";
|
||||
import type { SubagentRunRecord } from "./subagent-registry.types.js";
|
||||
@@ -152,6 +153,57 @@ describe("subagent registry sqlite store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("writes only named registry mutations", async () => {
|
||||
await withTempStateEnv(async () => {
|
||||
const first = createRun({ runId: "run-one", childSessionKey: "agent:main:subagent:one" });
|
||||
const removed = createRun({ runId: "run-two", childSessionKey: "agent:main:subagent:two" });
|
||||
const untouched = createRun({
|
||||
runId: "run-three",
|
||||
childSessionKey: "agent:main:subagent:three",
|
||||
});
|
||||
const runs = new Map([first, removed, untouched].map((run) => [run.runId, run] as const));
|
||||
saveSubagentRegistryToSqlite(runs);
|
||||
|
||||
const { db } = openOpenClawStateDatabase();
|
||||
db.exec(`
|
||||
CREATE TEMP TABLE subagent_run_write_audit (
|
||||
action TEXT NOT NULL,
|
||||
run_id TEXT NOT NULL
|
||||
);
|
||||
CREATE TEMP TRIGGER subagent_run_audit_insert
|
||||
AFTER INSERT ON subagent_runs
|
||||
BEGIN
|
||||
INSERT INTO subagent_run_write_audit VALUES ('insert', NEW.run_id);
|
||||
END;
|
||||
CREATE TEMP TRIGGER subagent_run_audit_update
|
||||
AFTER UPDATE ON subagent_runs
|
||||
BEGIN
|
||||
INSERT INTO subagent_run_write_audit VALUES ('update', NEW.run_id);
|
||||
END;
|
||||
CREATE TEMP TRIGGER subagent_run_audit_delete
|
||||
AFTER DELETE ON subagent_runs
|
||||
BEGIN
|
||||
INSERT INTO subagent_run_write_audit VALUES ('delete', OLD.run_id);
|
||||
END;
|
||||
`);
|
||||
|
||||
first.task = "updated task";
|
||||
runs.delete(removed.runId);
|
||||
saveSubagentRegistryChangesToSqlite(runs, [first.runId, removed.runId]);
|
||||
|
||||
expect(
|
||||
db.prepare("SELECT action, run_id FROM subagent_run_write_audit ORDER BY rowid").all(),
|
||||
).toEqual([
|
||||
{ action: "update", run_id: first.runId },
|
||||
{ action: "delete", run_id: removed.runId },
|
||||
]);
|
||||
expect([...loadSubagentRegistryFromSqlite().entries()]).toMatchObject([
|
||||
[first.runId, { task: "updated task" }],
|
||||
[untouched.runId, { task: untouched.task }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects writes outside the canonical nested state", async () => {
|
||||
await withTempStateEnv(async () => {
|
||||
const missingState = createRun({ execution: undefined });
|
||||
|
||||
@@ -381,6 +381,44 @@ function subagentRunRecordToSqliteUpdate(values: SubagentRunSqliteInsert): Subag
|
||||
return update;
|
||||
}
|
||||
|
||||
function writeSubagentRunValues(
|
||||
values: readonly SubagentRunSqliteInsert[],
|
||||
deleteRunIds?: readonly string[],
|
||||
retainedRunIds?: readonly string[],
|
||||
): void {
|
||||
if (values.length === 0 && deleteRunIds?.length === 0 && retainedRunIds === undefined) {
|
||||
return;
|
||||
}
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const stateDb = getNodeSqliteKysely<SubagentRegistryDatabase>(db);
|
||||
for (const row of values) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.insertInto("subagent_runs")
|
||||
.values(row)
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("run_id").doUpdateSet(subagentRunRecordToSqliteUpdate(row)),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (retainedRunIds !== undefined) {
|
||||
const deleteQuery =
|
||||
retainedRunIds.length === 0
|
||||
? stateDb.deleteFrom("subagent_runs")
|
||||
: stateDb.deleteFrom("subagent_runs").where("run_id", "not in", retainedRunIds);
|
||||
executeSqliteQuerySync(db, deleteQuery);
|
||||
return;
|
||||
}
|
||||
if (deleteRunIds && deleteRunIds.length > 0) {
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb.deleteFrom("subagent_runs").where("run_id", "in", deleteRunIds),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function readSubagentRegistryRows(): SubagentRunSqliteRow[] {
|
||||
const { db } = openOpenClawStateDatabase();
|
||||
const stateDb = getNodeSqliteKysely<SubagentRegistryDatabase>(db);
|
||||
@@ -472,26 +510,29 @@ export function loadSubagentRegistryFromSqlite(): Map<string, SubagentRunRecord>
|
||||
|
||||
/** Saves the complete subagent run snapshot to sqlite and prunes rows not in the snapshot. */
|
||||
export function saveSubagentRegistryToSqlite(runs: Map<string, SubagentRunRecord>): void {
|
||||
runOpenClawStateWriteTransaction(({ db }) => {
|
||||
const stateDb = getNodeSqliteKysely<SubagentRegistryDatabase>(db);
|
||||
const runIds: string[] = [];
|
||||
for (const entry of runs.values()) {
|
||||
const values = subagentRunRecordToSqliteInsert(entry);
|
||||
runIds.push(values.run_id);
|
||||
executeSqliteQuerySync(
|
||||
db,
|
||||
stateDb
|
||||
.insertInto("subagent_runs")
|
||||
.values(values)
|
||||
.onConflict((conflict) =>
|
||||
conflict.column("run_id").doUpdateSet(subagentRunRecordToSqliteUpdate(values)),
|
||||
),
|
||||
);
|
||||
}
|
||||
const deleteQuery =
|
||||
runIds.length === 0
|
||||
? stateDb.deleteFrom("subagent_runs")
|
||||
: stateDb.deleteFrom("subagent_runs").where("run_id", "not in", runIds);
|
||||
executeSqliteQuerySync(db, deleteQuery);
|
||||
});
|
||||
const values = [...runs.values()].map(subagentRunRecordToSqliteInsert);
|
||||
writeSubagentRunValues(
|
||||
values,
|
||||
undefined,
|
||||
values.map((row) => row.run_id),
|
||||
);
|
||||
}
|
||||
|
||||
/** Persists only named run mutations, deleting names absent from the current registry. */
|
||||
export function saveSubagentRegistryChangesToSqlite(
|
||||
runs: Map<string, SubagentRunRecord>,
|
||||
changedRunIds: readonly string[],
|
||||
): void {
|
||||
const runIds = [...new Set(changedRunIds.map((runId) => runId.trim()).filter(Boolean))];
|
||||
const values: SubagentRunSqliteInsert[] = [];
|
||||
const deleteRunIds: string[] = [];
|
||||
for (const runId of runIds) {
|
||||
const entry = runs.get(runId);
|
||||
if (entry) {
|
||||
values.push(subagentRunRecordToSqliteInsert(entry));
|
||||
} else {
|
||||
deleteRunIds.push(runId);
|
||||
}
|
||||
}
|
||||
writeSubagentRunValues(values, deleteRunIds);
|
||||
}
|
||||
|
||||
@@ -4398,13 +4398,15 @@ describe("subagent registry seam flow", () => {
|
||||
"agent.wait": { status: "pending" },
|
||||
});
|
||||
|
||||
const runId = `run-single-persist-${queued ? "queued" : "running"}`;
|
||||
mod.registerSubagentRun({
|
||||
runId: `run-single-persist-${queued ? "queued" : "running"}`,
|
||||
runId,
|
||||
task: "persist one registry snapshot",
|
||||
queued,
|
||||
});
|
||||
|
||||
expect(mocks.persistSubagentRunsToDiskOrThrow).toHaveBeenCalledOnce();
|
||||
expect(mocks.persistSubagentRunsToDiskOrThrow).toHaveBeenCalledWith(expect.any(Map), [runId]);
|
||||
expect(mocks.persistSubagentRunsToDisk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -76,12 +76,20 @@ let lastOrphanRecoveryScheduleAt = 0;
|
||||
const SUBAGENT_ANNOUNCE_TIMEOUT_MS = 120_000;
|
||||
const GATEWAY_ADMISSION_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
function persistSubagentRuns() {
|
||||
subagentRegistryDeps.persistSubagentRunsToDisk(subagentRuns);
|
||||
// Hot lifecycle callers name every changed or removed row. Zero ids is reserved
|
||||
// for explicit full-registry replacement at restore/reset boundaries.
|
||||
function persistSubagentRuns(...runIds: string[]) {
|
||||
subagentRegistryDeps.persistSubagentRunsToDisk(
|
||||
subagentRuns,
|
||||
runIds.length > 0 ? runIds : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function persistSubagentRunsOrThrow() {
|
||||
subagentRegistryDeps.persistSubagentRunsToDiskOrThrow(subagentRuns);
|
||||
function persistSubagentRunsOrThrow(...runIds: string[]) {
|
||||
subagentRegistryDeps.persistSubagentRunsToDiskOrThrow(
|
||||
subagentRuns,
|
||||
runIds.length > 0 ? runIds : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
function findSubagentTaskForRun(entry: SubagentRunRecord) {
|
||||
@@ -317,7 +325,7 @@ function resumeSubagentRun(runId: string) {
|
||||
resumedRuns,
|
||||
})
|
||||
) {
|
||||
persistSubagentRuns();
|
||||
persistSubagentRuns(runId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -468,7 +476,7 @@ configureSubagentRegistrySteerRuntime({
|
||||
entry.swarmLaunchIdempotencyKey = idempotencyKey;
|
||||
entry.swarmLaunchPending = true;
|
||||
try {
|
||||
persistSubagentRunsOrThrow();
|
||||
persistSubagentRunsOrThrow(entry.runId);
|
||||
} catch (error) {
|
||||
entry.swarmLaunchIdempotencyKey = previousIdempotencyKey;
|
||||
entry.swarmLaunchPending = previousPending;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user