Compare commits

..

1 Commits

Author SHA1 Message Date
momothemage
8242ed1a33 fix(skills): guard workshop create target drift 2026-06-23 18:29:07 +08:00
365 changed files with 3911 additions and 13825 deletions

View File

@@ -198,19 +198,10 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -116,19 +116,10 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -105,19 +105,10 @@ jobs:
"+refs/heads/main:refs/remotes/origin/main"
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -100,7 +100,6 @@ jobs:
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
steps:
@@ -205,7 +204,6 @@ jobs:
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
@@ -269,8 +267,6 @@ jobs:
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
const runMacos =
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
const runIosBuild =
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && !docsOnly && isCanonicalRepository;
const runAndroid =
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
const runWindows =
@@ -365,7 +361,6 @@ jobs:
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
),
run_macos_swift: runMacos,
run_ios_build: runIosBuild,
run_android_job: runAndroid,
android_matrix: createMatrix(
runAndroid
@@ -2167,76 +2162,6 @@ jobs:
done
exit 1
ios-build:
permissions:
contents: read
name: "ios-build"
needs: [preflight]
if: needs.preflight.outputs.run_ios_build == 'true'
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
env:
CHECKOUT_REPO: ${{ github.repository }}
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
run: |
set -euo pipefail
git init "$GITHUB_WORKSPACE"
git -C "$GITHUB_WORKSPACE" config gc.auto 0
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
fetch_timeout_seconds=90
fetch_checkout_ref() {
git -C "$GITHUB_WORKSPACE" \
-c protocol.version=2 \
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
local fetch_pid="$!"
local elapsed=0
while kill -0 "$fetch_pid" 2>/dev/null; do
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
kill -TERM "$fetch_pid" 2>/dev/null || true
sleep 10
kill -KILL "$fetch_pid" 2>/dev/null || true
wait "$fetch_pid" || true
return 124
fi
sleep 1
elapsed=$((elapsed + 1))
done
wait "$fetch_pid"
}
fetch_checkout_ref
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
- name: Select Xcode 26
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install iOS Swift tooling
run: brew install xcodegen swiftlint swiftformat
- name: Build iOS app
run: pnpm ios:build
android:
permissions:
contents: read
@@ -2388,7 +2313,6 @@ jobs:
- checks-windows
- macos-node
- macos-swift
- ios-build
- android
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
runs-on: ubuntu-24.04

View File

@@ -171,19 +171,10 @@ jobs:
set -euo pipefail
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"
@@ -593,19 +584,10 @@ jobs:
fi
node_bin="$(dirname "$(node -p 'process.execPath')")"
link_node_tool() {
local tool="$1"
local source="$node_bin/$tool"
local target="/usr/local/bin/$tool"
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
return
fi
sudo ln -sf "$source" "$target"
}
link_node_tool node
link_node_tool npm
link_node_tool npx
link_node_tool corepack
sudo ln -sf "$node_bin/node" /usr/local/bin/node
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
#!/usr/bin/env bash
exec /usr/local/bin/corepack pnpm "$@"

View File

@@ -132,9 +132,9 @@ jobs:
if: ${{ inputs.qa_evidence_run_id == '' }}
uses: ./.github/workflows/qa-profile-evidence.yml
with:
ref: ${{ inputs.ref }}
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: release
qa_profile: all
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -238,8 +238,8 @@ jobs:
}
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (evidence.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
if (evidence.profile !== "all") {
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
}
const artifactDir = path.dirname(evidencePath);
@@ -256,8 +256,8 @@ jobs:
const manifestPath = path.join(artifactDir, manifestNames[0]);
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const manifestProfile = manifest.qaProfile ?? evidence.profile;
if (manifestProfile !== "release") {
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
if (manifestProfile !== "all") {
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
}
if (manifest.targetSha !== targetSha) {
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);

View File

@@ -44,11 +44,6 @@ on:
required: false
default: false
type: boolean
run_maturity_scorecard:
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
required: false
default: false
type: boolean
rerun_group:
description: Release check group to run
required: false
@@ -111,7 +106,6 @@ jobs:
mode: ${{ steps.inputs.outputs.mode }}
release_profile: ${{ steps.inputs.outputs.release_profile }}
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
@@ -285,7 +279,6 @@ jobs:
RELEASE_MODE_INPUT: ${{ inputs.mode }}
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
@@ -326,12 +319,6 @@ jobs:
else
run_release_soak=true
fi
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
run_maturity_scorecard=false
else
run_maturity_scorecard=true
fi
release_profile="$RELEASE_PROFILE_INPUT"
if [[ "$release_profile" == "minimum" ]]; then
release_profile=beta
@@ -435,7 +422,6 @@ jobs:
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
printf 'release_profile=%s\n' "$release_profile"
printf 'run_release_soak=%s\n' "$run_release_soak"
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
@@ -458,7 +444,6 @@ jobs:
RELEASE_MODE: ${{ inputs.mode }}
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
@@ -476,7 +461,6 @@ jobs:
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
echo "- Release profile: \`${RELEASE_PROFILE}\`"
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
@@ -786,7 +770,7 @@ jobs:
maturity_scorecard_release_checks:
name: Render maturity scorecard release docs
needs: [resolve_target]
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group)
permissions:
actions: read
contents: read

View File

@@ -89,13 +89,6 @@ jobs:
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// Reusable workflow jobs inherit the caller event but run as
// github-actions[bot]; selected ref validation still gates secrets.
if (context.actor === "github-actions[bot]") {
core.info("Skipping manual actor permission check for a reusable workflow call.");
core.setOutput("authorized", "true");
return;
}
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");

View File

@@ -2,11 +2,7 @@
## Unreleased
## 2026.6.9 - 2026-06-23
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
Maintenance update for the current OpenClaw Android release.
## 2026.6.2 - 2026-06-02

View File

@@ -1,3 +0,0 @@
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.

View File

@@ -1,3 +1 @@
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
Maintenance update for the current OpenClaw Android release.

View File

@@ -1,185 +0,0 @@
# App Review Notes
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
## Demo Account / Setup
Use the OpenClaw iOS app with the live review Gateway setup code included in
the `Notes` field of this App Review submission.
The setup code is a single generated code string. It already contains the public
Gateway host and setup credential.
## Setup Walkthrough
1. Open the OpenClaw app.
2. Tap `Continue`.
3. On `Connect Gateway`, tap `Set Up Manually`.
4. In the `Setup Code` section, tap the `Paste setup code` field.
5. Paste the setup code string from the App Review submission `Notes` field.
6. Tap `Apply Setup Code`.
7. If `Trust and connect` appears, tap `Trust and connect`.
8. Wait for the `Connected` screen.
9. On `Connected`, tap `Open OpenClaw`.
10. Confirm the `Control` screen shows `Gateway Online`.
11. Tap `Settings`.
12. Tap `Approvals`.
13. Tap `Open Notifications`.
14. Tap `Enable Notifications`.
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
17. Confirm `Notifications` shows `Enabled`.
## Chat
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start Apple review checklist.
```
Expected result: the assistant replies with the available App Review demos.
## Approval Demo
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Run the approval demo.
```
Expected result: the iPhone shows `Exec approval required` with the harmless
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
`Allow Once`. The chat then replies:
```text
The approval demo completed.
```
## Talk
1. Tap the `Talk` tab.
2. Tap `Start Talk`.
3. If iOS asks for microphone access, tap `Allow`.
4. If iOS asks for Speech Recognition access, tap `Allow`.
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
6. Say:
```text
Summarize this review setup in one sentence.
```
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
## Talk + Background Audio
1. Tap the `Talk` tab.
2. Confirm `Speakerphone` is on.
3. Confirm `Background listening` is on.
4. Tap `Start Talk`.
5. If iOS asks for microphone access, tap `Allow`.
6. If iOS asks for Speech Recognition access, tap `Allow`.
7. Confirm `Stop Talk` is visible.
8. Say:
```text
Tell me when you can hear me.
```
9. While Talk is active, send OpenClaw to the background by returning to the
Home Screen or locking the iPhone. Do not force quit the app.
10. Continue speaking then wait for assistant audio reply.
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
## Gateway Status
1. Tap `Control`.
2. Tap `Instances`.
3. Confirm the screen shows `Gateway online`.
4. Confirm at least one `agent` row is connected.
5. Confirm the iPhone review device appears in the connected instances list.
## Push Notification
1. Tap the `Chat` tab.
2. Tap the text field labeled `Message main...`.
3. Send this exact message:
```text
Start push notification demo.
```
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
notification with this body:
```text
OpenClaw App Review push notification demo
```
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`. Expected chat reply:
```text
The push notification demo completed.
```
## Push Wake / Status
1. Tap the `Chat` tab.
2. Send this exact message:
```text
Start push wake demo.
```
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
force quit the app.
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
about 10 seconds after the message is sent.
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
`Control`, tap `Chat`.
Expected result: the app reconnects to the live Gateway and Chat replies:
```text
The push wake and node status demo completed.
```
## Device Permissions
1. Tap `Settings`.
2. Tap `Permissions`.
3. Confirm these current app controls are available:
- `Camera`
- `Location` with `Off`, `While Using`, and `Always`
- `Keep Awake`
4. Expand `Privacy & Access`.
5. Confirm these request controls are available:
- `Contacts` / `Request Access`
- `Calendar (Add Events)` / `Request Access`
- `Calendar (View Events)` / `Request Full Access`
- `Reminders` / `Request Access`
## Share Sheet
1. Open Safari.
2. Navigate to `https://example.com`.
3. Tap the Safari toolbar `More` button.
4. Tap `Share`.
5. Tap `OpenClaw`.
6. Confirm the OpenClaw share extension appears and shows
`Edit text, then tap Send.` and `Send to OpenClaw`.
7. Tap `Send to OpenClaw`.
Expected result: the OpenClaw share extension sends the shared Safari page to
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
Chat shows the shared `Example Domain` page.

View File

@@ -326,7 +326,6 @@ extension SettingsProTab {
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
return false
}
self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight")
self.setupStatusText = "Checking gateway reachability..."
let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight")
if !ok {

View File

@@ -127,8 +127,6 @@ final class GatewayConnectionController {
private let discovery = GatewayDiscoveryModel()
private let discoveryEnabled: Bool
private weak var appModel: NodeAppModel?
private var localNetworkAccessRequested: Bool
private var currentScenePhase: ScenePhase = .inactive
private var didAutoConnect = false
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
private var pendingTrustConnect: PendingTrustConnect?
@@ -139,14 +137,9 @@ final class GatewayConnectionController {
let useTLS: Bool
}
init(
appModel: NodeAppModel,
startDiscovery: Bool = true,
deferDiscoveryUntilLocalNetworkRequest: Bool = false)
{
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
self.discoveryEnabled = startDiscovery
self.appModel = appModel
self.localNetworkAccessRequested = !deferDiscoveryUntilLocalNetworkRequest
GatewaySettingsStore.bootstrapPersistence()
let defaults = UserDefaults.standard
@@ -155,7 +148,7 @@ final class GatewayConnectionController {
self.updateFromDiscovery()
self.observeDiscovery()
if self.discoveryEnabled, self.localNetworkAccessRequested {
if self.discoveryEnabled {
self.discovery.start()
}
}
@@ -164,29 +157,11 @@ final class GatewayConnectionController {
self.discovery.setDebugLoggingEnabled(enabled)
}
func requestLocalNetworkAccess(reason: String) {
guard self.discoveryEnabled else {
self.discovery.stop()
self.updateFromDiscovery()
return
}
self.localNetworkAccessRequested = true
GatewayDiagnostics.log("local network access requested reason=\(reason)")
guard self.currentScenePhase != .background else { return }
self.discovery.start()
self.updateFromDiscovery()
self.attemptAutoReconnectIfNeeded()
}
func setScenePhase(_ phase: ScenePhase) {
self.currentScenePhase = phase
guard self.discoveryEnabled else {
self.discovery.stop()
return
}
guard self.localNetworkAccessRequested else { return }
switch phase {
case .background:
@@ -206,10 +181,6 @@ final class GatewayConnectionController {
self.updateFromDiscovery()
return
}
guard self.localNetworkAccessRequested else {
self.requestLocalNetworkAccess(reason: "restart_discovery")
return
}
self.discovery.stop()
self.didAutoConnect = false
@@ -226,7 +197,6 @@ final class GatewayConnectionController {
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
forceReconnect: Bool = false) async -> String?
{
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway")
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if instanceId.isEmpty {
@@ -305,7 +275,6 @@ final class GatewayConnectionController {
authOverride: ManualAuthOverride? = nil,
forceReconnect: Bool = false) async
{
self.requestLocalNetworkAccess(reason: "connect_manual")
let instanceId = GatewaySettingsStore.currentInstanceID()
let token =
authOverride.map(\.token) ?? GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
@@ -371,7 +340,6 @@ final class GatewayConnectionController {
}
func connectLastKnown() async {
self.requestLocalNetworkAccess(reason: "connect_last_known")
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
switch last {
case let .manual(host, port, useTLS, _):

View File

@@ -73,16 +73,10 @@ struct OnboardingWizardView: View {
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
let allowSkip: Bool
let onRequestLocalNetworkAccess: (String) -> Void
let onClose: () -> Void
init(
allowSkip: Bool,
onRequestLocalNetworkAccess: @escaping (String) -> Void,
onClose: @escaping () -> Void)
{
init(allowSkip: Bool, onClose: @escaping () -> Void) {
self.allowSkip = allowSkip
self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess
self.onClose = onClose
_step = State(
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
@@ -237,7 +231,6 @@ struct OnboardingWizardView: View {
}
.onAppear {
self.initializeState()
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
}
.onDisappear {
self.discoveryRestartTask?.cancel()
@@ -871,20 +864,10 @@ extension OnboardingWizardView {
private func advanceFromIntro() {
OnboardingStateStore.markFirstRunIntroSeen()
self.requestLocalNetworkAccess(reason: "onboarding_continue")
self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here."
self.step = .welcome
}
private func requestLocalNetworkAccessIfPastIntro(reason: String) {
guard self.step != .intro else { return }
self.requestLocalNetworkAccess(reason: reason)
}
private func requestLocalNetworkAccess(reason: String) {
self.onRequestLocalNetworkAccess(reason)
}
private func navigateBack() {
guard let target = self.step.previous else { return }
self.connectingGatewayID = nil

View File

@@ -646,8 +646,7 @@ struct OpenClawApp: App {
_gatewayController = State(
initialValue: GatewayConnectionController(
appModel: appModel,
startDiscovery: !Self.screenshotModeEnabled,
deferDiscoveryUntilLocalNetworkRequest: true))
startDiscovery: !Self.screenshotModeEnabled))
}
var body: some Scene {

View File

@@ -683,7 +683,6 @@ struct RootTabs: View {
self.updateIdleTimer()
self.updateHomeCanvasState()
guard newValue == .active else { return }
self.maybeRequestLocalNetworkAccess(reason: "scene_active")
Task {
await self.appModel.refreshGatewayOverviewIfConnected()
await MainActor.run {
@@ -730,10 +729,6 @@ struct RootTabs: View {
.onChange(of: self.onboardingRequestID) { _, _ in
self.evaluateOnboardingPresentation(force: true)
}
.onChange(of: self.showOnboarding) { _, newValue in
guard !newValue else { return }
self.maybeRequestLocalNetworkAccess(reason: "onboarding_dismissed")
}
.onChange(of: self.appModel.openChatRequestID) { _, _ in
self.selectSidebarDestination(.chat)
}
@@ -772,9 +767,6 @@ struct RootTabs: View {
.fullScreenCover(isPresented: self.$showOnboarding) {
OnboardingWizardView(
allowSkip: self.onboardingAllowSkip,
onRequestLocalNetworkAccess: { reason in
self.requestLocalNetworkAccess(reason: reason)
},
onClose: {
self.showOnboarding = false
})
@@ -1053,14 +1045,13 @@ extension RootTabs {
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
switch route {
case .none:
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
break
case .onboarding:
self.onboardingAllowSkip = true
self.showOnboarding = true
case .settings:
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
}
}
@@ -1087,7 +1078,6 @@ extension RootTabs {
guard route == .settings else { return }
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.maybeRequestLocalNetworkAccess(reason: "auto_open_settings")
}
private func maybeOpenSettingsForGatewaySetup() {
@@ -1098,19 +1088,6 @@ extension RootTabs {
self.presentedSheet = nil
self.didAutoOpenSettings = true
self.selectSidebarDestination(.gateway)
self.requestLocalNetworkAccess(reason: "gateway_setup_deeplink")
}
private func maybeRequestLocalNetworkAccess(reason: String) {
guard self.didEvaluateOnboarding else { return }
guard self.scenePhase == .active else { return }
guard !self.showOnboarding else { return }
self.requestLocalNetworkAccess(reason: reason)
}
private func requestLocalNetworkAccess(reason: String) {
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
self.gatewayController.requestLocalNetworkAccess(reason: reason)
}
private func applyInitialChatSessionIfNeeded() {

View File

@@ -594,7 +594,6 @@ struct RootTabsSourceGuardTests {
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
#expect(actionsSource.contains("Check Tailscale or LAN."))
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
@@ -611,32 +610,6 @@ struct RootTabsSourceGuardTests {
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
}
@Test func `local network access is requested from visible gateway flows`() throws {
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
#expect(appSource.contains("deferDiscoveryUntilLocalNetworkRequest: true"))
#expect(controllerSource.contains("func requestLocalNetworkAccess(reason: String)"))
#expect(controllerSource.contains("guard self.localNetworkAccessRequested else"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_manual\")"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\")"))
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_last_known\")"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"root_appear\")"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"scene_active\")"))
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"onboarding_dismissed\")"))
#expect(rootSource.contains("self.requestLocalNetworkAccess(reason: \"gateway_setup_deeplink\")"))
#expect(rootSource.contains("guard self.didEvaluateOnboarding else { return }"))
#expect(rootSource.contains("onRequestLocalNetworkAccess: { reason in"))
#expect(onboardingSource.contains("self.requestLocalNetworkAccess(reason: \"onboarding_continue\")"))
#expect(onboardingSource.contains("self.requestLocalNetworkAccessIfPastIntro(reason: \"onboarding_appear\")"))
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
}
@Test func `gateway settings preview matrix covers primary states`() throws {
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
@@ -827,13 +800,6 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func onboardingWizardSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
}
private static func openClawAppSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()

View File

@@ -1,2 +1,2 @@
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl

View File

@@ -42,7 +42,6 @@ or an explicit manual dispatch.
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
| `ios-build` | Xcode project generation plus the iOS app simulator build | iOS app, shared app kit, or Swabble changes |
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
@@ -53,7 +52,7 @@ or an explicit manual dispatch.
2. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
4. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
@@ -81,7 +80,7 @@ When the check fails, update the PR body instead of pushing another code commit.
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, iOS, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
@@ -121,7 +120,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
## Manual dispatches
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref.
@@ -141,7 +140,7 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
| `blacksmith-12vcpu-macos-26` | `macos-swift` and `ios-build` on `openclaw/openclaw`; forks fall back to `macos-26` |
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.
@@ -163,7 +162,6 @@ pnpm test:channels
pnpm test:contracts:channels
pnpm check:docs # docs format + lint + broken links
pnpm build # build dist when CI artifact/smoke checks matter
pnpm ios:build # generate and build the iOS app project
pnpm ci:timings # summarize the latest origin/main push CI run
pnpm ci:timings:recent # compare recent successful main CI runs
node scripts/ci-run-timings.mjs <run-id> # summarize wall time, queue time, and slowest jobs

View File

@@ -25,7 +25,7 @@ OpenClaw agent or Gateway.
openclaw skills search "calendar"
openclaw skills install @owner/<slug>
openclaw skills update @owner/<slug>
openclaw skills verify @owner/<slug>
openclaw skills verify <slug>
openclaw plugins search "calendar"
openclaw plugins install clawhub:<package>

View File

@@ -38,11 +38,11 @@ openclaw skills update @owner/<slug> --global
openclaw skills update --all
openclaw skills update --all --agent <id>
openclaw skills update --all --global
openclaw skills verify @owner/<slug>
openclaw skills verify @owner/<slug> --version <version>
openclaw skills verify @owner/<slug> --tag <tag>
openclaw skills verify @owner/<slug> --card
openclaw skills verify @owner/<slug> --global
openclaw skills verify <slug>
openclaw skills verify <slug> --version <version>
openclaw skills verify <slug> --tag <tag>
openclaw skills verify <slug> --card
openclaw skills verify <slug> --global
openclaw skills list
openclaw skills list --eligible
openclaw skills list --json
@@ -105,11 +105,8 @@ Notes:
target the shared managed skills directory instead of the workspace.
- `update --all` updates tracked ClawHub installs in the selected workspace, or
in the shared managed skills directory when combined with `--global`.
- `verify @owner/<slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON
envelope by default. There is no `--json` flag because JSON is already the
default. Bare slugs remain accepted for compatibility when the skill is
already installed or unambiguous, but owner-qualified refs avoid publisher
ambiguity.
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
default. There is no `--json` flag because JSON is already the default.
- When ClawHub returns server-resolved source provenance, verify JSON also
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
self-declared source URLs stay only in the raw provenance envelope and are not
@@ -157,6 +154,11 @@ openclaw skills workshop reject <proposal-id> --reason "Duplicate"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`propose-create` always targets a new sibling skill under `skills/<name>/` and
rejects drafts that reference existing workspace skill paths such as
`skills/qa-check/SKILL.md`. Use `propose-update <skill>` once per existing skill
when a change touches current skills.
## Related
- [CLI reference](/cli)

View File

@@ -27,7 +27,13 @@ plugin, ClawHub, extra-root, managed, personal-agent, or system skills.
active skills.
- **Workspace scoped:** creates target the workspace `skills/` root. Updates
are allowed only for writable workspace skills.
- **Single target:** create always proposes one new sibling skill, and update
targets one existing skill. Split multi-skill changes into separate update
proposals.
- **No clobber:** create fails if the target skill already exists.
- **Wrong-target guard:** create rejects proposal content that references
existing workspace skill paths such as `skills/trip-planning/SKILL.md`;
those changes belong in update proposals.
- **Hash bound:** update proposals bind to the current target hash and become
stale if the live skill changes before apply.
- **Scanner gated:** apply reruns scanning before writing.
@@ -88,12 +94,20 @@ openclaw skills workshop propose-create \
--proposal ./PROPOSAL.md
```
`propose-create` always creates a new sibling under `skills/<name>/`. If the
draft references an existing workspace skill path such as
`skills/trip-planning/SKILL.md`, Skill Workshop rejects it so the update does
not silently land in an unused sibling skill.
Create an update proposal for an existing workspace skill:
```bash
openclaw skills workshop propose-update trip-planning --proposal ./PROPOSAL.md
```
For changes that touch multiple existing skills, create one update proposal per
target skill.
List and inspect:
```bash
@@ -167,6 +181,10 @@ The model uses `skill_workshop`:
action: create | update | revise | list | inspect | apply | reject | quarantine
```
`action=create` is only for a brand-new workspace skill. `action=update` targets
one existing skill through `skill_name`. Agents should split multi-skill patches
into separate `action=update` calls before applying them.
Agents must use `skill_workshop` for generated skill work. They must not create
or change proposal files through `write`, `edit`, `exec`, shell commands, or
direct filesystem operations.

View File

@@ -152,8 +152,8 @@ publish and sync.
| Update all workspace skills | `openclaw skills update --all` |
| Update a shared managed skill | `openclaw skills update @owner/<slug> --global` |
| Update all shared managed skills | `openclaw skills update --all --global` |
| Verify a skill's trust envelope | `openclaw skills verify @owner/<slug>` |
| Print the generated Skill Card | `openclaw skills verify @owner/<slug> --card` |
| Verify a skill's trust envelope | `openclaw skills verify <slug>` |
| Print the generated Skill Card | `openclaw skills verify <slug> --card` |
| Publish / sync via ClawHub CLI | `clawhub sync --all` |
<AccordionGroup>
@@ -171,11 +171,9 @@ publish and sync.
</Accordion>
<Accordion title="Verification and security scanning">
`openclaw skills verify @owner/<slug>` asks ClawHub for the skill's
`openclaw skills verify <slug>` asks ClawHub for the skill's
`clawhub.skill.verify.v1` trust envelope. Installed ClawHub skills verify
against the version and registry recorded in `.clawhub/origin.json`.
Bare slugs remain accepted for existing installed or unambiguous skills, but
owner-qualified refs avoid publisher ambiguity.
ClawHub skill pages expose the latest security scan state before install,
with detail pages for VirusTotal, ClawScan, and static analysis. The

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.11.2",
"acpx": "0.10.0",
"zod": "4.4.3"
}
},
@@ -196,9 +196,9 @@
}
},
"node_modules/@clack/core": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz",
"integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==",
"license": "MIT",
"dependencies": {
"fast-wrap-ansi": "^0.2.0",
@@ -209,12 +209,12 @@
}
},
"node_modules/@clack/prompts": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz",
"integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==",
"license": "MIT",
"dependencies": {
"@clack/core": "1.3.1",
"@clack/core": "1.4.1",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
"sisteransi": "^1.0.5"
@@ -831,15 +831,15 @@
}
},
"node_modules/acpx": {
"version": "0.11.2",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.11.2.tgz",
"integrity": "sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==",
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.10.0.tgz",
"integrity": "sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==",
"license": "MIT",
"dependencies": {
"@agentclientprotocol/sdk": "^0.28.1",
"commander": "^15.0.0",
"skillflag": "^0.2.0",
"tsx": "^4.22.4",
"@agentclientprotocol/sdk": "^0.22.1",
"commander": "^14.0.3",
"skillflag": "^0.1.4",
"tsx": "^4.22.0",
"zod": "^4.4.3"
},
"bin": {
@@ -849,15 +849,6 @@
"node": ">=22.13.0"
}
},
"node_modules/acpx/node_modules/@agentclientprotocol/sdk": {
"version": "0.28.1",
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
"license": "Apache-2.0",
"peerDependencies": {
"zod": "^3.25.0 || ^4.0.0"
}
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
@@ -1059,12 +1050,12 @@
}
},
"node_modules/commander": {
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
"license": "MIT",
"engines": {
"node": ">=22.12.0"
"node": ">=20"
}
},
"node_modules/content-disposition": {
@@ -2061,9 +2052,9 @@
"license": "MIT"
},
"node_modules/skillflag": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.2.0.tgz",
"integrity": "sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==",
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz",
"integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==",
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.0.1",

View File

@@ -10,7 +10,7 @@
"dependencies": {
"@agentclientprotocol/claude-agent-acp": "0.39.0",
"@zed-industries/codex-acp": "0.15.0",
"acpx": "0.11.2",
"acpx": "0.10.0",
"zod": "4.4.3"
},
"devDependencies": {

View File

@@ -251,15 +251,6 @@ describe("prepareAcpxCodexAuthConfig", () => {
expect(wrapper).not.toMatch(
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
);
// Orphan detection must trigger on any PPID change, not only when the new
// PPID is init (1). Systemd user services and container init reparent
// orphaned processes to a session manager or container init (PID != 1),
// and the older `process.ppid !== 1` guard would silently leak the codex
// adapter tree there.
expect(wrapper).not.toContain("process.ppid !== 1");
expect(wrapper).toMatch(
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
);
});
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {

View File

@@ -475,13 +475,7 @@ const parentWatcher =
process.platform === "win32"
? undefined
: setInterval(() => {
// Orphan detection: parent PID changed means our original parent died.
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
// systemd user-session manager, OR a container init, OR a session
// leader — depending on environment. Previously this only triggered
// on PPID == 1, which missed all systemd-managed deployments and
// leaked codex-acp adapter trees on every gateway restart.
if (process.ppid === originalParentPid) {
if (process.ppid === originalParentPid || process.ppid !== 1) {
return;
}
if (orphanCleanupStarted) {

View File

@@ -2,7 +2,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { RequestedModelUnsupportedError } from "acpx/runtime";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
AcpRuntimeError,
@@ -709,100 +708,6 @@ describe("AcpxRuntime fresh reset wrapper", () => {
});
});
it("retries without a model when ACPX reports missing model capability", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise model support",
"missing-capability",
),
)
.mockResolvedValueOnce({
sessionKey: "agent:opencode:acp:test",
backend: "acpx",
runtimeSessionName: "opencode",
});
await runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "openrouter/owl-alpha",
});
expect(ensure).toHaveBeenCalledTimes(2);
expect(readFirstEnsureSessionInput(ensure)).toMatchObject({
model: "openrouter/owl-alpha",
sessionOptions: { model: "openrouter/owl-alpha" },
});
const [, secondCall] = ensure.mock.calls;
expect(secondCall?.[0]).not.toHaveProperty("sessionOptions");
expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined();
});
it("does not retry when ACPX rejects an explicitly unsupported model id", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore, {
agentRegistry: {
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
list: () => ["opencode"],
},
});
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(
new RequestedModelUnsupportedError(
"Cannot apply --model: the ACP agent did not advertise that model",
"unadvertised-model",
),
);
await expect(
runtime.ensureSession({
sessionKey: "agent:opencode:acp:test",
agent: "opencode",
mode: "persistent",
model: "unknown/model",
}),
).rejects.toThrow("did not advertise that model");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("does not retry an unrelated error with similar wording", async () => {
const baseStore: TestSessionStore = {
load: vi.fn(async () => undefined),
save: vi.fn(async () => {}),
};
const { runtime, delegate } = makeRuntime(baseStore);
const ensure = vi
.spyOn(delegate, "ensureSession")
.mockRejectedValueOnce(new Error("the ACP agent did not advertise model support"));
await expect(
runtime.ensureSession({
sessionKey: "agent:main:acp:test",
agent: "main",
mode: "persistent",
model: "openrouter/owl-alpha",
}),
).rejects.toThrow("did not advertise model support");
expect(ensure).toHaveBeenCalledTimes(1);
});
it("injects Codex ACP startup config into the scoped registry", () => {
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);

View File

@@ -13,7 +13,6 @@ import {
createFileSessionStore,
decodeAcpxRuntimeHandleState,
encodeAcpxRuntimeHandleState,
isRequestedModelUnsupportedError,
type AcpAgentRegistry,
type AcpRuntimeDoctorReport,
type AcpRuntimeEvent,
@@ -587,26 +586,6 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate
} as AcpxDelegateEnsureInput;
}
function isAcpModelCapabilityMissingError(error: unknown): boolean {
return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability";
}
// ACPX owns the distinction between missing model capability and an invalid model id.
// Retry only the former so explicit model mistakes remain visible to the caller.
async function ensureDelegateSessionWithModelFallback(
delegate: BaseAcpxRuntime,
input: OpenClawRuntimeEnsureInput,
): Promise<AcpRuntimeHandle> {
try {
return await delegate.ensureSession(withAcpxSessionOptions(input));
} catch (error) {
if (!input.model || !isAcpModelCapabilityMissingError(error)) {
throw error;
}
return await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }));
}
}
function quoteShellArg(value: string): string {
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
return value;
@@ -1010,7 +989,7 @@ export class AcpxRuntime implements AcpRuntime {
this.withCodexWrapperDiagnostics({
command: stableLaunchCommand,
fallbackCode: "ACP_SESSION_INIT_FAILED",
run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput),
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
}),
});
}

View File

@@ -1,6 +1,6 @@
{
"id": "alibaba",
"icon": "https://cdn.simpleicons.org/alibabacloud",
"icon": "https://cdn.simpleicons.org/alibabacloud/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "anthropic-vertex",
"name": "Anthropic Vertex",
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
"icon": "https://cdn.simpleicons.org/anthropic",
"icon": "https://cdn.simpleicons.org/anthropic/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "anthropic",
"icon": "https://cdn.simpleicons.org/anthropic",
"icon": "https://cdn.simpleicons.org/anthropic/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "brave",
"name": "Brave",
"description": "OpenClaw Brave Search provider plugin for web search.",
"icon": "https://cdn.simpleicons.org/brave",
"icon": "https://cdn.simpleicons.org/brave/111111",
"activation": {
"onStartup": false
},

View File

@@ -43,39 +43,23 @@ afterAll(() => {
vi.resetModules();
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
function emptyWebSearchResponse(): Response {
return jsonResponse({ web: { results: [] } });
}
function installBraveLlmContextFetch() {
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return jsonResponse({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
});
return {
ok: true,
json: async () => ({
grounding: {
generic: [
{
url: "https://example.com/context",
title: "Context",
snippets: ["snippet"],
},
],
},
sources: [],
}),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
return mockFetch;
@@ -270,7 +254,10 @@ describe("brave web search provider", () => {
it("uses configured Brave baseUrl for web search requests", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -323,7 +310,12 @@ describe("brave web search provider", () => {
it("reports malformed Brave web search JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return malformedJsonResponse();
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -347,7 +339,12 @@ describe("brave web search provider", () => {
it("reports malformed Brave llm-context JSON as a provider error", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return malformedJsonResponse();
return {
ok: true,
json: async () => {
throw new SyntaxError("Unexpected token");
},
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -431,7 +428,10 @@ describe("brave web search provider", () => {
it("keeps Brave cache entries isolated by baseUrl", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -573,7 +573,10 @@ describe("brave web search provider", () => {
it("sends Brave web auth in the X-Subscription-Token header", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -729,7 +732,10 @@ describe("brave web search provider", () => {
it("falls back unsupported country values before calling Brave", async () => {
vi.stubEnv("BRAVE_API_KEY", "test-key");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return emptyWebSearchResponse();
return {
ok: true,
json: async () => ({ web: { results: [] } }),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;
@@ -757,17 +763,21 @@ describe("brave web search provider", () => {
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
vi.stubEnv("BRAVE_API_KEY", "");
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
return jsonResponse({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
});
return {
ok: true,
status: 200,
json: async () => ({
web: {
results: [
{
title: "Diagnostics",
url: "https://example.com/diagnostics",
description: "debug details",
},
],
},
}),
} as unknown as Response;
});
global.fetch = mockFetch as typeof global.fetch;

View File

@@ -15,14 +15,6 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
const provider = await registerSingleProviderPlugin(plugin);
const result = await provider.catalog?.run({
@@ -52,9 +44,10 @@ async function withRealChutesDiscovery<T>(
delete process.env.VITEST;
delete process.env.NODE_ENV;
const fetchMock = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [{ id: "chutes/private-model" }] }));
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {

View File

@@ -15,14 +15,6 @@ function restoreEnvVar(name: string, value: string | undefined): void {
}
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveChutesDiscovery<T>(
fetchMock: ReturnType<typeof vi.fn>,
run: () => Promise<T>,
@@ -53,11 +45,12 @@ async function withLiveChutesDiscovery<T>(
function createAuthEchoFetchMock() {
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: auth ? `${auth}-model` : "public-model" }],
}),
);
});
});
}
@@ -131,8 +124,9 @@ describe("chutes-models", () => {
});
it("discoverChutesModels correctly maps API response when not in test env", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{ id: "zai-org/GLM-4.7-TEE" },
{
@@ -146,7 +140,7 @@ describe("chutes-models", () => {
{ id: "new-provider/simple-model" },
],
}),
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-real-fetch");
expect(models.length).toBeGreaterThan(0);
@@ -164,8 +158,9 @@ describe("chutes-models", () => {
});
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
data: [
{
id: "provider/bad-window",
@@ -179,7 +174,7 @@ describe("chutes-models", () => {
},
],
}),
);
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("malformed-token-metadata");
@@ -200,10 +195,14 @@ describe("chutes-models", () => {
it("discoverChutesModels retries without auth on 401", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
return Promise.resolve(new Response("", { status: 401 }));
return Promise.resolve({
ok: false,
status: 401,
});
}
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [
{
id: "Qwen/Qwen3-32B",
@@ -233,7 +232,7 @@ describe("chutes-models", () => {
},
],
}),
);
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
const models = await discoverChutesModels("test-token-error");
@@ -243,7 +242,10 @@ describe("chutes-models", () => {
});
it("does not cache fallback static catalog for non-OK responses", async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 503,
});
await withLiveChutesDiscovery(mockFetch, async () => {
const first = await discoverChutesModels("chutes-fallback-token");
@@ -258,24 +260,27 @@ describe("chutes-models", () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
const auth = readAuthorizationHeader(init);
if (auth === "Bearer chutes-token-a") {
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "private/model-a" }],
}),
);
});
}
if (auth === "Bearer chutes-token-b") {
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "private/model-b" }],
}),
);
});
}
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "public/model" }],
}),
);
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
const modelsA = await discoverChutesModels("chutes-token-a");
@@ -320,13 +325,17 @@ describe("chutes-models", () => {
it("does not cache 401 fallback under the failed token key", async () => {
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
if (readAuthorizationHeader(init) === "Bearer failed-token") {
return Promise.resolve(new Response("", { status: 401 }));
return Promise.resolve({
ok: false,
status: 401,
});
}
return Promise.resolve(
jsonResponse({
return Promise.resolve({
ok: true,
json: async () => ({
data: [{ id: "public/model" }],
}),
);
});
});
await withLiveChutesDiscovery(mockFetch, async () => {
await discoverChutesModels("failed-token");

View File

@@ -1,6 +1,6 @@
{
"id": "cloudflare-ai-gateway",
"icon": "https://cdn.simpleicons.org/cloudflare",
"icon": "https://cdn.simpleicons.org/cloudflare/111111",
"activation": {
"onStartup": false
},

View File

@@ -78,16 +78,13 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
};
/** Reads mirrored Codex session history for harness hooks. */
export async function readMirroredSessionHistoryMessages(params: {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
}): Promise<AgentMessage[] | undefined> {
const messages = await readCodexMirroredSessionHistoryMessages(params);
export async function readMirroredSessionHistoryMessages(
sessionFile: string,
): Promise<AgentMessage[] | undefined> {
const messages = await readCodexMirroredSessionHistoryMessages(sessionFile);
if (!messages) {
embeddedAgentLog.warn("failed to read mirrored session history for codex harness hooks", {
sessionFile: params.sessionFile,
sessionFile,
});
}
return messages;

View File

@@ -1827,14 +1827,7 @@ export class CodexAppServerEventProjector {
}
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
return (
(await readCodexMirroredSessionHistoryMessages({
agentId: this.params.agentId,
sessionFile: this.params.sessionFile,
sessionId: this.params.sessionId,
sessionKey: this.params.sessionKey,
})) ?? []
);
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
}
private createAssistantMessage(text: string): AssistantMessage {

View File

@@ -849,16 +849,7 @@ export async function runCodexAppServerAttempt(
},
});
const hadSessionFile = await pathExists(activeSessionFile);
const activeTranscriptTarget = {
agentId: sessionAgentId,
sessionFile: activeSessionFile,
sessionId: activeSessionId,
sessionKey: contextSessionKey,
};
let historyMessages =
!activeContextEngine && initialStartupBindingHadInactiveThreadBootstrap
? []
: ((await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? []);
let historyMessages = (await readMirroredSessionHistoryMessages(activeSessionFile)) ?? [];
const hookContextWindowFields = {
...(params.contextWindowInfo?.tokens
? { contextTokenBudget: params.contextWindowInfo.tokens }
@@ -916,7 +907,7 @@ export async function runCodexAppServerAttempt(
warn: (message) => embeddedAgentLog.warn(message),
});
historyMessages =
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? historyMessages;
(await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages;
}
const memoryToolNames = getCodexWorkspaceMemoryToolNames(toolBridge.availableSpecs);
const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({
@@ -3048,7 +3039,7 @@ export async function runCodexAppServerAttempt(
const activeContextEnginePluginIdLocal =
resolveContextEngineOwnerPluginId(activeContextEngine);
const finalMessages =
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ??
(await readMirroredSessionHistoryMessages(activeSessionFile)) ??
historyMessages.concat(result.messagesSnapshot);
await finalizeHarnessContextEngineTurn({
contextEngine: activeContextEngine,

View File

@@ -51,14 +51,6 @@ function messageEntry(params: {
};
}
function mirroredTarget(sessionFile: string) {
return {
sessionFile,
sessionId: "codex-session",
sessionKey: "codex-session",
};
}
describe("readCodexMirroredSessionHistoryMessages", () => {
it("replays only the branch selected by a leaf control", async () => {
const sessionFile = await writeSession([
@@ -83,9 +75,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
},
]);
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toMatchObject([
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
{ role: "user", content: "root prompt" },
{ role: "assistant", content: "active answer" },
]);
@@ -103,9 +93,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
},
]);
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toEqual([]);
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toEqual([]);
});
it("keeps visible history when continuation rows use a disjoint append cursor", async () => {
@@ -137,9 +125,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
}),
]);
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toMatchObject([
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
{ role: "user", content: "visible prompt" },
{ role: "assistant", content: "continued answer" },
]);
@@ -168,9 +154,7 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
}),
]);
await expect(
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
).resolves.toMatchObject([
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
{ role: "user", content: "visible prompt" },
{ role: "assistant", content: "continued answer" },
]);

View File

@@ -10,59 +10,40 @@ import {
migrateSessionEntries,
parseSessionEntries,
} from "openclaw/plugin-sdk/agent-sessions";
import {
resolveSessionTranscriptTarget,
type SessionTranscriptTargetParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { sanitizeCodexHistoryImagePayloads } from "./image-payload-sanitizer.js";
export type CodexMirroredSessionHistoryTarget = {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
};
function isMissingFileError(error: unknown): boolean {
return Boolean(
error &&
typeof error === "object" &&
"code" in error &&
(error as { code?: unknown }).code === "ENOENT",
);
}
/** Returns sanitized session-context messages for a Codex mirrored session file. */
export async function readCodexMirroredSessionHistoryMessages(
target: CodexMirroredSessionHistoryTarget,
sessionFile: string,
): Promise<AgentMessage[] | undefined> {
try {
await resolveSessionTranscriptTarget(resolveCodexHistoryTranscriptTarget(target));
const raw = await fs.readFile(target.sessionFile, "utf-8");
const raw = await fs.readFile(sessionFile, "utf-8");
const entries = parseSessionEntries(raw);
if (entries.length === 0) {
return [];
}
const firstEntry = entries[0] as { type?: unknown; id?: unknown } | undefined;
if (firstEntry?.type !== "session" || typeof firstEntry.id !== "string") {
return undefined;
}
migrateSessionEntries(entries as SessionEntry[]);
const sessionEntries = entries.filter((entry): entry is SessionEntry => {
return (
entry !== null &&
typeof entry === "object" &&
!Array.isArray(entry) &&
(entry as { type?: unknown }).type !== "session"
);
});
migrateSessionEntries(entries);
const sessionEntries = entries.filter(
(entry): entry is SessionEntry => entry.type !== "session",
);
return sanitizeCodexHistoryImagePayloads(
buildSessionContext(sessionEntries).messages,
"codex mirrored history",
);
} catch {
} catch (error) {
if (isMissingFileError(error)) {
return [];
}
return undefined;
}
}
function resolveCodexHistoryTranscriptTarget(
target: CodexMirroredSessionHistoryTarget,
): SessionTranscriptTargetParams {
return {
...(target.agentId ? { agentId: target.agentId } : {}),
sessionFile: target.sessionFile,
sessionId: target.sessionId,
sessionKey: target.sessionKey ?? "",
};
}

View File

@@ -21,14 +21,13 @@ import {
mirrorCodexAppServerTranscript,
} from "./transcript-mirror.js";
const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn());
const emitSessionTranscriptUpdateMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
const actual =
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
return {
...actual,
publishSessionTranscriptUpdateByIdentity: publishSessionTranscriptUpdateByIdentityMock,
emitSessionTranscriptUpdate: emitSessionTranscriptUpdateMock,
};
});
@@ -45,7 +44,7 @@ const tempDirs: string[] = [];
afterEach(async () => {
resetGlobalHookRunner();
publishSessionTranscriptUpdateByIdentityMock.mockReset();
emitSessionTranscriptUpdateMock.mockReset();
for (const dir of tempDirs.splice(0)) {
await fs.rm(dir, { recursive: true, force: true });
}
@@ -131,7 +130,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage, toolResultMessage],
idempotencyScope: "scope-1",
@@ -166,32 +164,30 @@ describe("mirrorCodexAppServerTranscript", () => {
const firstMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:main",
messages: [userMessage],
idempotencyScope: "codex-app-server:thread-1",
});
const secondMirror = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:main",
messages: [userMessage],
idempotencyScope: "codex-app-server:thread-1",
});
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
([update]) => update as Record<string, unknown>,
);
expect(updates).toHaveLength(1);
expect(updates[0]?.sessionFile).toBe(sessionFile);
expect(updates[0]?.sessionKey).toBe("agent:main:main");
expect(updates[0]?.update?.messageId).toEqual(expect.any(String));
expect(updates[0]?.update?.message).toMatchObject({
expect(updates[0]?.messageId).toEqual(expect.any(String));
expect(updates[0]?.message).toMatchObject({
role: "user",
content: [{ type: "text", text: "show me live" }],
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
});
expect(updates[0]?.update?.messageSeq).toBe(1);
expect(updates[0]?.messageSeq).toBe(1);
expect(firstMirror.userMessagesPresent).toHaveLength(1);
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
role: "user",
@@ -211,7 +207,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "agent:main:main",
messages: [
attachCodexMirrorIdentity(
@@ -232,16 +227,14 @@ describe("mirrorCodexAppServerTranscript", () => {
idempotencyScope: "codex-app-server:thread-1",
});
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
([update]) => update as Record<string, unknown>,
);
expect(updates.map((update) => update.update?.messageSeq)).toEqual([1, 2]);
expect(
updates.map((update) => {
const message = update.update?.message as { role?: string } | undefined;
return message?.role;
}),
).toEqual(["user", "assistant"]);
expect(updates.map((update) => update.messageSeq)).toEqual([1, 2]);
expect(updates.map((update) => (update.message as { role?: string }).role)).toEqual([
"user",
"assistant",
]);
});
it("creates the transcript directory on first mirror", async () => {
@@ -250,7 +243,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -281,14 +273,12 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "scope-1",
@@ -322,7 +312,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
@@ -359,14 +348,12 @@ describe("mirrorCodexAppServerTranscript", () => {
const first = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
});
const second = await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
@@ -407,7 +394,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "scope-1",
@@ -433,7 +419,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -471,7 +456,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -550,7 +534,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage],
idempotencyScope: "codex-app-server:thread-X",
@@ -564,7 +547,6 @@ describe("mirrorCodexAppServerTranscript", () => {
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, reasoningMessage, assistantMessage],
idempotencyScope: "codex-app-server:thread-X",
@@ -613,14 +595,12 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1],
idempotencyScope: "codex-app-server:thread-X",
});
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn2, assistantTurn2],
idempotencyScope: "codex-app-server:thread-X",
@@ -658,7 +638,6 @@ describe("mirrorCodexAppServerTranscript", () => {
);
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1],
idempotencyScope: "codex-app-server:thread-X",
@@ -682,7 +661,6 @@ describe("mirrorCodexAppServerTranscript", () => {
// turn 1's entries (with their original identities preserved).
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2],
idempotencyScope: "codex-app-server:thread-X",
@@ -713,7 +691,6 @@ describe("mirrorCodexAppServerTranscript", () => {
await mirrorCodexAppServerTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage],
idempotencyScope: "scope-1",

View File

@@ -1,19 +1,19 @@
// Codex plugin module implements transcript mirror behavior.
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import {
acquireSessionWriteLock,
appendSessionTranscriptMessage,
embeddedAgentLog,
emitSessionTranscriptUpdate,
formatErrorMessage,
resolveSessionWriteLockOptions,
runAgentHarnessBeforeMessageWriteHook,
type AgentMessage,
type EmbeddedRunAttemptParams,
type EmbeddedRunAttemptResult,
type SessionWriteLockAcquireTimeoutConfig,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
publishSessionTranscriptUpdateByIdentity,
withSessionTranscriptWriteLock,
type SessionTranscriptTargetParams,
type SessionTranscriptWriteLockParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
@@ -273,13 +273,13 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
export async function mirrorCodexAppServerTranscript(params: {
sessionFile: string;
sessionId: string;
sessionId?: string;
cwd?: string;
sessionKey?: string;
agentId?: string;
messages: AgentMessage[];
idempotencyScope?: string;
config?: SessionTranscriptWriteLockParams["config"];
config?: SessionWriteLockAcquireTimeoutConfig;
}): Promise<CodexAppServerTranscriptMirrorResult> {
const messages = params.messages.filter(
(message): message is MirroredAgentMessage =>
@@ -289,133 +289,120 @@ export async function mirrorCodexAppServerTranscript(params: {
return { userMessagesPresent: [] };
}
const transcriptTarget = resolveCodexMirrorTranscriptTarget(params);
const { appendedUpdates, userMessagesPresent } = await withSessionTranscriptWriteLock(
{ ...transcriptTarget, config: params.config },
async (transcript) => {
const nextAppendedUpdates: Array<{
messageId: string;
message: AgentMessage;
messageSeq: number;
}> = [];
const nextUserMessagesPresent: MirroredUserMessage[] = [];
const mirrorState = readTranscriptMirrorState(await transcript.readEvents());
let nextMessageSeq = mirrorState.messageCount;
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
if (persistedUserMessage) {
nextUserMessagesPresent.push(persistedUserMessage);
}
continue;
const lock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
...resolveSessionWriteLockOptions(params.config),
});
const appendedUpdates: Array<{ messageId: string; message: AgentMessage; messageSeq: number }> =
[];
const userMessagesPresent: MirroredUserMessage[] = [];
try {
const mirrorState = await readTranscriptMirrorState(params.sessionFile);
let nextMessageSeq = mirrorState.messageCount;
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
if (persistedUserMessage) {
userMessagesPresent.push(persistedUserMessage);
}
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
const appended = await transcript.appendMessage({
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
cwd: params.cwd,
});
if (!appended) {
continue;
}
const { messageId, message: appendedMessage } = appended;
if (appendedMessage.role === "user") {
nextUserMessagesPresent.push(appendedMessage);
if (idempotencyKey) {
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
}
}
nextMessageSeq += 1;
nextAppendedUpdates.push({
messageId,
message: appendedMessage,
messageSeq: nextMessageSeq,
});
continue;
}
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
transcriptPath: params.sessionFile,
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
sessionId: params.sessionId,
cwd: params.cwd,
config: params.config,
});
if (appendedMessage.role === "user") {
userMessagesPresent.push(appendedMessage);
if (idempotencyKey) {
mirrorState.idempotencyKeys.add(idempotencyKey);
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
}
}
return { appendedUpdates: nextAppendedUpdates, userMessagesPresent: nextUserMessagesPresent };
},
);
nextMessageSeq += 1;
appendedUpdates.push({ messageId, message: appendedMessage, messageSeq: nextMessageSeq });
if (idempotencyKey) {
mirrorState.idempotencyKeys.add(idempotencyKey);
}
}
} finally {
await lock.release();
}
for (const update of appendedUpdates) {
await publishSessionTranscriptUpdateByIdentity({
...transcriptTarget,
update: {
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
message: update.message,
messageId: update.messageId,
messageSeq: update.messageSeq,
},
emitSessionTranscriptUpdate({
sessionFile: params.sessionFile,
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
...(params.agentId ? { agentId: params.agentId } : {}),
message: update.message,
messageId: update.messageId,
messageSeq: update.messageSeq,
});
}
return { userMessagesPresent };
}
function resolveCodexMirrorTranscriptTarget(params: {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
}): SessionTranscriptTargetParams {
return {
...(params.agentId ? { agentId: params.agentId } : {}),
sessionFile: params.sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? "",
};
}
function readTranscriptMirrorState(events: unknown[]): {
async function readTranscriptMirrorState(sessionFile: string): Promise<{
idempotencyKeys: Set<string>;
messageCount: number;
userMessagesByIdempotencyKey: Map<string, MirroredUserMessage>;
} {
}> {
const idempotencyKeys = new Set<string>();
const userMessagesByIdempotencyKey = new Map<string, MirroredUserMessage>();
let messageCount = 0;
for (const event of events) {
if (!event || typeof event !== "object" || Array.isArray(event)) {
let raw: string;
try {
raw = await fs.readFile(sessionFile, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
}
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
const parsed = event as {
message?: AgentMessage & { idempotencyKey?: unknown };
type?: unknown;
};
if (parsed.type === "message") {
messageCount += 1;
}
if (typeof parsed.message?.idempotencyKey === "string") {
idempotencyKeys.add(parsed.message.idempotencyKey);
if (parsed.message.role === "user") {
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
try {
const parsed = JSON.parse(line) as { message?: AgentMessage & { idempotencyKey?: unknown } };
if ((parsed as { type?: unknown }).type === "message") {
messageCount += 1;
}
if (typeof parsed.message?.idempotencyKey === "string") {
idempotencyKeys.add(parsed.message.idempotencyKey);
if (parsed.message.role === "user") {
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
}
}
} catch {
continue;
}
}
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };

View File

@@ -1,6 +1,6 @@
{
"id": "copilot-proxy",
"icon": "https://cdn.simpleicons.org/githubcopilot",
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "copilot",
"name": "GitHub Copilot agent runtime",
"description": "Registers the GitHub Copilot agent runtime.",
"icon": "https://cdn.simpleicons.org/githubcopilot",
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
"version": "2026.6.2",
"activation": {
"onStartup": false,

View File

@@ -2461,13 +2461,11 @@ describe("runCopilotAttempt", () => {
expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1);
const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as {
sessionFile: string;
sessionId: string;
messages: Array<{ role: string }>;
idempotencyScope?: string;
};
expect(args.sessionFile).toBe("session.json");
expect(args.sessionId).toBe("session-1");
expect(args.idempotencyScope).toBe("copilot:sess-1");
expect(args.idempotencyScope).toMatch(/^copilot:/u);
expect(args.messages.length).toBeGreaterThan(0);
const roles = args.messages.map((m) => m.role);
expect(roles).toContain("user");
@@ -2514,9 +2512,10 @@ describe("runCopilotAttempt", () => {
}
const identity = message["__openclaw"]?.mirrorIdentity ?? "";
// The terminal assistant carries the turn-stable
// `${runId}:assistant:final` identity attached by attempt.ts.
// Caller-passed history without an identity falls through to
// the positional `${scope}:role:idx`.
// `${runId}:assistant:final` identity attached by attempt.ts
// (rubber-duck-validated identity scheme — survives SDK session
// reuse across turns). Caller-passed history without an
// identity falls through to the positional `${scope}:role:idx`
// fingerprint that the existing tagging map applies.
if (message.role === "assistant" && index === args.messages.length - 1) {
expect(identity).toMatch(/:assistant:final$/u);

View File

@@ -44,7 +44,6 @@ import {
type SessionLike,
} from "./event-bridge.js";
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
import { createCopilotNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
import {
createPermissionBridge,
rejectAllPolicy,
@@ -229,7 +228,6 @@ function deferBackgroundCompactionCleanup(params: {
handle: PooledClient;
pool: CopilotClientPool;
cleanupToolBridge?: () => void;
finalizeNativeSubagents?: () => void;
sdkSessionId?: string;
session: SessionLike;
timeoutMs: number;
@@ -252,7 +250,6 @@ function deferBackgroundCompactionCleanup(params: {
await cancelBackgroundCompactionBeforeTeardown(params.session);
params.bridge.settleCompactionWait();
}
params.finalizeNativeSubagents?.();
params.bridge.detach();
try {
await params.session.disconnect();
@@ -413,11 +410,6 @@ export async function runCopilotAttempt(
let handle: PooledClient | undefined;
let session: SessionLike | undefined;
let bridge: ReturnType<typeof attachEventBridge> | undefined;
const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({
agentId: sessionAgentId,
now,
scope: input.agentHarnessTaskRuntimeScope,
});
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
let cleanupToolBridge: (() => void) | undefined;
@@ -756,8 +748,6 @@ export async function runCopilotAttempt(
}
bridge = attachEventBridge(session, {
onAssistantDelta: input.onAssistantDelta,
onAgentEvent: input.onAgentEvent,
onNativeSubagentEvent: (event) => nativeSubagentTaskMirror?.handleEvent(event),
onCompactionStart: async () => {
const sessionFile = readString(input.sessionFile);
if (!sessionFile) {
@@ -823,7 +813,6 @@ export async function runCopilotAttempt(
}
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
await bridge.awaitDeltaChain();
await bridge.awaitAgentEventChain();
if (!bridge.recordSendResult(result) && !aborted) {
// SDK sendAndWait returning undefined is treated as a timeout by the
// capability inventory. Do not call session.abort() here: OpenClaw may
@@ -859,7 +848,6 @@ export async function runCopilotAttempt(
} catch {
// delta-flush failure must not mask the timeout state
}
await bridge?.awaitAgentEventChain();
} else {
promptError = toError(error);
}
@@ -890,7 +878,6 @@ export async function runCopilotAttempt(
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
cleanupToolBridge,
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
handle,
pool: deps.pool,
sdkSessionId,
@@ -919,8 +906,6 @@ export async function runCopilotAttempt(
// defines as no background agents in flight. Timeouts retain the bridge
// until that event so compaction that starts after the timer still completes.
await bridge?.awaitCompactionChain();
await bridge?.awaitAgentEventChain();
nativeSubagentTaskMirror?.finalizeActiveRuns();
cleanupToolBridge?.();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
@@ -1005,9 +990,8 @@ export async function runCopilotAttempt(
// extension. Identity-tagged so re-emits dedupe. Errors are
// swallowed so a mirror failure cannot break the attempt.
const sessionFileForMirror = readString(input.sessionFile);
const openClawSessionIdForMirror = readString(input.sessionId);
const mirrorScopeSessionId = sessionIdUsed ?? openClawSessionIdForMirror;
if (sessionFileForMirror && openClawSessionIdForMirror && messagesSnapshot.length > 0) {
const sessionIdForScope = sessionIdUsed ?? readString(input.sessionId);
if (sessionFileForMirror && messagesSnapshot.length > 0) {
const taggedMessages = messagesSnapshot.map((message, index) => {
if (
message.role !== "user" &&
@@ -1028,16 +1012,15 @@ export async function runCopilotAttempt(
if (hasMirrorIdentity(message)) {
return message;
}
const identityScope = sdkSessionId ?? mirrorScopeSessionId ?? "attempt";
const identityScope = sdkSessionId ?? sessionIdForScope ?? "attempt";
return attachCopilotMirrorIdentity(message, `${identityScope}:${message.role}:${index}`);
});
await dualWriteCopilotTranscriptBestEffort({
sessionFile: sessionFileForMirror,
sessionId: openClawSessionIdForMirror,
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
agentId: readString(input.agentId),
messages: taggedMessages,
idempotencyScope: mirrorScopeSessionId ? `copilot:${mirrorScopeSessionId}` : undefined,
idempotencyScope: sessionIdForScope ? `copilot:${sessionIdForScope}` : undefined,
config: (input as { config?: unknown }).config as never,
}).catch((mirrorError: unknown) => {
// Defense-in-depth: the best-effort wrapper already swallows

View File

@@ -86,7 +86,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [userMessage, assistantMessage, toolResultMessage],
idempotencyScope: "copilot:session-1",
@@ -114,7 +113,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -145,14 +143,12 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "copilot:session-1",
});
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [...messages],
idempotencyScope: "copilot:session-1",
@@ -189,7 +185,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [sourceMessage],
idempotencyScope: "copilot:session-1",
@@ -215,7 +210,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [
makeAgentAssistantMessage({
@@ -234,7 +228,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
sessionKey: "session-1",
messages: [],
idempotencyScope: "copilot:session-1",
@@ -252,7 +245,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [message],
idempotencyScope: "scope-fp",
});
@@ -271,7 +263,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [tagged],
idempotencyScope: "copilot:openclaw-session-1",
});
@@ -288,7 +279,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "no scope" }],
@@ -316,7 +306,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [userMessage, systemLike],
idempotencyScope: "scope",
});
@@ -337,7 +326,6 @@ describe("mirrorCopilotTranscript", () => {
await mirrorCopilotTranscript({
sessionFile,
sessionId: "session-1",
messages: [second],
idempotencyScope: "scope",
});
@@ -354,7 +342,6 @@ describe("dualWriteCopilotTranscriptBestEffort", () => {
await expect(
dualWriteCopilotTranscriptBestEffort({
sessionFile,
sessionId: "session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "ok" }],
@@ -369,34 +356,22 @@ describe("dualWriteCopilotTranscriptBestEffort", () => {
});
it("swallows infrastructure failures and never rejects", async () => {
const root = await makeRoot("openclaw-copilot-mirror-invalid-");
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
process.env.OPENCLAW_STATE_DIR = root;
try {
await expect(
dualWriteCopilotTranscriptBestEffort({
agentId: "main",
sessionFile: "",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "should-not-throw" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope",
}),
).resolves.toBeUndefined();
await expect(
fs.access(path.join(root, "agents", "main", "sessions", "session-1.jsonl")),
).rejects.toHaveProperty("code", "ENOENT");
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
}
// Pointing sessionFile at a path under a non-existent root with an
// empty-string segment can fail differently on different platforms;
// instead force failure by passing an invalid type and asserting
// that the wrapper itself does not reject. Use any-cast for the
// bad input shape since we are testing the wrapper's catch.
await expect(
dualWriteCopilotTranscriptBestEffort({
sessionFile: "" as unknown as string,
messages: [
makeAgentAssistantMessage({
content: [{ type: "text", text: "should-not-throw" }],
timestamp: Date.now(),
}),
],
idempotencyScope: "scope",
}),
).resolves.toBeUndefined();
});
});

View File

@@ -29,16 +29,16 @@
*/
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import {
acquireSessionWriteLock,
appendSessionTranscriptMessage,
emitSessionTranscriptUpdate,
resolveSessionWriteLockAcquireTimeoutMs,
runAgentHarnessBeforeMessageWriteHook,
type AgentMessage,
type SessionWriteLockAcquireTimeoutConfig,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
publishSessionTranscriptUpdateByIdentity,
withSessionTranscriptWriteLock,
type SessionTranscriptTargetParams,
type SessionTranscriptWriteLockParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
@@ -95,7 +95,6 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
export interface MirrorCopilotTranscriptParams {
sessionFile: string;
sessionId: string;
sessionKey?: string;
agentId?: string;
messages: AgentMessage[];
@@ -107,7 +106,7 @@ export interface MirrorCopilotTranscriptParams {
* entry collide with its existing on-disk key and be a true no-op.
*/
idempotencyScope?: string;
config?: SessionTranscriptWriteLockParams["config"];
config?: SessionWriteLockAcquireTimeoutConfig;
}
export async function mirrorCopilotTranscript(
@@ -121,91 +120,82 @@ export async function mirrorCopilotTranscript(
return;
}
const transcriptTarget = resolveCopilotMirrorTranscriptTarget(params);
const didAppend = await withSessionTranscriptWriteLock(
{ ...transcriptTarget, config: params.config },
async (transcript) => {
let didAppendMessage = false;
const existingIdempotencyKeys = readTranscriptIdempotencyKeys(await transcript.readEvents());
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
continue;
}
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
const appended = await transcript.appendMessage({
message: messageToAppend,
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
});
if (!appended) {
continue;
}
didAppendMessage = true;
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}
const lock = await acquireSessionWriteLock({
sessionFile: params.sessionFile,
timeoutMs: resolveSessionWriteLockAcquireTimeoutMs(params.config),
});
try {
const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile);
for (const message of messages) {
const dedupeIdentity = buildMirrorDedupeIdentity(message);
const idempotencyKey = params.idempotencyScope
? `${params.idempotencyScope}:${dedupeIdentity}`
: undefined;
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
continue;
}
return didAppendMessage;
},
);
const transcriptMessage = {
...message,
...(idempotencyKey ? { idempotencyKey } : {}),
} as AgentMessage;
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
message: transcriptMessage,
agentId: params.agentId,
sessionKey: params.sessionKey,
});
if (!nextMessage) {
continue;
}
const messageToAppend = (
idempotencyKey
? {
...(nextMessage as unknown as Record<string, unknown>),
idempotencyKey,
}
: nextMessage
) as AgentMessage;
await appendSessionTranscriptMessage({
transcriptPath: params.sessionFile,
message: messageToAppend,
config: params.config,
});
if (idempotencyKey) {
existingIdempotencyKeys.add(idempotencyKey);
}
}
} finally {
await lock.release();
}
if (didAppend) {
await publishSessionTranscriptUpdateByIdentity({
...transcriptTarget,
update: params.sessionKey ? { sessionKey: params.sessionKey } : undefined,
});
if (params.sessionKey) {
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
} else {
emitSessionTranscriptUpdate(params.sessionFile);
}
}
function resolveCopilotMirrorTranscriptTarget(params: {
agentId?: string;
sessionFile: string;
sessionId: string;
sessionKey?: string;
}): SessionTranscriptTargetParams {
const sessionFile = params.sessionFile.trim();
if (!sessionFile) {
throw new Error("Copilot transcript mirror requires a sessionFile target");
}
return {
...(params.agentId ? { agentId: params.agentId } : {}),
sessionFile,
sessionId: params.sessionId,
sessionKey: params.sessionKey ?? "",
};
}
function readTranscriptIdempotencyKeys(events: unknown[]): Set<string> {
async function readTranscriptIdempotencyKeys(sessionFile: string): Promise<Set<string>> {
const keys = new Set<string>();
for (const event of events) {
if (!event || typeof event !== "object" || Array.isArray(event)) {
let raw: string;
try {
raw = await fs.readFile(sessionFile, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
throw error;
}
return keys;
}
for (const line of raw.split(/\r?\n/)) {
if (!line.trim()) {
continue;
}
const parsed = event as { message?: { idempotencyKey?: unknown } };
if (typeof parsed.message?.idempotencyKey === "string") {
keys.add(parsed.message.idempotencyKey);
try {
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
if (typeof parsed.message?.idempotencyKey === "string") {
keys.add(parsed.message.idempotencyKey);
}
} catch {
continue;
}
}
return keys;

View File

@@ -15,11 +15,6 @@ const REGISTERED_EVENT_TYPES = [
"assistant.usage",
"tool.execution_start",
"tool.execution_complete",
"session.plan_changed",
"exit_plan_mode.requested",
"subagent.started",
"subagent.completed",
"subagent.failed",
"session.compaction_start",
"session.compaction_complete",
"session.idle",
@@ -460,78 +455,6 @@ describe("attachEventBridge", () => {
});
});
it("projects Copilot plan events through the generic plan stream", async () => {
const session = createFakeSession();
const onAgentEvent = vi.fn().mockResolvedValue(undefined);
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onAgentEvent,
});
session.emit(
"session.plan_changed",
makeEvent("session.plan_changed", { operation: "update" }),
);
session.emit(
"exit_plan_mode.requested",
makeEvent("exit_plan_mode.requested", {
actions: ["approve", "edit"],
planContent: "# Plan\n- inspect\n- patch",
recommendedAction: "approve",
requestId: "request-1",
summary: "Plan ready",
}),
);
await bridge.awaitAgentEventChain();
expect(onAgentEvent).toHaveBeenCalledTimes(2);
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: "update",
},
});
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
explanation: "Plan ready",
steps: ["# Plan", "inspect", "patch"],
actions: ["approve", "edit"],
requestId: "request-1",
recommendedAction: "approve",
},
});
});
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
const session = createFakeSession();
const onNativeSubagentEvent = vi.fn();
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session-id",
isAborted: () => false,
onNativeSubagentEvent,
});
const event = makeEvent("subagent.started", {
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
});
session.emit("subagent.started", event);
expect(onNativeSubagentEvent).toHaveBeenCalledWith(event);
bridge.detach();
});
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
const session = createFakeSession();
const bridge = attachEventBridge(session, {

View File

@@ -41,16 +41,6 @@ export interface SessionLike {
export interface EventBridgeOptions {
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
onAgentEvent?: (event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}) => void | Promise<void>;
onNativeSubagentEvent?: (
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
) => void;
onCompactionComplete?: (payload: {
messagesRemoved?: number;
success: boolean;
@@ -82,7 +72,6 @@ export interface EventBridgeController {
awaitSessionIdle(): Promise<void>;
settleCompactionWait(): void;
awaitDeltaChain(): Promise<void>;
awaitAgentEventChain(): Promise<void>;
hasObservedCompaction(): boolean;
hasObservedSessionIdle(): boolean;
isCompacting(): boolean;
@@ -114,7 +103,6 @@ export function attachEventBridge(
let observedCompaction = false;
let deltaQueue = Promise.resolve();
let deltaChain = Promise.resolve();
let agentEventChain = Promise.resolve();
let compactionChain = Promise.resolve();
let compactionIdle = Promise.resolve();
let resolveCompactionIdle: (() => void) | undefined;
@@ -203,51 +191,6 @@ export function attachEventBridge(
}
});
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
operation: event.data.operation,
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
const steps = splitPlanText(event.data.planContent);
enqueueAgentEvent({
stream: "plan",
data: {
phase: "update",
title: "Plan updated",
source: "copilot-sdk",
...(event.data.summary ? { explanation: event.data.summary } : {}),
...(steps.length > 0 ? { steps } : {}),
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
...(event.data.recommendedAction
? { recommendedAction: event.data.recommendedAction }
: {}),
...(event.agentId ? { agentId: event.agentId } : {}),
},
});
});
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
forwardNativeSubagentEvent(event);
});
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
if (!isRootCompactionEvent(event)) {
return;
@@ -333,9 +276,6 @@ export function attachEventBridge(
awaitDeltaChain() {
return deltaChain;
},
awaitAgentEventChain() {
return agentEventChain;
},
hasObservedCompaction() {
return observedCompaction;
},
@@ -394,31 +334,6 @@ export function attachEventBridge(
compactionChain = queued.catch(() => undefined);
}
function enqueueAgentEvent(event: {
stream: "item" | "plan";
data: Record<string, unknown>;
}): void {
const callback = options.onAgentEvent;
if (!callback) {
return;
}
const invoke = () => callback(event);
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
}
function forwardNativeSubagentEvent(
event: Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>,
): void {
try {
options.onNativeSubagentEvent?.(event);
} catch {
// Native task mirroring must not corrupt the Copilot turn.
}
}
async function awaitStableCompaction(): Promise<void> {
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
if (idle) {
@@ -541,13 +456,6 @@ function joinReasoning(order: string[], reasoningById: Map<string, string>): str
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
}
function splitPlanText(text: string | undefined): string[] {
return (text ?? "")
.split(/\r?\n/)
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
.filter((line) => line.length > 0);
}
function readString(value: unknown): string | undefined {
return typeof value === "string" && value.length > 0 ? value : undefined;
}

View File

@@ -1,200 +0,0 @@
import type { SessionEvent } from "@github/copilot-sdk";
import type {
AgentHarnessTaskRecord,
AgentHarnessTaskRuntime,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
import { describe, expect, it, vi } from "vitest";
import {
CopilotNativeSubagentTaskMirror,
createCopilotNativeSubagentTaskMirror,
} from "./native-subagent-task-mirror.js";
type NativeSubagentEventType = "subagent.started" | "subagent.completed" | "subagent.failed";
function makeEvent<T extends NativeSubagentEventType>(
type: T,
data: Extract<SessionEvent, { type: T }>["data"],
agentId?: string,
): Extract<SessionEvent, { type: T }> {
return {
data,
id: `${type}-id`,
parentId: null,
timestamp: "2024-01-01T00:00:00.000Z",
type,
...(agentId ? { agentId } : {}),
} as Extract<SessionEvent, { type: T }>;
}
function createRuntime() {
const task = {} as AgentHarnessTaskRecord;
return {
tryCreateRunningTaskRun: vi.fn(() => task),
recordTaskRunProgressByRunId: vi.fn(() => []),
finalizeTaskRunByRunId: vi.fn(() => []),
} satisfies Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
}
describe("CopilotNativeSubagentTaskMirror", () => {
it("does not create a mirror without a host-issued task scope", () => {
expect(createCopilotNativeSubagentTaskMirror({})).toBeUndefined();
});
it("mirrors start and completion using agentId with toolCallId fallback", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror(
{ agentId: "parent-agent", now: () => 100 },
runtime,
);
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: "inspect the repository",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
},
"child-1",
),
);
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-1",
totalToolCalls: 2,
totalTokens: 30,
},
"child-1",
),
);
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
sourceId: "call-1",
agentId: "parent-agent",
runId: "copilot-agent:child-1",
label: "Researcher",
task: "inspect the repository",
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent started.",
});
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:child-1",
status: "succeeded",
endedAt: 100,
lastEventAt: 100,
progressSummary: "Copilot native subagent completed.",
terminalSummary: "Copilot native subagent completed (2 tool calls, 30 tokens).",
});
});
it("uses toolCallId when the SDK omits agentId", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 200 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-2",
}),
);
mirror.handleEvent(
makeEvent("subagent.failed", {
agentDisplayName: "Researcher",
agentName: "researcher",
error: "failed",
toolCallId: "call-2",
}),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
expect.objectContaining({
runId: "copilot-agent:call-2",
status: "failed",
error: "failed",
}),
);
});
it("keeps parallel subagents distinct when they share a parent tool call", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.started",
{
agentDescription: `inspect ${agentId}`,
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
for (const agentId of ["child-1", "child-2"]) {
mirror.handleEvent(
makeEvent(
"subagent.completed",
{
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-shared",
},
agentId,
),
);
}
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
1,
expect.objectContaining({ runId: "copilot-agent:child-1" }),
);
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
2,
expect.objectContaining({ runId: "copilot-agent:child-2" }),
);
});
it("finalizes active tasks when the parent attempt tears down", () => {
const runtime = createRuntime();
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);
mirror.handleEvent(
makeEvent("subagent.started", {
agentDescription: "inspect",
agentDisplayName: "Researcher",
agentName: "researcher",
toolCallId: "call-3",
}),
);
mirror.finalizeActiveRuns();
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
runId: "copilot-agent:call-3",
status: "cancelled",
endedAt: 300,
lastEventAt: 300,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
});
});

View File

@@ -1,199 +0,0 @@
import type { SessionEvent } from "@github/copilot-sdk";
import {
createAgentHarnessTaskRuntime,
type AgentHarnessTaskRuntime,
type AgentHarnessTaskRuntimeScope,
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
type CopilotNativeSubagentEvent = Extract<
SessionEvent,
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
>;
type TaskLifecycleRuntime = Pick<
AgentHarnessTaskRuntime,
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
>;
export function createCopilotNativeSubagentTaskMirror(params: {
agentId?: string;
now?: () => number;
scope?: AgentHarnessTaskRuntimeScope;
}): CopilotNativeSubagentTaskMirror | undefined {
if (!params.scope) {
return undefined;
}
return new CopilotNativeSubagentTaskMirror(
{
agentId: params.agentId,
now: params.now,
},
createAgentHarnessTaskRuntime({
runtime: "subagent",
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
scope: params.scope,
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
}),
);
}
export class CopilotNativeSubagentTaskMirror {
private readonly runIdByAgentId = new Map<string, string>();
private readonly runIdByToolCallId = new Map<string, string>();
private readonly terminalRunIds = new Set<string>();
private readonly activeRunIds = new Set<string>();
private readonly now: () => number;
constructor(
private readonly params: { agentId?: string; now?: () => number },
private readonly runtime: TaskLifecycleRuntime,
) {
this.now = params.now ?? Date.now;
}
handleEvent(event: CopilotNativeSubagentEvent): void {
const toolCallId = event.data.toolCallId.trim();
if (!toolCallId) {
return;
}
const runId = this.resolveRunId(event);
if (event.type === "subagent.started") {
this.handleStarted(event, runId, toolCallId);
return;
}
if (event.type === "subagent.completed") {
this.handleCompleted(event, runId);
return;
}
this.handleFailed(event, runId);
}
finalizeActiveRuns(): void {
const eventAt = this.now();
for (const runId of this.activeRunIds) {
this.terminalRunIds.add(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "cancelled",
endedAt: eventAt,
lastEventAt: eventAt,
error: "Copilot native subagent ended with its parent attempt.",
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
terminalSummary: "Copilot native subagent cancelled.",
});
}
this.activeRunIds.clear();
}
private handleStarted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.started" }>,
runId: string,
toolCallId: string,
): void {
const agentId = event.agentId?.trim();
const existingRunId = agentId
? this.runIdByAgentId.get(agentId)
: this.runIdByToolCallId.get(toolCallId);
if (existingRunId) {
return;
}
const eventAt = this.now();
const label = event.data.agentDisplayName.trim() || event.data.agentName.trim();
const task = event.data.agentDescription.trim() || `Copilot native subagent ${label}`;
const taskRecord = this.runtime.tryCreateRunningTaskRun({
sourceId: toolCallId,
agentId: this.params.agentId,
runId,
label: label || "Copilot subagent",
task,
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
startedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent started.",
});
if (!taskRecord) {
return;
}
if (agentId) {
this.runIdByAgentId.set(agentId, runId);
} else {
this.runIdByToolCallId.set(toolCallId, runId);
}
this.terminalRunIds.delete(runId);
this.activeRunIds.add(runId);
}
private handleCompleted(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "succeeded",
endedAt: eventAt,
lastEventAt: eventAt,
progressSummary: "Copilot native subagent completed.",
terminalSummary: buildCompletionSummary(event),
});
}
private handleFailed(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.failed" }>,
runId: string,
): void {
if (this.terminalRunIds.has(runId)) {
return;
}
const eventAt = this.now();
this.terminalRunIds.add(runId);
this.activeRunIds.delete(runId);
this.runtime.finalizeTaskRunByRunId({
runId,
status: "failed",
endedAt: eventAt,
lastEventAt: eventAt,
error: event.data.error,
progressSummary: "Copilot native subagent failed.",
terminalSummary: "Copilot native subagent failed.",
});
}
private resolveRunId(event: CopilotNativeSubagentEvent): string {
const agentId = event.agentId?.trim();
if (agentId) {
const existing = this.runIdByAgentId.get(agentId);
if (existing) {
return existing;
}
}
const existing = this.runIdByToolCallId.get(event.data.toolCallId);
if (existing) {
return existing;
}
const identity = agentId || event.data.toolCallId.trim();
return `${COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX}${identity}`;
}
}
function buildCompletionSummary(
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
): string {
const details = [
event.data.totalToolCalls !== undefined ? `${event.data.totalToolCalls} tool calls` : undefined,
event.data.totalTokens !== undefined ? `${event.data.totalTokens} tokens` : undefined,
].filter((value): value is string => value !== undefined);
return details.length > 0
? `Copilot native subagent completed (${details.join(", ")}).`
: "Copilot native subagent completed.";
}

View File

@@ -1,6 +1,6 @@
{
"id": "deepgram",
"icon": "https://cdn.simpleicons.org/deepgram",
"icon": "https://cdn.simpleicons.org/deepgram/111111",
"activation": {
"onStartup": false
},

View File

@@ -49,14 +49,6 @@ function makeAgentModelEntry(id = "profile/live-model") {
};
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveDiscoveryTestEnv(
mockFetch: ReturnType<typeof vi.fn>,
runAssertions: () => Promise<void>,
@@ -130,9 +122,10 @@ describe("deepinfra augmentModelCatalog", () => {
it("uses config-backed API keys to enable live model catalog augmentation", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("config/live-model")] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry("config/live-model")] }),
});
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
await withLiveDiscoveryTestEnv(mockFetch, async () => {
@@ -158,9 +151,10 @@ describe("deepinfra augmentModelCatalog", () => {
it("still runs live discovery when ctx.entries includes custom DeepInfra rows", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi
.fn()
.mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry("custom/live-model")] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry("custom/live-model")] }),
});
const provider = await registerSingleProviderPlugin(deepinfraPlugin);
const seededDeepInfraCount = DEEPINFRA_MODEL_CATALOG.length + 5;
@@ -236,7 +230,10 @@ describe("deepinfra capability registration", () => {
it("uses profile-resolved API keys for live text catalog discovery", async () => {
resetDeepInfraModelCacheForTest();
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
});
const captured = createCapturedPluginRegistration();
deepinfraPlugin.register(captured.api);
const provider = captured.providers[0];

View File

@@ -48,14 +48,6 @@ function makeAgentModelEntry(overrides: Record<string, unknown> = {}) {
};
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
function expectedStaticChatCatalog() {
return DEEPINFRA_MODEL_CATALOG.map((model) => {
const compat = Object.assign({}, model.compat, {
@@ -203,7 +195,10 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("fetches the openclaw-projection endpoint and parses chat-surface entries when an API key is configured", async () => {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [makeAgentModelEntry()] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry()] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -233,19 +228,21 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("skips entries with no metadata or no surface tag, and deduplicates ids", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
{ id: "BAAI/bge-m3", object: "model", metadata: null },
makeAgentModelEntry({
id: "untagged/model",
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
}),
makeAgentModelEntry(),
makeAgentModelEntry(),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
{ id: "BAAI/bge-m3", object: "model", metadata: null },
makeAgentModelEntry({
id: "untagged/model",
metadata: { context_length: 1, max_tokens: 1, pricing: {}, tags: [] },
}),
makeAgentModelEntry(),
makeAgentModelEntry(),
],
}),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -286,7 +283,7 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
});
it("falls back to the static catalog on non-2xx HTTP responses", async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
const mockFetch = vi.fn().mockResolvedValue({ ok: false, status: 503 });
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const models = await discoverDeepInfraModels();
@@ -297,10 +294,14 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("falls back without caching malformed successful model list payloads", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ data: {} }))
.mockResolvedValueOnce(
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
);
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: {} }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
@@ -327,8 +328,14 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("caches successful discovery responses only", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "first/model" })] }))
.mockResolvedValueOnce(jsonResponse({ data: [makeAgentModelEntry({ id: "second/model" })] }));
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "first/model" })] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "second/model" })] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const expectedIds = expectedLiveChatCatalog([
@@ -352,10 +359,14 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
it("does not cache successful responses that produce no live catalog rows", async () => {
const mockFetch = vi
.fn()
.mockResolvedValueOnce(jsonResponse({ data: [] }))
.mockResolvedValueOnce(
jsonResponse({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
);
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [] }),
})
.mockResolvedValueOnce({
ok: true,
json: () => Promise.resolve({ data: [makeAgentModelEntry({ id: "recovered/model" })] }),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
expect((await discoverDeepInfraModels()).map((m) => m.id)).toEqual(
@@ -382,65 +393,67 @@ describe("discoverDeepInfraModels (chat-only shim)", () => {
describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
it("buckets dynamic entries by short-alias surface tag", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeAgentModelEntry({
id: "anthropic/claude-sonnet-4-6",
metadata: {
description: "claude sonnet 4.6",
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat", "vlm", "vision", "prompt_cache"],
},
}),
makeAgentModelEntry({
id: "BAAI/bge-m3",
metadata: {
description: "bge-m3",
pricing: { input_tokens: 0.01 },
tags: ["embed"],
},
}),
makeAgentModelEntry({
id: "black-forest-labs/FLUX-1-schnell",
metadata: {
description: "FLUX schnell",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: 1024,
default_height: 1024,
default_iterations: 4,
},
}),
makeAgentModelEntry({
id: "Wan-AI/Wan2.6-T2V",
metadata: {
description: "Wan T2V",
pricing: { output_seconds: 0.05 },
tags: ["video-gen"],
},
}),
makeAgentModelEntry({
id: "Qwen/Qwen3-TTS",
metadata: {
description: "Qwen3 TTS",
pricing: { input_characters: 0.65 },
tags: ["tts"],
},
}),
makeAgentModelEntry({
id: "openai/whisper-large-v3-turbo",
metadata: {
description: "whisper",
pricing: { input_seconds: 0.00004 },
tags: ["stt"],
},
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeAgentModelEntry({
id: "anthropic/claude-sonnet-4-6",
metadata: {
description: "claude sonnet 4.6",
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat", "vlm", "vision", "prompt_cache"],
},
}),
makeAgentModelEntry({
id: "BAAI/bge-m3",
metadata: {
description: "bge-m3",
pricing: { input_tokens: 0.01 },
tags: ["embed"],
},
}),
makeAgentModelEntry({
id: "black-forest-labs/FLUX-1-schnell",
metadata: {
description: "FLUX schnell",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: 1024,
default_height: 1024,
default_iterations: 4,
},
}),
makeAgentModelEntry({
id: "Wan-AI/Wan2.6-T2V",
metadata: {
description: "Wan T2V",
pricing: { output_seconds: 0.05 },
tags: ["video-gen"],
},
}),
makeAgentModelEntry({
id: "Qwen/Qwen3-TTS",
metadata: {
description: "Qwen3 TTS",
pricing: { input_characters: 0.65 },
tags: ["tts"],
},
}),
makeAgentModelEntry({
id: "openai/whisper-large-v3-turbo",
metadata: {
description: "whisper",
pricing: { input_seconds: 0.00004 },
tags: ["stt"],
},
}),
],
}),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const catalog = await discoverDeepInfraSurfaces();
@@ -458,33 +471,35 @@ describe("discoverDeepInfraSurfaces (per-surface bucketing)", () => {
});
it("drops malformed live numeric metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeAgentModelEntry({
id: "bad/chat",
metadata: {
description: "bad chat",
context_length: -1,
max_tokens: 1.5,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat"],
},
}),
makeAgentModelEntry({
id: "bad/image",
metadata: {
description: "bad image",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: Number.POSITIVE_INFINITY,
default_height: 1024.5,
default_iterations: 0,
},
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeAgentModelEntry({
id: "bad/chat",
metadata: {
description: "bad chat",
context_length: -1,
max_tokens: 1.5,
pricing: { input_tokens: 3, output_tokens: 15 },
tags: ["chat"],
},
}),
makeAgentModelEntry({
id: "bad/image",
metadata: {
description: "bad image",
pricing: { per_image_unit: 0.003 },
tags: ["image-gen"],
default_width: Number.POSITIVE_INFINITY,
default_height: 1024.5,
default_iterations: 0,
},
}),
],
}),
});
await withFetchPathTest(mockFetch, { DEEPINFRA_API_KEY: "sk-test" }, async () => {
const catalog = await discoverDeepInfraSurfaces();

View File

@@ -49,14 +49,6 @@ const surfaceEntry = (id: string, surfaceTag: string, extra: Record<string, unkn
},
});
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withLiveFetch(mockFetch: ReturnType<typeof vi.fn>, run: () => Promise<void>) {
const env = { ...process.env };
delete process.env.NODE_ENV;
@@ -94,17 +86,19 @@ describe("DeepInfra generation catalogs", () => {
describe("listDeepInfraImageGenCatalog", () => {
it("returns null when live discovery succeeds but the response has zero image-gen entries", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
@@ -121,26 +115,28 @@ describe("listDeepInfraImageGenCatalog", () => {
});
it("projects discovered image-gen entries when a key is configured and discovery is live", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
default_width: 1024,
default_height: 1024,
default_iterations: 28,
}),
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
pricing: { per_image_unit: 0.03 },
}),
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
default_width: 1024,
default_height: 1024,
default_iterations: 28,
}),
surfaceEntry("ByteDance/Seedream-4", "image-gen", {
pricing: { per_image_unit: 0.03 },
}),
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraImageGenCatalog(withKeyCtx());
@@ -165,20 +161,22 @@ describe("listDeepInfraVideoGenCatalog", () => {
// produces zero video-gen entries. We must return null so the registered
// provider's static fallback list is consulted instead of an empty
// "live" answer.
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("anthropic/claude-sonnet-4-6", "chat", {
context_length: 200000,
max_tokens: 8192,
pricing: { input_tokens: 3, output_tokens: 15 },
}),
surfaceEntry("black-forest-labs/FLUX-2-pro", "image-gen", {
pricing: { per_image_unit: 0.08 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
@@ -187,18 +185,20 @@ describe("listDeepInfraVideoGenCatalog", () => {
});
it("projects discovered video-gen entries with capability shape", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
pricing: { output_seconds: 0.08 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
surfaceEntry("ByteDance/Seedance-2.0", "video-gen", {
pricing: { output_seconds: 0.08 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const result = await listDeepInfraVideoGenCatalog(withKeyCtx());
@@ -214,15 +214,17 @@ describe("listDeepInfraVideoGenCatalog", () => {
describe("resolveDeepInfraVideoModelCapabilities", () => {
it("returns capabilities for a discovered video-gen model", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({
@@ -234,15 +236,17 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
});
it("strips the deepinfra/ prefix when matching", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({
@@ -253,15 +257,17 @@ describe("resolveDeepInfraVideoModelCapabilities", () => {
});
it("returns undefined for an unknown model", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
surfaceEntry("Wan-AI/Wan2.6-T2V", "video-gen", {
pricing: { output_seconds: 0.05 },
}),
],
}),
});
await withLiveFetch(mockFetch, async () => {
const caps = await resolveDeepInfraVideoModelCapabilities({

View File

@@ -1,6 +1,6 @@
{
"id": "deepseek",
"icon": "https://cdn.simpleicons.org/deepseek",
"icon": "https://cdn.simpleicons.org/deepseek/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "discord",
"name": "Discord",
"description": "OpenClaw Discord channel plugin for channels, DMs, commands, and app events.",
"icon": "https://cdn.simpleicons.org/discord",
"icon": "https://cdn.simpleicons.org/discord/111111",
"skills": ["./skills"],
"activation": {
"onStartup": false

View File

@@ -204,18 +204,31 @@ const recordInboundSession = vi.hoisted(() =>
vi.fn<(params?: unknown) => Promise<void>>(async () => {}),
);
const configSessionsMocks = vi.hoisted(() => ({
getSessionEntry: vi.fn<(params?: unknown) => unknown>(() => undefined),
readLatestAssistantTextByIdentity: vi.fn<
(params?: unknown) => Promise<{ text: string; timestamp?: number } | undefined>
>(async () => undefined),
loadSessionStore: vi.fn<(storePath: string, opts?: unknown) => Record<string, unknown>>(
() => ({}),
),
readSessionUpdatedAt: vi.fn<(params?: unknown) => number | undefined>(() => undefined),
readLatestAssistantTextFromSessionTranscript: vi.fn<
(sessionFile: string) => Promise<{ text: string; timestamp?: number } | undefined>
>(async () => undefined),
resolveAndPersistSessionFile: vi.fn<(params?: unknown) => Promise<{ sessionFile: string }>>(
async () => ({ sessionFile: "/tmp/openclaw-discord-process-test-session.jsonl" }),
),
resolveSessionStoreEntry: vi.fn<
(params: { store: Record<string, unknown>; sessionKey?: string }) => { existing?: unknown }
>((params) => ({
existing: params.sessionKey ? params.store[params.sessionKey] : undefined,
})),
resolveStorePath: vi.fn<(path?: unknown, opts?: unknown) => string>(
() => "/tmp/openclaw-discord-process-test-sessions.json",
),
}));
const getSessionEntry = configSessionsMocks.getSessionEntry;
const readLatestAssistantTextByIdentity = configSessionsMocks.readLatestAssistantTextByIdentity;
const loadSessionStore = configSessionsMocks.loadSessionStore;
const readSessionUpdatedAt = configSessionsMocks.readSessionUpdatedAt;
const readLatestAssistantTextFromSessionTranscript =
configSessionsMocks.readLatestAssistantTextFromSessionTranscript;
const resolveAndPersistSessionFile = configSessionsMocks.resolveAndPersistSessionFile;
const resolveSessionStoreEntry = configSessionsMocks.resolveSessionStoreEntry;
const resolveStorePath = configSessionsMocks.resolveStorePath;
const createDiscordRestClientSpy = vi.hoisted(() =>
vi.fn<
@@ -387,17 +400,19 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", () => ({
}));
vi.mock("openclaw/plugin-sdk/session-store-runtime", () => ({
getSessionEntry: (params?: unknown) => configSessionsMocks.getSessionEntry(params),
loadSessionStore: (storePath: string, opts?: unknown) =>
configSessionsMocks.loadSessionStore(storePath, opts),
readSessionUpdatedAt: (params?: unknown) => configSessionsMocks.readSessionUpdatedAt(params),
readLatestAssistantTextFromSessionTranscript: (sessionFile: string) =>
configSessionsMocks.readLatestAssistantTextFromSessionTranscript(sessionFile),
resolveAndPersistSessionFile: (params?: unknown) =>
configSessionsMocks.resolveAndPersistSessionFile(params),
resolveSessionStoreEntry: (params: { store: Record<string, unknown>; sessionKey?: string }) =>
configSessionsMocks.resolveSessionStoreEntry(params),
resolveStorePath: (path?: unknown, opts?: unknown) =>
configSessionsMocks.resolveStorePath(path, opts),
}));
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", () => ({
readLatestAssistantTextByIdentity: (params?: unknown) =>
configSessionsMocks.readLatestAssistantTextByIdentity(params),
}));
vi.mock("../client.js", () => ({
createDiscordRuntimeAccountContext: (params: { cfg: unknown; accountId: string }) => ({
cfg: params.cfg,
@@ -490,16 +505,24 @@ beforeEach(() => {
createDiscordDraftStream.mockClear();
dispatchInboundMessage.mockClear();
recordInboundSession.mockClear();
loadSessionStore.mockClear();
readSessionUpdatedAt.mockClear();
getSessionEntry.mockClear();
readLatestAssistantTextByIdentity.mockClear();
readLatestAssistantTextFromSessionTranscript.mockClear();
resolveAndPersistSessionFile.mockClear();
resolveSessionStoreEntry.mockClear();
resolveStorePath.mockClear();
createDiscordRestClientSpy.mockClear();
dispatchInboundMessage.mockResolvedValue(createNoQueuedDispatchResult());
recordInboundSession.mockResolvedValue(undefined);
loadSessionStore.mockReturnValue({});
readSessionUpdatedAt.mockReturnValue(undefined);
getSessionEntry.mockReturnValue(undefined);
readLatestAssistantTextByIdentity.mockResolvedValue(undefined);
readLatestAssistantTextFromSessionTranscript.mockResolvedValue(undefined);
resolveAndPersistSessionFile.mockResolvedValue({
sessionFile: "/tmp/openclaw-discord-process-test-session.jsonl",
});
resolveSessionStoreEntry.mockImplementation((params) => ({
existing: params.sessionKey ? params.store[params.sessionKey] : undefined,
}));
resolveStorePath.mockReturnValue("/tmp/openclaw-discord-process-test-sessions.json");
threadBindingTesting.resetThreadBindingsForTests();
});
@@ -2249,8 +2272,10 @@ describe("processDiscordMessage draft streaming", () => {
(_value, index) => `continuation${index}`,
).join(" ")}`;
getSessionEntry.mockReturnValue({ sessionId: "session-1" });
readLatestAssistantTextByIdentity.mockResolvedValue({
loadSessionStore.mockReturnValue({
"agent:main:discord:channel:c1": { sessionId: "session-1" },
});
readLatestAssistantTextFromSessionTranscript.mockResolvedValue({
text: fullAnswer,
timestamp: Date.now() + 60_000,
});

View File

@@ -1,4 +1,5 @@
// Discord plugin module implements message handler.process behavior.
import path from "node:path";
import { MessageFlags } from "discord-api-types/v10";
import { resolveAckReaction, resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
import {
@@ -37,8 +38,13 @@ import {
} from "openclaw/plugin-sdk/reply-payload";
import type { ReplyDispatchKind, ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import { danger, logVerbose, shouldLogVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { readLatestAssistantTextByIdentity } from "openclaw/plugin-sdk/session-transcript-runtime";
import {
loadSessionStore,
readLatestAssistantTextFromSessionTranscript,
resolveAndPersistSessionFile,
resolveSessionStoreEntry,
resolveStorePath,
} from "openclaw/plugin-sdk/session-store-runtime";
import { resolveDiscordAccount, resolveDiscordMaxLinesPerMessage } from "../accounts.js";
import { createDiscordRestClient } from "../client.js";
import { beginDiscordInboundEventDeliveryCorrelation } from "../inbound-event-delivery.js";
@@ -516,20 +522,21 @@ async function processDiscordMessageInner(
}
try {
const storePath = resolveStorePath(cfg.session?.store, { agentId: route.agentId });
const sessionEntry = getSessionEntry({
agentId: route.agentId,
sessionKey,
storePath,
});
const store = loadSessionStore(storePath, { clone: false });
const sessionEntry = resolveSessionStoreEntry({ store, sessionKey }).existing;
if (!sessionEntry?.sessionId) {
return undefined;
}
const latest = await readLatestAssistantTextByIdentity({
agentId: route.agentId,
const { sessionFile } = await resolveAndPersistSessionFile({
sessionId: sessionEntry.sessionId,
sessionKey,
sessionStore: store,
storePath,
sessionEntry,
agentId: route.agentId,
sessionsDir: path.dirname(storePath),
});
const latest = await readLatestAssistantTextFromSessionTranscript(sessionFile);
if (!latest?.timestamp || latest.timestamp < dispatchStartedAt) {
return undefined;
}

View File

@@ -1,6 +1,6 @@
{
"id": "duckduckgo",
"icon": "https://cdn.simpleicons.org/duckduckgo",
"icon": "https://cdn.simpleicons.org/duckduckgo/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "elevenlabs",
"icon": "https://cdn.simpleicons.org/elevenlabs",
"icon": "https://cdn.simpleicons.org/elevenlabs/111111",
"activation": {
"onStartup": false
},

View File

@@ -464,7 +464,11 @@ describe("fetchCopilotModelCatalog", () => {
};
it("maps Copilot /models entries to ModelDefinitionConfig with real context windows", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, sampleApiResponse));
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => sampleApiResponse,
});
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -535,7 +539,11 @@ describe("fetchCopilotModelCatalog", () => {
});
it("strips trailing slash from baseUrl when building the /models URL", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, { data: [] }));
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({ data: [] }),
});
await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -547,8 +555,10 @@ describe("fetchCopilotModelCatalog", () => {
});
it("dedupes by id when API returns duplicates", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: [
{
id: "gpt-5.5",
@@ -570,7 +580,7 @@ describe("fetchCopilotModelCatalog", () => {
},
],
}),
);
});
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -583,8 +593,10 @@ describe("fetchCopilotModelCatalog", () => {
});
it("falls back from malformed live token limits", async () => {
const fetchImpl = vi.fn().mockResolvedValue(
makeResponse(200, {
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => ({
data: [
{
id: "gpt-bad-window",
@@ -612,7 +624,7 @@ describe("fetchCopilotModelCatalog", () => {
},
],
}),
);
});
const out = await fetchCopilotModelCatalog({
copilotApiToken: "tid=test",
@@ -634,7 +646,11 @@ describe("fetchCopilotModelCatalog", () => {
});
it("throws on non-2xx HTTP responses so the caller can fall back to the static catalog", async () => {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(401, {}));
const fetchImpl = vi.fn().mockResolvedValue({
ok: false,
status: 401,
json: async () => ({}),
});
await expect(
fetchCopilotModelCatalog({
@@ -647,7 +663,11 @@ describe("fetchCopilotModelCatalog", () => {
it("throws provider-owned errors for malformed successful /models payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const fetchImpl = vi.fn().mockResolvedValue(makeResponse(200, payload));
const fetchImpl = vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => payload,
});
await expect(
fetchCopilotModelCatalog({

View File

@@ -2,7 +2,7 @@
"id": "google-meet",
"name": "Google Meet",
"description": "OpenClaw Google Meet participant plugin for joining calls through Chrome or Twilio transports.",
"icon": "https://cdn.simpleicons.org/googlemeet",
"icon": "https://cdn.simpleicons.org/googlemeet/111111",
"enabledByDefault": false,
"commandAliases": [{ "name": "googlemeet" }],
"activation": {

View File

@@ -1,6 +1,6 @@
{
"id": "google",
"icon": "https://cdn.simpleicons.org/google",
"icon": "https://cdn.simpleicons.org/google/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "googlechat",
"name": "Google Chat",
"description": "OpenClaw Google Chat channel plugin for spaces and direct messages.",
"icon": "https://cdn.simpleicons.org/googlechat",
"icon": "https://cdn.simpleicons.org/googlechat/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "huggingface",
"icon": "https://cdn.simpleicons.org/huggingface",
"icon": "https://cdn.simpleicons.org/huggingface/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "imessage",
"icon": "https://cdn.simpleicons.org/imessage",
"icon": "https://cdn.simpleicons.org/imessage/111111",
"activation": {
"onStartup": false
},

View File

@@ -14,7 +14,16 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
import { discoverKilocodeModels, KILOCODE_MODELS_URL } from "./provider-models.js";
type MockKilocodeFetch = ((url: string, init?: RequestInit) => Promise<Response>) & {
type MockKilocodeFetchResponse = {
ok: boolean;
status?: number;
json?: () => Promise<unknown>;
};
type MockKilocodeFetch = ((
url: string,
init?: RequestInit,
) => Promise<MockKilocodeFetchResponse>) & {
mock: { calls: unknown[][] };
};
@@ -106,14 +115,6 @@ function makeAutoModel(overrides: Record<string, unknown> = {}) {
});
}
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "Content-Type": "application/json" },
...init,
});
}
async function withFetchPathTest(mockFetch: MockKilocodeFetch, runAssertions: () => Promise<void>) {
const release = vi.fn(async () => {});
vi.stubEnv("NODE_ENV", "");
@@ -164,11 +165,13 @@ describe("discoverKilocodeModels", () => {
describe("discoverKilocodeModels (fetch path)", () => {
it("parses gateway models with correct pricing conversion", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeAutoModel(), makeGatewayModel()],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [makeAutoModel(), makeGatewayModel()],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
@@ -214,7 +217,10 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("falls back to static catalog on HTTP error", async () => {
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
const mockFetch = vi.fn().mockResolvedValue({
ok: false,
status: 500,
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
@@ -223,7 +229,10 @@ describe("discoverKilocodeModels (fetch path)", () => {
it("falls back to static catalog for malformed successful model list payloads", async () => {
for (const payload of [[], { data: {} }, { data: [null] }]) {
const mockFetch = vi.fn().mockResolvedValue(jsonResponse(payload));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve(payload),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(models).toStrictEqual(EXPECTED_STATIC_KILOCODE_MODELS);
@@ -232,22 +241,24 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("falls back from malformed live token metadata", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [
makeGatewayModel({
id: "some/bad-window",
context_length: -1,
top_provider: { max_completion_tokens: 8192.5 },
}),
makeGatewayModel({
id: "some/bad-output",
context_length: Number.POSITIVE_INFINITY,
top_provider: { max_completion_tokens: 0 },
}),
],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
@@ -264,11 +275,13 @@ describe("discoverKilocodeModels (fetch path)", () => {
});
it("ensures kilo/auto is present even when API doesn't return it", async () => {
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [makeGatewayModel()],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [makeGatewayModel()],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
expect(requireModelById(models, "kilo/auto").id).toBe("kilo/auto");
@@ -288,7 +301,10 @@ describe("discoverKilocodeModels (fetch path)", () => {
supported_parameters: ["max_tokens", "temperature"],
});
const mockFetch = vi.fn().mockResolvedValue(jsonResponse({ data: [textOnlyModel] }));
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () => Promise.resolve({ data: [textOnlyModel] }),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const textModel = requireModelById(models, "some/text-model");
@@ -303,11 +319,13 @@ describe("discoverKilocodeModels (fetch path)", () => {
pricing: undefined,
});
const mockFetch = vi.fn().mockResolvedValue(
jsonResponse({
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
);
const mockFetch = vi.fn().mockResolvedValue({
ok: true,
json: () =>
Promise.resolve({
data: [malformedAutoModel, makeAutoModel(), makeGatewayModel()],
}),
});
await withFetchPathTest(mockFetch, async () => {
const models = await discoverKilocodeModels();
const auto = requireModelById(models, "kilo/auto");

View File

@@ -2,7 +2,7 @@
"id": "line",
"name": "LINE",
"description": "OpenClaw LINE channel plugin for LINE Bot API chats.",
"icon": "https://cdn.simpleicons.org/line",
"icon": "https://cdn.simpleicons.org/line/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "lmstudio",
"icon": "https://cdn.simpleicons.org/lmstudio",
"icon": "https://cdn.simpleicons.org/lmstudio/111111",
"activation": {
"onStartup": false
},

View File

@@ -31,21 +31,6 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
};
});
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
function malformedJsonResponse(): Response {
return new Response("{ nope", {
status: 200,
headers: { "content-type": "application/json" },
});
}
afterAll(() => {
vi.doUnmock("openclaw/plugin-sdk/ssrf-runtime");
vi.resetModules();
@@ -87,26 +72,33 @@ describe("lmstudio-models", () => {
loadedContextLength?: number;
maxContextLength?: number;
}) =>
vi.fn(async (url: string | URL, _init?: RequestInit) => {
vi.fn(async (url: string | URL, init?: RequestInit) => {
const key = params?.key ?? "qwen3-8b-instruct";
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [
{
type: "llm",
key,
max_context_length: params?.maxContextLength,
variants: params?.variants,
selected_variant: params?.selectedVariant,
loaded_instances: params?.loadedContextLength
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
: [],
},
],
});
return {
ok: true,
json: async () => ({
models: [
{
type: "llm",
key,
max_context_length: params?.maxContextLength,
variants: params?.variants,
selected_variant: params?.selectedVariant,
loaded_instances: params?.loadedContextLength
? [{ id: "inst-1", config: { context_length: params.loadedContextLength } }]
: [],
},
],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return jsonResponse({ status: "loaded" });
return {
ok: true,
json: async () => ({ status: "loaded" }),
requestInit: init,
};
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
@@ -304,8 +296,9 @@ describe("lmstudio-models", () => {
});
it("discovers llm models and maps metadata", async () => {
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
jsonResponse({
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) => ({
ok: true,
json: async () => ({
models: [
{
type: "llm",
@@ -337,7 +330,7 @@ describe("lmstudio-models", () => {
},
],
}),
);
}));
const models = await discoverLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -393,7 +386,13 @@ describe("lmstudio-models", () => {
});
it("reports malformed model list JSON with an owned error", async () => {
const fetchMock = vi.fn(async () => malformedJsonResponse());
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => {
throw new SyntaxError("bad json");
},
}));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -406,7 +405,11 @@ describe("lmstudio-models", () => {
it("reports wrong-shaped model list payloads with owned errors", async () => {
for (const payload of [[], { models: {} }, { models: [null] }]) {
const fetchMock = vi.fn(async () => jsonResponse(payload));
const fetchMock = vi.fn(async () => ({
ok: true,
status: 200,
json: async () => payload,
}));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -421,9 +424,12 @@ describe("lmstudio-models", () => {
it("caps oversized direct fetch timeouts before discovering models", async () => {
const timeoutController = new AbortController();
const timeoutSpy = vi.spyOn(AbortSignal, "timeout").mockReturnValue(timeoutController.signal);
const fetchMock = vi.fn(async (_url: string | URL, _init?: RequestInit) =>
jsonResponse({ models: [] }),
);
const fetchMock = vi.fn(async (_url: string | URL, init?: RequestInit) => ({
ok: true,
status: 200,
requestInit: init,
json: async () => ({ models: [] }),
}));
const result = await fetchLmstudioModels({
baseUrl: "http://localhost:1234/v1",
@@ -515,17 +521,20 @@ describe("lmstudio-models", () => {
const variantKey = `${canonicalKey}@q4_k_m`;
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [
{
type: "llm",
key: canonicalKey,
variants: [variantKey],
selected_variant: variantKey,
loaded_instances: [],
},
],
});
return {
ok: true,
json: async () => ({
models: [
{
type: "llm",
key: canonicalKey,
variants: [variantKey],
selected_variant: variantKey,
loaded_instances: [],
},
],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return new Response("load failed", { status: 503 });
@@ -566,12 +575,20 @@ describe("lmstudio-models", () => {
it("reports malformed model load JSON with an owned error", async () => {
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return malformedJsonResponse();
return {
ok: true,
json: async () => {
throw new SyntaxError("bad json");
},
};
}
throw new Error(`Unexpected fetch URL: ${String(url)}`);
});
@@ -591,9 +608,12 @@ describe("lmstudio-models", () => {
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
const fetchMock = vi.fn(async (url: string | URL) => {
if (String(url).endsWith("/api/v1/models")) {
return jsonResponse({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
});
return {
ok: true,
json: async () => ({
models: [{ type: "llm", key: "qwen3-8b-instruct", loaded_instances: [] }],
}),
};
}
if (String(url).endsWith("/api/v1/models/load")) {
return tracked.response;

View File

@@ -2,7 +2,7 @@
"id": "matrix",
"name": "Matrix",
"description": "OpenClaw Matrix channel plugin for rooms and direct messages.",
"icon": "https://cdn.simpleicons.org/matrix",
"icon": "https://cdn.simpleicons.org/matrix/111111",
"commandAliases": [{ "name": "matrix" }],
"activation": {
"onStartup": false,

View File

@@ -29,12 +29,10 @@ describe("performMatrixRequest", () => {
});
it("rejects oversized raw responses before buffering the whole body", async () => {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({ cancel });
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
new Response("too-big", {
status: 200,
headers: {
"content-length": "8192",
@@ -55,7 +53,6 @@ describe("performMatrixRequest", () => {
ssrfPolicy: { allowPrivateNetwork: true },
}),
).rejects.toBeInstanceOf(MatrixMediaSizeLimitError);
expect(cancel).toHaveBeenCalledOnce();
});
it("rejects malformed raw content-length before buffering the body", async () => {
@@ -198,141 +195,6 @@ describe("performMatrixRequest", () => {
"MockAgent",
);
});
it("rejects oversized JSON responses via content-length before buffering the body", async () => {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({ cancel });
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
status: 200,
headers: {
"content-type": "application/json",
"content-length": String(16 * 1024 * 1024),
},
}),
),
);
await expect(
performMatrixRequest({
homeserver: "http://127.0.0.1:8008",
accessToken: "token",
method: "GET",
endpoint: "/_matrix/client/v3/account/whoami",
timeoutMs: 5000,
maxBytes: 1024,
ssrfPolicy: { allowPrivateNetwork: true },
}),
).rejects.toThrow("Matrix JSON response exceeds configured size limit");
expect(cancel).toHaveBeenCalledOnce();
});
it("applies streaming byte limits when JSON responses omit content-length", async () => {
const chunk = new Uint8Array(768);
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(chunk);
controller.enqueue(chunk);
controller.close();
},
});
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
await expect(
performMatrixRequest({
homeserver: "http://127.0.0.1:8008",
accessToken: "token",
method: "GET",
endpoint: "/_matrix/client/v3/account/whoami",
timeoutMs: 5000,
maxBytes: 1024,
ssrfPolicy: { allowPrivateNetwork: true },
}),
).rejects.toThrow(
"Matrix JSON response exceeds configured size limit (1536 bytes > 1024 bytes)",
);
});
it("uses the JSON-specific idle-timeout error for stalled JSON downloads", async () => {
vi.useFakeTimers();
try {
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new Uint8Array([1, 2, 3]));
},
});
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
const requestPromise = performMatrixRequest({
homeserver: "http://127.0.0.1:8008",
accessToken: "token",
method: "GET",
endpoint: "/_matrix/client/v3/account/whoami",
timeoutMs: 5000,
maxBytes: 1024,
readIdleTimeoutMs: 50,
ssrfPolicy: { allowPrivateNetwork: true },
});
const rejection = expect(requestPromise).rejects.toThrow(
"Matrix JSON response stalled: no data received for 50ms",
);
await vi.advanceTimersByTimeAsync(60);
await rejection;
} finally {
vi.useRealTimers();
}
}, 5_000);
it("returns full JSON bodies that stay under the byte limit", async () => {
const payload = JSON.stringify({ ok: true, items: [1, 2, 3] });
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(payload));
controller.close();
},
});
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
),
);
const result = await performMatrixRequest({
homeserver: "http://127.0.0.1:8008",
accessToken: "token",
method: "GET",
endpoint: "/_matrix/client/v3/account/whoami",
timeoutMs: 5000,
maxBytes: 1024,
ssrfPolicy: { allowPrivateNetwork: true },
});
expect(result.text).toBe(payload);
expect(result.buffer.toString("utf8")).toBe(payload);
});
});
describe("createMatrixGuardedFetch", () => {
@@ -345,29 +207,6 @@ describe("createMatrixGuardedFetch", () => {
clearTestUndiciRuntimeDepsOverride();
});
it("rejects and cancels SDK responses above the declared size limit", async () => {
const cancel = vi.fn();
const stream = new ReadableStream<Uint8Array>({ cancel });
stubRuntimeFetch(
vi.fn(
async () =>
new Response(stream, {
status: 200,
headers: { "content-length": String(64 * 1024 * 1024 + 1) },
}),
),
);
const guardedFetch = createMatrixGuardedFetch({
ssrfPolicy: { allowPrivateNetwork: true },
});
await expect(guardedFetch("http://127.0.0.1:8008/_matrix/client/v3/sync")).rejects.toThrow(
"Matrix SDK response exceeds size limit (67108865 bytes > 67108864 bytes)",
);
expect(cancel).toHaveBeenCalledOnce();
});
it("strips matrix-js-sdk state_after sync opt-in from /sync requests", async () => {
const runtimeFetch = vi.fn(
async (_input: RequestInfo | URL, _init?: RequestInit) =>
@@ -384,11 +223,10 @@ describe("createMatrixGuardedFetch", () => {
ssrfPolicy: { allowPrivateNetwork: true },
});
const response = await guardedFetch(
await guardedFetch(
"http://127.0.0.1:8008/_matrix/client/v3/sync?filter=abc&org.matrix.msc4222.use_state_after=true&timeout=30000",
);
await expect(response.json()).resolves.toEqual({});
expect(runtimeFetch).toHaveBeenCalledTimes(1);
expect(runtimeFetch.mock.calls.at(0)?.[0]).toBe(
"http://127.0.0.1:8008/_matrix/client/v3/sync?filter=abc&timeout=30000",

View File

@@ -14,16 +14,6 @@ import {
export type HttpMethod = "GET" | "POST" | "PUT" | "DELETE";
// Default ceiling for non-raw JSON control-plane responses (whoami, receipts,
// directory search, key-backup status, generic doRequest). Matrix homeservers
// are untrusted, so bound the body the same way the raw media path is bounded
// instead of buffering an unbounded stream via response.text().
const MATRIX_JSON_RESPONSE_MAX_BYTES = 8 * 1024 * 1024;
// matrix-js-sdk also uses the injected fetch for raw encrypted key bundles.
// Keep that path bounded without applying the tighter control-plane JSON cap.
const MATRIX_SDK_RESPONSE_MAX_BYTES = 64 * 1024 * 1024;
type QueryValue =
| string
| number
@@ -100,7 +90,7 @@ function withoutMatrixStateAfterSyncParam(rawUrl: string): string {
function buildBufferedResponse(params: {
source: Response;
body: BodyInit;
body: ArrayBuffer;
url: string;
}): Response {
const response = new Response(params.body, {
@@ -119,32 +109,6 @@ function buildBufferedResponse(params: {
return response;
}
async function enforceDeclaredResponseSize(params: {
response: Response;
maxBytes: number;
createError: (length: number) => Error;
}): Promise<void> {
const contentLength = params.response.headers.get("content-length");
if (!contentLength) {
return;
}
let length: number | null;
try {
length = parseMediaContentLength(contentLength);
} catch (error) {
await params.response.body?.cancel(error).catch(() => undefined);
throw error;
}
if (length === null || length <= params.maxBytes) {
return;
}
const error = params.createError(length);
await params.response.body?.cancel(error).catch(() => undefined);
throw error;
}
async function fetchWithMatrixDispatcher(params: {
url: string;
init: MatrixDispatcherRequestInit;
@@ -280,21 +244,10 @@ export function createMatrixGuardedFetch(params: {
});
try {
await enforceDeclaredResponseSize({
response,
maxBytes: MATRIX_SDK_RESPONSE_MAX_BYTES,
createError: (length) =>
new Error(
`Matrix SDK response exceeds size limit (${length} bytes > ${MATRIX_SDK_RESPONSE_MAX_BYTES} bytes)`,
),
});
const body = await readResponseWithLimit(response, MATRIX_SDK_RESPONSE_MAX_BYTES, {
onOverflow: ({ maxBytes, size }) =>
new Error(`Matrix SDK response exceeds size limit (${size} bytes > ${maxBytes} bytes)`),
});
const body = await response.arrayBuffer();
return buildBufferedResponse({
source: response,
body: Uint8Array.from(body),
body,
url,
});
} finally {
@@ -365,15 +318,14 @@ export async function performMatrixRequest(params: {
try {
if (params.raw) {
if (params.maxBytes) {
await enforceDeclaredResponseSize({
response,
maxBytes: params.maxBytes,
createError: (length) =>
new MatrixMediaSizeLimitError(
`Matrix media exceeds configured size limit (${length} bytes > ${params.maxBytes} bytes)`,
),
});
const contentLength = response.headers.get("content-length");
if (params.maxBytes && contentLength) {
const length = parseMediaContentLength(contentLength);
if (length !== null && length > params.maxBytes) {
throw new MatrixMediaSizeLimitError(
`Matrix media exceeds configured size limit (${length} bytes > ${params.maxBytes} bytes)`,
);
}
}
const bytes = params.maxBytes
? await readResponseWithLimit(response, params.maxBytes, {
@@ -390,28 +342,11 @@ export async function performMatrixRequest(params: {
buffer: bytes,
};
}
const jsonMaxBytes = params.maxBytes ?? MATRIX_JSON_RESPONSE_MAX_BYTES;
await enforceDeclaredResponseSize({
response,
maxBytes: jsonMaxBytes,
createError: (length) =>
new Error(
`Matrix JSON response exceeds configured size limit (${length} bytes > ${jsonMaxBytes} bytes)`,
),
});
const buffer = await readResponseWithLimit(response, jsonMaxBytes, {
onOverflow: ({ maxBytes, size }) =>
new Error(
`Matrix JSON response exceeds configured size limit (${size} bytes > ${maxBytes} bytes)`,
),
chunkTimeoutMs: params.readIdleTimeoutMs,
onIdleTimeout: ({ chunkTimeoutMs }) =>
new Error(`Matrix JSON response stalled: no data received for ${chunkTimeoutMs}ms`),
});
const text = await response.text();
return {
response,
text: buffer.toString("utf8"),
buffer,
text,
buffer: Buffer.from(text, "utf8"),
};
} finally {
await release();

View File

@@ -1,6 +1,6 @@
{
"id": "mattermost",
"icon": "https://cdn.simpleicons.org/mattermost",
"icon": "https://cdn.simpleicons.org/mattermost/111111",
"activation": {
"onStartup": false
},

View File

@@ -89,7 +89,6 @@ async function expectPathMissing(targetPath: string): Promise<void> {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
resolveGlobalMap<string, unknown>(DREAMS_FILE_LOCKS_KEY).clear();
resolveGlobalMap<string, unknown>(NARRATIVE_SESSION_LOCKS_KEY).clear();
});
@@ -1193,7 +1192,6 @@ describe("generateAndAppendDreamNarrative", () => {
const storePath = path.join(sessionsDir, "sessions.json");
const orphanPath = path.join(sessionsDir, "orphan.jsonl");
const livePath = path.join(sessionsDir, "still-live.jsonl");
const normalTranscriptPath = path.join(sessionsDir, "normal-user-session.jsonl");
const updatedAt = Date.now();
await sessionStoreRuntimeModule.saveSessionStore(
storePath,
@@ -1210,25 +1208,25 @@ describe("generateAndAppendDreamNarrative", () => {
sessionId: "still-missing-non-dreaming",
updatedAt,
},
"agent:main:dreaming-narrative-corrupt-normal": {
sessionId: "normal-user-session",
updatedAt,
},
},
{ skipMaintenance: true },
);
await fs.writeFile(orphanPath, '{"runId":"dreaming-narrative-light-123"}\n', "utf-8");
await fs.writeFile(livePath, '{"runId":"dreaming-narrative-light-keep"}\n', "utf-8");
await fs.writeFile(normalTranscriptPath, '{"runId":"ordinary-user-session"}\n', "utf-8");
const oldDate = new Date(Date.now() - 600_000);
await fs.utimes(orphanPath, oldDate, oldDate);
await fs.utimes(livePath, oldDate, oldDate);
await fs.utimes(normalTranscriptPath, oldDate, oldDate);
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(sessionStoreRuntimeModule, "resolveStorePath").mockImplementation(((
_store: string | undefined,
{ agentId }: { agentId: string },
) => {
expect(agentId).toBe("main");
return storePath;
}) as typeof sessionStoreRuntimeModule.resolveStorePath);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("The repository whispered of forgotten endpoints.");
@@ -1245,13 +1243,11 @@ describe("generateAndAppendDreamNarrative", () => {
skipCache: true,
}) as Record<string, unknown>;
expect(updatedStore).not.toHaveProperty("agent:main:dreaming-narrative-light-1");
expect(updatedStore).not.toHaveProperty("agent:main:dreaming-narrative-corrupt-normal");
expect(updatedStore).toHaveProperty("agent:main:kept-session");
expect(updatedStore).toHaveProperty("agent:main:telegram:group:dreaming-narrative-room");
const sessionFiles = await fs.readdir(sessionsDir);
expect(sessionFiles.filter((file) => file.startsWith("orphan.jsonl.deleted."))).not.toEqual([]);
expect(sessionFiles).toContain("still-live.jsonl");
expect(sessionFiles).toContain("normal-user-session.jsonl");
expectLogIncludes(logger.info, "dreaming cleanup scrubbed");
});
@@ -1297,7 +1293,13 @@ describe("generateAndAppendDreamNarrative", () => {
vi.spyOn(runtimeConfigSnapshotModule, "getRuntimeConfig").mockReturnValue({
session: {},
} as never);
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
vi.spyOn(sessionStoreRuntimeModule, "resolveStorePath").mockImplementation(((
_store: string | undefined,
{ agentId }: { agentId: string },
) => {
expect(agentId).toBe("main");
return storePath;
}) as typeof sessionStoreRuntimeModule.resolveStorePath);
vi.spyOn(memoryCoreHostRuntimeCoreModule, "resolveStateDir").mockReturnValue(stateDir);
const subagent = createMockSubagent("A forgotten endpoint hummed in the dark.");

View File

@@ -14,7 +14,12 @@ import {
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
import { resolveStateDir } from "openclaw/plugin-sdk/memory-core-host-runtime-core";
import { getRuntimeConfig } from "openclaw/plugin-sdk/runtime-config-snapshot";
import { cleanupSessionLifecycleArtifacts } from "openclaw/plugin-sdk/session-store-runtime";
import { pathExists } from "openclaw/plugin-sdk/security-runtime";
import {
loadSessionStore,
resolveStorePath,
updateSessionStore,
} from "openclaw/plugin-sdk/session-store-runtime";
import { readDreamsFile, resolveDreamsPath, updateDreamsFile } from "./dreaming-dreams-file.js";
// ── Types ──────────────────────────────────────────────────────────────
@@ -103,6 +108,7 @@ const NARRATIVE_MESSAGE_SETTLE_DELAYS_MS = [50, 150, 300, 750] as const;
const DREAMING_SESSION_KEY_PREFIX = "dreaming-narrative-";
const DREAMING_TRANSCRIPT_RUN_MARKER = '"runId":"dreaming-narrative-';
const DREAMING_ORPHAN_MIN_AGE_MS = 300_000;
const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry";
@@ -770,6 +776,80 @@ export async function appendNarrativeEntry(params: {
// ── Orchestrator ───────────────────────────────────────────────────────
function normalizeComparablePath(pathname: string): string {
return process.platform === "win32" ? pathname.toLowerCase() : pathname;
}
async function normalizeSessionFileForComparison(params: {
sessionsDir: string;
sessionFile: string;
}): Promise<string | null> {
const trimmed = params.sessionFile.trim();
if (!trimmed) {
return null;
}
const resolved = path.isAbsolute(trimmed) ? trimmed : path.resolve(params.sessionsDir, trimmed);
try {
return normalizeComparablePath(await fs.realpath(resolved));
} catch {
return normalizeComparablePath(path.resolve(resolved));
}
}
function isDreamingSessionStoreKey(sessionKey: string): boolean {
const firstSeparator = sessionKey.indexOf(":");
if (firstSeparator < 0) {
return sessionKey.startsWith(DREAMING_SESSION_KEY_PREFIX);
}
const secondSeparator = sessionKey.indexOf(":", firstSeparator + 1);
const sessionSegment = secondSeparator < 0 ? sessionKey : sessionKey.slice(secondSeparator + 1);
return sessionSegment.startsWith(DREAMING_SESSION_KEY_PREFIX);
}
// A dreaming store row is reclaimable once its narrative run is finished. The
// happy path deletes the session in `finally`, but when `deleteSession` throws
// (e.g. request-scoped subagent runtime) the row is left behind referencing a
// still-present transcript, so the missing-transcript check alone never reaps
// it and the session lingers in the sidebar forever (issue #88322). Reclaim a
// dreaming row when its transcript is missing, or when the transcript has aged
// past the orphan threshold (a live narrative refreshes its transcript well
// within that window, so active runs are never reaped).
async function isReclaimableDreamingStoreEntry(
normalizedSessionFile: string | null,
): Promise<boolean> {
if (!normalizedSessionFile || !(await pathExists(normalizedSessionFile))) {
return true;
}
try {
const stat = await fs.stat(normalizedSessionFile);
return Date.now() - stat.mtimeMs >= DREAMING_ORPHAN_MIN_AGE_MS;
} catch {
return true;
}
}
async function normalizeSessionEntryPathForComparison(params: {
sessionsDir: string;
entry: { sessionFile?: string; sessionId?: string } | undefined;
}): Promise<string | null> {
const sessionFile = typeof params.entry?.sessionFile === "string" ? params.entry.sessionFile : "";
if (sessionFile) {
return normalizeSessionFileForComparison({
sessionsDir: params.sessionsDir,
sessionFile,
});
}
const sessionId =
typeof params.entry?.sessionId === "string" ? params.entry.sessionId.trim() : "";
if (!SAFE_SESSION_ID_RE.test(sessionId)) {
return null;
}
return normalizeSessionFileForComparison({
sessionsDir: params.sessionsDir,
sessionFile: `${sessionId}.jsonl`,
});
}
async function scrubDreamingNarrativeArtifacts(logger: Logger): Promise<void> {
const cfg = getRuntimeConfig();
const agentsDir = path.join(resolveStateDir(), "agents");
@@ -788,20 +868,112 @@ async function scrubDreamingNarrativeArtifacts(logger: Logger): Promise<void> {
continue;
}
const storePath = resolveStorePath(cfg.session?.store, { agentId: agentEntry.name });
const sessionsDir = path.dirname(storePath);
let store: Record<string, { sessionFile?: string; sessionId?: string } | undefined>;
try {
const result = await cleanupSessionLifecycleArtifacts({
agentId: agentEntry.name,
archiveRemovedEntryTranscripts: false,
sessionStore: cfg.session?.store,
sessionKeySegmentPrefix: DREAMING_SESSION_KEY_PREFIX,
transcriptContentMarker: DREAMING_TRANSCRIPT_RUN_MARKER,
orphanTranscriptMinAgeMs: DREAMING_ORPHAN_MIN_AGE_MS,
});
prunedEntries += result.removedEntries;
archivedOrphans += result.archivedTranscriptArtifacts;
store = loadSessionStore(storePath) as Record<
string,
{ sessionFile?: string; sessionId?: string } | undefined
>;
} catch {
continue;
}
const referencedSessionFiles = new Set<string>();
let needsStoreUpdate = false;
for (const [key, entry] of Object.entries(store)) {
const normalizedSessionFile = await normalizeSessionEntryPathForComparison({
sessionsDir,
entry,
});
if (normalizedSessionFile) {
referencedSessionFiles.add(normalizedSessionFile);
}
if (!isDreamingSessionStoreKey(key)) {
continue;
}
if (await isReclaimableDreamingStoreEntry(normalizedSessionFile)) {
needsStoreUpdate = true;
}
}
if (needsStoreUpdate) {
referencedSessionFiles.clear();
prunedEntries += await updateSessionStore(storePath, async (lockedStore) => {
let prunedForAgent = 0;
for (const [key, entry] of Object.entries(lockedStore)) {
const normalizedSessionFile = await normalizeSessionEntryPathForComparison({
sessionsDir,
entry,
});
if (!isDreamingSessionStoreKey(key)) {
if (normalizedSessionFile) {
referencedSessionFiles.add(normalizedSessionFile);
}
continue;
}
if (await isReclaimableDreamingStoreEntry(normalizedSessionFile)) {
// Drop the row and leave the transcript unreferenced so the orphan
// transcript pass below archives the aged-out (or missing) file.
delete lockedStore[key];
prunedForAgent += 1;
continue;
}
if (normalizedSessionFile) {
referencedSessionFiles.add(normalizedSessionFile);
}
}
return prunedForAgent;
});
}
let sessionFiles: Dirent[];
try {
sessionFiles = await fs.readdir(sessionsDir, { withFileTypes: true });
} catch {
continue;
}
for (const fileEntry of sessionFiles) {
if (!fileEntry.isFile() || !fileEntry.name.endsWith(".jsonl")) {
continue;
}
const transcriptPath = path.join(sessionsDir, fileEntry.name);
const normalizedTranscriptPath =
(await normalizeSessionFileForComparison({
sessionsDir,
sessionFile: fileEntry.name,
})) ?? normalizeComparablePath(transcriptPath);
if (referencedSessionFiles.has(normalizedTranscriptPath)) {
continue;
}
let stat;
try {
stat = await fs.stat(transcriptPath);
} catch {
continue;
}
if (Date.now() - stat.mtimeMs < DREAMING_ORPHAN_MIN_AGE_MS) {
continue;
}
let content;
try {
content = await fs.readFile(transcriptPath, "utf-8");
} catch {
continue;
}
if (!content.includes(DREAMING_TRANSCRIPT_RUN_MARKER)) {
continue;
}
const archivedPath = `${transcriptPath}.deleted.${Date.now()}`;
try {
await fs.rename(transcriptPath, archivedPath);
archivedOrphans += 1;
} catch {
// best-effort scrubber
}
}
}
if (prunedEntries > 0 || archivedOrphans > 0) {

View File

@@ -5,7 +5,9 @@ import fs from "node:fs/promises";
import path from "node:path";
import {
buildSessionEntry,
listSessionTranscriptCorpusEntriesForAgent,
listSessionFilesForAgent,
loadSessionTranscriptClassificationForAgent,
normalizeSessionTranscriptPathForComparison,
parseUsageCountedSessionIdFromFileName,
sessionPathForFile,
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
@@ -846,16 +848,25 @@ async function collectSessionIngestionBatches(params: {
sessionPath: string;
}> = [];
for (const agentId of agentIds) {
for (const entry of await listSessionTranscriptCorpusEntriesForAgent(agentId)) {
const absolutePath = entry.sessionFile;
const files = await listSessionFilesForAgent(agentId);
const transcriptClassification =
files.length > 0
? loadSessionTranscriptClassificationForAgent(agentId)
: {
dreamingNarrativeTranscriptPaths: new Set<string>(),
cronRunTranscriptPaths: new Set<string>(),
};
for (const absolutePath of files) {
if (isCheckpointSessionTranscriptPath(absolutePath)) {
continue;
}
const normalizedPath = normalizeSessionTranscriptPathForComparison(absolutePath);
sessionFiles.push({
agentId,
absolutePath,
generatedByDreamingNarrative: entry.generatedByDreamingNarrative === true,
generatedByCronRun: entry.generatedByCronRun === true,
generatedByDreamingNarrative:
transcriptClassification.dreamingNarrativeTranscriptPaths.has(normalizedPath),
generatedByCronRun: transcriptClassification.cronRunTranscriptPaths.has(normalizedPath),
sessionPath: sessionPathForFile(absolutePath),
});
}

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { DatabaseSync } from "node:sqlite";
import { emitSessionTranscriptUpdate } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
resolveSessionTranscriptsDirForAgent,
type OpenClawConfig,
@@ -14,10 +13,6 @@ import type {
MemorySyncParams,
MemorySyncProgressUpdate,
} from "openclaw/plugin-sdk/memory-core-host-engine-storage";
import {
clearConfigCache,
clearRuntimeConfigSnapshot,
} from "openclaw/plugin-sdk/runtime-config-snapshot";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MemoryManagerSyncOps } from "./manager-sync-ops.js";
@@ -38,25 +33,6 @@ type SyncParams = {
progress?: (update: MemorySyncProgressUpdate) => void;
};
type MemorySessionTranscriptUpdate = {
agentId?: string;
sessionFile?: string;
sessionKey?: string;
target?: {
agentId: string;
sessionId: string;
sessionKey: string;
};
};
type MemoryTranscriptUpdateSubscriber = (
listener: (update: MemorySessionTranscriptUpdate) => void,
) => () => void;
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
type SourceStateRow = { path: string; hash: string; mtime: number; size: number };
class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
@@ -106,7 +82,6 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
readonly syncCalls: SyncParams[] = [];
readonly indexedPaths: string[] = [];
readonly indexedContents: string[] = [];
constructor(sourceRows: SourceStateRow[]) {
super();
@@ -136,56 +111,10 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
return Array.from(this.sessionsDirtyFiles);
}
getPendingSessionTargets(): MemorySyncParams["sessions"] {
return Array.from(this.sessionPendingTargets.values());
}
getPendingSessionFiles(): string[] {
return Array.from(this.sessionPendingFiles);
}
addPendingSessionTarget(target: NonNullable<MemorySyncParams["sessions"]>[number]): void {
this.sessionPendingTargets.set(
[target.agentId ?? "", target.sessionId, target.sessionKey ?? ""].join("\0"),
target,
);
}
async processPendingSessionDeltas(): Promise<void> {
await (
this as unknown as {
processSessionDeltaBatch: () => Promise<void>;
}
).processSessionDeltaBatch();
}
async combineTargetSessionFilesForTest(params: {
sessions?: MemorySyncParams["sessions"];
sessionFiles?: string[];
}): Promise<Set<string> | null> {
return await (
this as unknown as {
combineTargetSessionFiles: (params: {
sessions?: MemorySyncParams["sessions"];
sessionFiles?: string[];
}) => Promise<Set<string> | null>;
}
).combineTargetSessionFiles(params);
}
isSessionsDirty(): boolean {
return this.sessionsDirty;
}
startTranscriptListener(): void {
this.ensureSessionListener();
}
stopTranscriptListener(): void {
this.sessionUnsubscribe?.();
this.sessionUnsubscribe = null;
}
protected computeProviderKey(): string {
return "test";
}
@@ -218,10 +147,9 @@ class SessionStartupCatchupHarness extends MemoryManagerSyncOps {
protected async indexFile(
entry: MemoryIndexEntry,
options: { source: MemorySource; content?: string },
_options: { source: MemorySource; content?: string },
): Promise<void> {
this.indexedPaths.push(entry.path);
this.indexedContents.push(options.content ?? "");
}
}
@@ -234,11 +162,7 @@ describe("session startup catch-up", () => {
});
afterEach(async () => {
vi.clearAllTimers();
vi.useRealTimers();
vi.unstubAllEnvs();
clearRuntimeConfigSnapshot();
clearConfigCache();
await fs.rm(stateDir, { recursive: true, force: true });
});
@@ -432,268 +356,4 @@ describe("session startup catch-up", () => {
expect(harness.indexedPaths).toEqual([]);
});
it("resolves identity-targeted delta sync through a custom session store", async () => {
const storeDir = path.join(stateDir, "custom-sessions");
const sessionFile = path.join(storeDir, "custom-thread.jsonl");
const storePath = path.join(storeDir, "sessions.json");
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(storeDir, { recursive: true });
await fs.writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: { role: "user", content: "custom store target" },
}) + "\n",
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify({
"agent:main:chat:custom": {
sessionFile: "custom-thread.jsonl",
sessionId: "custom-thread",
},
}),
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
(harness as unknown as { settings: ResolvedMemorySearchConfig }).settings.sync.sessions = {
deltaBytes: 1,
deltaMessages: 1,
postCompactionForce: true,
};
harness.addPendingSessionTarget({
agentId: "main",
sessionId: "custom-thread",
sessionKey: "agent:main:chat:custom",
});
await harness.processPendingSessionDeltas();
await Promise.resolve();
expect(harness.getDirtySessionFiles()).toEqual([sessionFile]);
expect(harness.syncCalls).toEqual([{ reason: "session-delta" }]);
});
it("keeps explicit custom-store session file targets at the sync gate", async () => {
const storeDir = path.join(stateDir, "custom-sessions");
const sessionFile = path.join(storeDir, "explicit-target.jsonl");
const storePath = path.join(storeDir, "sessions.json");
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(storeDir, { recursive: true });
await fs.writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: { role: "user", content: "explicit target" },
}) + "\n",
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify({
"agent:main:chat:explicit-target": {
sessionFile: "explicit-target.jsonl",
sessionId: "explicit-target",
},
}),
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
await expect(
harness.combineTargetSessionFilesForTest({ sessionFiles: [sessionFile] }),
).resolves.toEqual(new Set([sessionFile]));
});
it("preserves generated-session classification during targeted custom-store indexing", async () => {
const storeDir = path.join(stateDir, "custom-sessions");
const sessionFile = path.join(storeDir, "cron-thread.jsonl");
const otherSessionFile = path.join(storeDir, "other-thread.jsonl");
const storePath = path.join(storeDir, "sessions.json");
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(storeDir, { recursive: true });
await fs.writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: { role: "assistant", content: "Internal cron output that must stay out." },
}) + "\n",
"utf-8",
);
await fs.writeFile(
otherSessionFile,
JSON.stringify({
type: "message",
message: { role: "user", content: "Other custom-store content" },
}) + "\n",
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify({
"agent:main:cron:job-1:run:run-1": {
sessionFile: "cron-thread.jsonl",
sessionId: "cron-thread",
},
"agent:main:chat:other": {
sessionFile: "other-thread.jsonl",
sessionId: "other-thread",
},
}),
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
await (
harness as unknown as {
syncSessionFiles: (params: {
needsFullReindex: boolean;
targetSessionFiles: string[];
}) => Promise<void>;
}
).syncSessionFiles({
needsFullReindex: false,
targetSessionFiles: [sessionFile],
});
expect(harness.indexedPaths).toEqual(["sessions/cron-thread.jsonl"]);
expect(harness.indexedContents).toEqual([""]);
});
it("queues transcript update identity without requiring a session file", async () => {
vi.useFakeTimers();
const harness = new SessionStartupCatchupHarness([]);
const originalSubscriber = (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
let transcriptListener: ((update: MemorySessionTranscriptUpdate) => void) | undefined;
(globalThis as Record<symbol, unknown>)[MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY] = ((
listener,
) => {
transcriptListener = listener;
return () => {
if (transcriptListener === listener) {
transcriptListener = undefined;
}
};
}) satisfies MemoryTranscriptUpdateSubscriber;
harness.startTranscriptListener();
try {
transcriptListener?.({
target: {
agentId: "main",
sessionId: "thread",
sessionKey: "agent:main:thread",
},
});
expect(harness.getPendingSessionTargets()).toEqual([
{ agentId: "main", sessionId: "thread", sessionKey: "agent:main:thread" },
]);
} finally {
harness.stopTranscriptListener();
if (originalSubscriber === undefined) {
delete (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
} else {
(globalThis as Record<symbol, unknown>)[MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY] =
originalSubscriber;
}
}
});
it("keeps canonical path transcript update compatibility", async () => {
vi.useFakeTimers();
const session = await writeSessionFile("thread.jsonl");
const harness = new SessionStartupCatchupHarness([]);
harness.startTranscriptListener();
emitSessionTranscriptUpdate({
sessionFile: session.filePath,
sessionKey: "agent:main:thread",
});
expect(harness.getPendingSessionFiles()).toEqual([session.filePath]);
expect(harness.getPendingSessionTargets()).toEqual([]);
harness.stopTranscriptListener();
});
it("queues file-only transcript updates from a custom session store", async () => {
vi.useFakeTimers();
const storeDir = path.join(stateDir, "custom-sessions");
const sessionFile = path.join(storeDir, "custom-update.jsonl");
const storePath = path.join(storeDir, "sessions.json");
const configPath = path.join(stateDir, "openclaw.json");
await fs.mkdir(storeDir, { recursive: true });
await fs.writeFile(
sessionFile,
JSON.stringify({
type: "message",
message: { role: "user", content: "custom update" },
}) + "\n",
"utf-8",
);
await fs.writeFile(
storePath,
JSON.stringify({
"agent:main:chat:custom-update": {
sessionFile: "custom-update.jsonl",
sessionId: "custom-update",
},
}),
"utf-8",
);
await fs.writeFile(configPath, JSON.stringify({ session: { store: storePath } }), "utf-8");
vi.stubEnv("OPENCLAW_CONFIG_PATH", configPath);
clearRuntimeConfigSnapshot();
clearConfigCache();
const harness = new SessionStartupCatchupHarness([]);
harness.startTranscriptListener();
emitSessionTranscriptUpdate({
sessionFile,
sessionKey: "agent:main:chat:custom-update",
});
await Promise.resolve();
expect(harness.getPendingSessionFiles()).toEqual([sessionFile]);
expect(harness.getPendingSessionTargets()).toEqual([]);
harness.stopTranscriptListener();
});
it("prefers transcript update path compatibility before identity", async () => {
vi.useFakeTimers();
const session = await writeSessionFile("thread.jsonl");
const harness = new SessionStartupCatchupHarness([]);
harness.startTranscriptListener();
emitSessionTranscriptUpdate({
sessionFile: session.filePath,
target: {
agentId: "main",
sessionId: "identity-target",
sessionKey: "agent:main:identity-target",
},
});
expect(harness.getPendingSessionFiles()).toEqual([session.filePath]);
expect(harness.getPendingSessionTargets()).toEqual([]);
harness.stopTranscriptListener();
});
});

View File

@@ -21,11 +21,9 @@ import {
isSessionArchiveArtifactName,
isUsageCountedSessionTranscriptFileName,
listSessionFilesForAgent,
listSessionTranscriptCorpusEntriesForAgent,
parseCanonicalSessionSyncTargetFromPath,
resolveSessionFileForSyncTarget,
sessionPathForFile,
type SessionTranscriptCorpusEntry,
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
buildFileEntry,
@@ -172,27 +170,9 @@ const IGNORED_MEMORY_WATCH_DIR_NAMES = new Set([
]);
const log = createSubsystemLogger("memory");
const MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY = Symbol.for(
"openclaw.memoryCore.sessionTranscriptUpdateSubscriber",
);
const TEST_MEMORY_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryWatchFactory");
const TEST_MEMORY_NATIVE_WATCH_FACTORY_KEY = Symbol.for("openclaw.test.memoryNativeWatchFactory");
type MemorySessionTranscriptUpdate = {
agentId?: string;
sessionFile?: string;
sessionKey?: string;
target?: {
agentId: string;
sessionId: string;
sessionKey: string;
};
};
type MemoryTranscriptUpdateSubscriber = (
listener: (update: MemorySessionTranscriptUpdate) => void,
) => () => void;
function memoryTableExists(db: DatabaseSync, tableName: string): boolean {
return Boolean(
db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName),
@@ -211,18 +191,6 @@ type LinuxMemoryDirectoryWatcher = {
ino: number;
};
function subscribeMemorySessionTranscriptUpdates(
listener: (update: MemorySessionTranscriptUpdate) => void,
): () => void {
const injected = (globalThis as Record<symbol, unknown>)[
MEMORY_CORE_TRANSCRIPT_UPDATE_SUBSCRIBER_KEY
];
if (typeof injected === "function") {
return (injected as MemoryTranscriptUpdateSubscriber)(listener);
}
return onSessionTranscriptUpdate(listener);
}
function resolveMemoryWatchFactory(): typeof chokidar.watch {
if (process.env.VITEST === "true" || process.env.NODE_ENV === "test") {
const override = (globalThis as Record<PropertyKey, unknown>)[TEST_MEMORY_WATCH_FACTORY_KEY];
@@ -1454,16 +1422,12 @@ export abstract class MemoryManagerSyncOps {
if (!this.sources.has("sessions") || this.sessionUnsubscribe) {
return;
}
this.sessionUnsubscribe = subscribeMemorySessionTranscriptUpdates((update) => {
this.sessionUnsubscribe = onSessionTranscriptUpdate((update) => {
if (this.closed) {
return;
}
const sessionFile = update.sessionFile;
if (sessionFile && isSessionArchiveArtifactName(path.basename(sessionFile))) {
return;
}
if (sessionFile && this.isSessionFileForAgent(sessionFile)) {
this.scheduleSessionDirty(sessionFile);
if (!this.isSessionFileForAgent(sessionFile)) {
return;
}
const target = this.resolveSessionTranscriptUpdateSyncTarget(update);
@@ -1471,22 +1435,10 @@ export abstract class MemoryManagerSyncOps {
this.scheduleSessionDirty(target);
return;
}
if (sessionFile) {
void this.scheduleCorpusSessionFileDirty(sessionFile).catch((err: unknown) => {
log.warn(`memory session corpus update failed: ${String(err)}`);
});
}
this.scheduleSessionDirty(sessionFile);
});
}
private async scheduleCorpusSessionFileDirty(sessionFile: string): Promise<void> {
const resolvedSessionFile = path.resolve(sessionFile);
const corpusEntries = await listSessionTranscriptCorpusEntriesForAgent(this.agentId);
if (corpusEntries.some((entry) => path.resolve(entry.sessionFile) === resolvedSessionFile)) {
this.scheduleSessionDirty(resolvedSessionFile);
}
}
protected ensureSessionStartupCatchup(): void {
if (!this.sources.has("sessions")) {
return;
@@ -1579,7 +1531,7 @@ export abstract class MemoryManagerSyncOps {
const pendingTargets = Array.from(this.sessionPendingTargets.values());
this.sessionPendingFiles.clear();
this.sessionPendingTargets.clear();
pending.push(...Array.from(await this.resolveSessionFilesForSyncTargets(pendingTargets)));
pending.push(...Array.from(this.resolveSessionFilesForSyncTargets(pendingTargets)));
let shouldSync = false;
for (const sessionFile of pending) {
// Usage-counted session archives (`.jsonl.reset.<iso>` and
@@ -1751,30 +1703,13 @@ export abstract class MemoryManagerSyncOps {
return resolvedFile.startsWith(`${resolvedDir}${path.sep}`);
}
private resolveSessionTranscriptUpdateSyncTarget(
update: MemorySessionTranscriptUpdate,
): MemorySessionSyncTarget | null {
if (update.sessionFile && isSessionArchiveArtifactName(path.basename(update.sessionFile))) {
return null;
}
if (update.target) {
const agentId = update.target.agentId.trim();
const sessionId = update.target.sessionId.trim();
const sessionKey = update.target.sessionKey.trim();
if (!agentId || !sessionId || normalizeAgentId(agentId) !== normalizeAgentId(this.agentId)) {
return null;
}
return {
agentId,
sessionId,
...(sessionKey ? { sessionKey } : {}),
};
}
if (!update.sessionFile) {
return null;
}
private resolveSessionTranscriptUpdateSyncTarget(update: {
agentId?: string;
sessionFile: string;
sessionKey?: string;
}): MemorySessionSyncTarget | null {
const parsed = parseCanonicalSessionSyncTargetFromPath(update.sessionFile);
if (!parsed) {
if (!parsed || isSessionArchiveArtifactName(path.basename(update.sessionFile))) {
return null;
}
const agentId = update.agentId?.trim() || parsed.agentId;
@@ -1789,15 +1724,11 @@ export abstract class MemoryManagerSyncOps {
};
}
private normalizeTargetSessionFiles(
sessionFiles?: string[],
corpusEntries: readonly SessionTranscriptCorpusEntry[] = [],
): Set<string> | null {
private normalizeTargetSessionFiles(sessionFiles?: string[]): Set<string> | null {
if (!sessionFiles || sessionFiles.length === 0) {
return null;
}
const normalized = new Set<string>();
const corpusPaths = new Set(corpusEntries.map((entry) => path.resolve(entry.sessionFile)));
for (const sessionFile of sessionFiles) {
const trimmed = sessionFile.trim();
if (!trimmed) {
@@ -1809,10 +1740,6 @@ export abstract class MemoryManagerSyncOps {
parseCanonicalSessionSyncTargetFromPath(resolved)
) {
normalized.add(resolved);
continue;
}
if (corpusPaths.has(resolved)) {
normalized.add(resolved);
}
}
return normalized.size > 0 ? normalized : null;
@@ -1842,36 +1769,11 @@ export abstract class MemoryManagerSyncOps {
return normalized.size > 0 ? normalized : null;
}
private async resolveSessionFilesForSyncTargets(
private resolveSessionFilesForSyncTargets(
sessions?: Iterable<MemorySessionSyncTarget> | null,
knownCorpusEntries?: readonly SessionTranscriptCorpusEntry[],
): Promise<Set<string>> {
): Set<string> {
const files = new Set<string>();
const targets = Array.from(sessions ?? []);
if (targets.length === 0) {
return files;
}
const corpusEntries =
knownCorpusEntries ?? (await listSessionTranscriptCorpusEntriesForAgent(this.agentId));
for (const session of targets) {
const sessionKey = session.sessionKey?.trim();
let matchedCorpusEntry = false;
for (const entry of corpusEntries) {
if (normalizeAgentId(entry.agentId) !== normalizeAgentId(this.agentId)) {
continue;
}
if (entry.sessionId !== session.sessionId) {
continue;
}
if (sessionKey && entry.sessionKey !== sessionKey) {
continue;
}
files.add(path.resolve(entry.sessionFile));
matchedCorpusEntry = true;
}
if (matchedCorpusEntry) {
continue;
}
for (const session of sessions ?? []) {
const resolved = resolveSessionFileForSyncTarget(session, this.agentId);
if (!resolved || normalizeAgentId(resolved.agentId) !== normalizeAgentId(this.agentId)) {
continue;
@@ -1887,18 +1789,16 @@ export abstract class MemoryManagerSyncOps {
return files;
}
private async combineTargetSessionFiles(params: {
private combineTargetSessionFiles(params: {
sessions?: MemorySessionSyncTarget[];
sessionFiles?: string[];
}): Promise<Set<string> | null> {
}): Set<string> | null {
const files = new Set<string>();
const corpusEntries = await listSessionTranscriptCorpusEntriesForAgent(this.agentId);
for (const file of this.normalizeTargetSessionFiles(params.sessionFiles, corpusEntries) ?? []) {
for (const file of this.normalizeTargetSessionFiles(params.sessionFiles) ?? []) {
files.add(file);
}
for (const file of await this.resolveSessionFilesForSyncTargets(
for (const file of this.resolveSessionFilesForSyncTargets(
this.normalizeTargetSessions(params.sessions)?.values(),
corpusEntries,
)) {
files.add(file);
}
@@ -2113,16 +2013,12 @@ export abstract class MemoryManagerSyncOps {
? this.db.prepare(`DELETE FROM ${FTS_TABLE} WHERE path = ? AND source = ?`)
: null;
const corpusEntries = await listSessionTranscriptCorpusEntriesForAgent(this.agentId);
const targetSessionFiles = params.needsFullReindex
? null
: this.normalizeTargetSessionFiles(params.targetSessionFiles, corpusEntries);
const corpusEntryByPath = new Map<string, SessionTranscriptCorpusEntry>(
corpusEntries.map((entry) => [entry.sessionFile, entry]),
);
: this.normalizeTargetSessionFiles(params.targetSessionFiles);
const files = targetSessionFiles
? Array.from(targetSessionFiles)
: corpusEntries.map((entry) => entry.sessionFile);
: await listSessionFilesForAgent(this.agentId);
const sessionPlan = resolveMemorySessionSyncPlan({
needsFullReindex: params.needsFullReindex,
files,
@@ -2218,17 +2114,7 @@ export abstract class MemoryManagerSyncOps {
}
return null;
}
const corpusEntry = corpusEntryByPath.get(absPath);
const entry = await buildSessionEntry(
absPath,
corpusEntry
? {
generatedByDreamingNarrative:
corpusEntry.generatedByDreamingNarrative === true,
generatedByCronRun: corpusEntry.generatedByCronRun === true,
}
: undefined,
);
const entry = await buildSessionEntry(absPath);
if (!entry) {
if (params.progress) {
params.progress.completed += 1;
@@ -2298,16 +2184,7 @@ export abstract class MemoryManagerSyncOps {
}
return;
}
const corpusEntry = corpusEntryByPath.get(absPath);
const entry = await buildSessionEntry(
absPath,
corpusEntry
? {
generatedByDreamingNarrative: corpusEntry.generatedByDreamingNarrative === true,
generatedByCronRun: corpusEntry.generatedByCronRun === true,
}
: undefined,
);
const entry = await buildSessionEntry(absPath);
if (!entry) {
if (params.progress) {
params.progress.completed += 1;
@@ -2418,7 +2295,7 @@ export abstract class MemoryManagerSyncOps {
}
const vectorReady = await this.ensureVectorReady();
const meta = this.readMeta();
const targetSessionFiles = await this.combineTargetSessionFiles({
const targetSessionFiles = this.combineTargetSessionFiles({
sessions: params?.sessions,
sessionFiles: params?.sessionFiles,
});

View File

@@ -34,7 +34,6 @@ vi.mock("openclaw/plugin-sdk/memory-core-host-engine-qmd", () => {
isSessionArchiveArtifactName: (fileName: string) => /\.jsonl\.(reset|deleted)\./.test(fileName),
isUsageCountedSessionTranscriptFileName: (fileName: string) => fileName.endsWith(".jsonl"),
listSessionFilesForAgent: vi.fn(async () => []),
listSessionTranscriptCorpusEntriesForAgent: vi.fn(async () => []),
parseCanonicalSessionSyncTargetFromPath: (filePath: string) => ({
agentId: "main",
sessionId: basename(filePath).replace(/\.jsonl$/, ""),

View File

@@ -2721,314 +2721,6 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("aborts the in-flight qmd search subprocess when the caller signal aborts", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
// The query child never closes on its own so the only way the search can
// settle is the caller-owned abort signal killing the subprocess.
let queryChildKill: ReturnType<typeof vi.fn> | undefined;
let queryChild: MockChild | undefined;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "query") {
const child = createMockChild({ autoClose: false });
const kill = vi.fn(() => {
// Mirror a real child exiting after SIGKILL so the close handler runs.
queueMicrotask(() => child.emit("close", null));
});
Object.assign(child, { kill });
queryChildKill = kill;
queryChild = child;
return child;
}
return createMockChild();
});
const { manager } = await createManager();
const controller = new AbortController();
const searchPromise = manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
});
searchPromise.catch(() => undefined);
await waitUntil(() => queryChildKill !== undefined);
expect(queryChild).toBeDefined();
controller.abort(new Error("memory_search timed out after 15s"));
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
expect(queryChildKill).toHaveBeenCalledWith("SIGKILL");
await manager.close();
});
it("rejects the qmd search before spawning when the caller signal is already aborted", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager();
const controller = new AbortController();
controller.abort(new Error("memory_search timed out after 15s"));
const callsBefore = spawnMock.mock.calls.filter(
(call: unknown[]) => (call[1] as string[])?.[0] === "query",
).length;
await expect(
manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
}),
).rejects.toThrow("memory_search timed out after 15s");
const callsAfter = spawnMock.mock.calls.filter(
(call: unknown[]) => (call[1] as string[])?.[0] === "query",
).length;
expect(callsAfter).toBe(callsBefore);
await manager.close();
});
it("aborts the in-flight grouped qmd search subprocess when the caller signal aborts", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
sessions: { enabled: true },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
// Mixed memory/session sources route the search through
// runQueryAcrossCollectionGroups. The first grouped search child never
// closes on its own, so the only way the search can settle is the
// caller-owned abort signal reaching the grouped subprocess and killing it.
let groupedChildKill: ReturnType<typeof vi.fn> | undefined;
let groupedChild: MockChild | undefined;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
emitAndClose(
child,
"stdout",
"-c, --collection <name> Filter by one or more collections",
);
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
const kill = vi.fn(() => {
// Mirror a real child exiting after SIGKILL so the close handler runs.
queueMicrotask(() => child.emit("close", null));
});
Object.assign(child, { kill });
if (!groupedChildKill) {
groupedChildKill = kill;
groupedChild = child;
}
return child;
}
return createMockChild();
});
const { manager } = await createManager();
const controller = new AbortController();
const searchPromise = manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
});
searchPromise.catch(() => undefined);
await waitUntil(() => groupedChildKill !== undefined);
expect(groupedChild).toBeDefined();
controller.abort(new Error("memory_search timed out after 15s"));
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
expect(groupedChildKill).toHaveBeenCalledWith("SIGKILL");
await manager.close();
});
it("aborts the multi-collection capability probe without caching a failure", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [
{ path: workspaceDir, pattern: "**/*.md", name: "workspace" },
{ path: path.join(workspaceDir, "notes"), pattern: "**/*.md", name: "notes" },
],
},
},
} as OpenClawConfig;
let helpChildKill: ReturnType<typeof vi.fn> | undefined;
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "--help") {
const child = createMockChild({ autoClose: false });
const kill = vi.fn(() => {
queueMicrotask(() => child.emit("close", null));
});
Object.assign(child, { kill });
helpChildKill = kill;
return child;
}
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "[]");
return child;
}
return createMockChild();
});
const { manager } = await createManager();
const controller = new AbortController();
const searchPromise = manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
});
searchPromise.catch(() => undefined);
await waitUntil(() => helpChildKill !== undefined);
controller.abort(new Error("memory_search timed out after 15s"));
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
expect(helpChildKill).toHaveBeenCalledWith("SIGKILL");
expect(
spawnMock.mock.calls.some((call: unknown[]) => (call[1] as string[])?.[0] === "search"),
).toBe(false);
await manager.close();
});
it("aborts the in-flight mcporter search subprocess when the caller signal aborts", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
// The mcporter `call` child never closes on its own, so the only way the
// search can settle is the caller-owned abort signal reaching the mcporter
// subprocess via runMcporter -> runCliCommand and killing it.
let mcporterCallKill: ReturnType<typeof vi.fn> | undefined;
let mcporterCallChild: MockChild | undefined;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
if (isMcporterCommand(cmd) && args[0] === "call") {
const child = createMockChild({ autoClose: false });
const kill = vi.fn(() => {
// Mirror a real child exiting after SIGKILL so the close handler runs.
queueMicrotask(() => child.emit("close", null));
});
Object.assign(child, { kill });
mcporterCallKill = kill;
mcporterCallChild = child;
return child;
}
return createMockChild();
});
const { manager } = await createManager();
const controller = new AbortController();
const searchPromise = manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
});
searchPromise.catch(() => undefined);
await waitUntil(() => mcporterCallKill !== undefined);
expect(mcporterCallChild).toBeDefined();
controller.abort(new Error("memory_search timed out after 15s"));
await expect(searchPromise).rejects.toThrow("memory_search timed out after 15s");
expect(mcporterCallKill).toHaveBeenCalledWith("SIGKILL");
await manager.close();
});
it("rejects the mcporter search before spawning a call subprocess when the caller signal is already aborted", async () => {
cfg = {
...cfg,
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
searchMode: "query",
update: { interval: "0s", debounceMs: 60_000, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
mcporter: { enabled: true, serverName: "qmd", startDaemon: false },
},
},
} as OpenClawConfig;
spawnMock.mockImplementation((cmd: string, args: string[]) => {
const child = createMockChild({ autoClose: false });
if (isMcporterCommand(cmd) && args[0] === "call") {
emitAndClose(child, "stdout", JSON.stringify({ results: [] }));
return child;
}
emitAndClose(child, "stdout", "[]");
return child;
});
const { manager } = await createManager();
const controller = new AbortController();
controller.abort(new Error("memory_search timed out after 15s"));
const callsBefore = spawnMock.mock.calls.filter(
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call",
).length;
await expect(
manager.search("test", {
sessionKey: "agent:main:slack:dm:u123",
signal: controller.signal,
}),
).rejects.toThrow("memory_search timed out after 15s");
const callsAfter = spawnMock.mock.calls.filter(
(call: unknown[]) => isMcporterCommand(call[0]) && (call[1] as string[])?.[0] === "call",
).length;
expect(callsAfter).toBe(callsBefore);
await manager.close();
});
it("does not pass --no-rerank to direct query fallback from search mode", async () => {
cfg = {
...cfg,

View File

@@ -25,14 +25,13 @@ import {
deriveQmdScopeChatType,
isSessionArchiveArtifactName,
isQmdScopeAllowed,
listSessionTranscriptCorpusEntriesForAgent,
listSessionFilesForAgent,
parseQmdQueryJson,
resolveSessionIdentityForTranscriptFile,
resolveCliSpawnInvocation,
runCliCommand,
type QmdQueryResult,
type SessionFileEntry,
type SessionTranscriptCorpusEntry,
} from "openclaw/plugin-sdk/memory-core-host-engine-qmd";
import {
buildMemoryReadResult,
@@ -90,23 +89,6 @@ type SqliteDatabase = import("node:sqlite").DatabaseSync;
const log = createSubsystemLogger("memory");
/**
* Normalize an already-aborted search signal into the error thrown before any
* qmd work starts. Prefers the caller-supplied abort reason (so a deadline
* message such as "memory_search timed out after 15s" survives) and falls back
* to a stable abort error.
*/
function asAbortError(signal: AbortSignal): Error {
const reason = signal.reason;
if (reason instanceof Error) {
return reason;
}
if (typeof reason === "string" && reason.length > 0) {
return new Error(reason);
}
return new Error("qmd search aborted");
}
const SNIPPET_HEADER_RE = /@@\s*-([0-9]+),([0-9]+)/;
const SEARCH_PENDING_UPDATE_WAIT_MS = 500;
const MAX_QMD_OUTPUT_CHARS = 200_000;
@@ -342,7 +324,6 @@ type QmdMcporterSearchParams =
minScore: number;
collection?: string;
timeoutMs: number;
signal?: AbortSignal;
}
| {
mcporter: ResolvedQmdMcporterConfig;
@@ -354,7 +335,6 @@ type QmdMcporterSearchParams =
minScore: number;
collection?: string;
timeoutMs: number;
signal?: AbortSignal;
};
type QmdMcporterAcrossCollectionsParams =
| {
@@ -365,7 +345,6 @@ type QmdMcporterAcrossCollectionsParams =
limit: number;
minScore: number;
collectionNames: string[];
signal?: AbortSignal;
}
| {
tool: BuiltinQmdMcpTool;
@@ -375,7 +354,6 @@ type QmdMcporterAcrossCollectionsParams =
limit: number;
minScore: number;
collectionNames: string[];
signal?: AbortSignal;
};
export class QmdMemoryManager implements MemorySearchManager {
@@ -1301,23 +1279,12 @@ export class QmdMemoryManager implements MemorySearchManager {
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
/**
* Caller-owned cancellation. When the caller stops waiting (e.g. the
* memory_search tool deadline fires), abort kills the in-flight qmd
* subprocess instead of leaving it running orphaned for the full qmd
* timeout.
*/
signal?: AbortSignal;
},
): Promise<MemorySearchResult[]> {
if (!this.isScopeAllowed(opts?.sessionKey)) {
this.logScopeDenied(opts?.sessionKey);
return [];
}
const searchSignal = opts?.signal;
if (searchSignal?.aborted) {
throw asAbortError(searchSignal);
}
const trimmed = query.trim();
if (!trimmed) {
return [];
@@ -1357,7 +1324,6 @@ export class QmdMemoryManager implements MemorySearchManager {
limit,
minScore,
collectionNames,
signal: searchSignal,
});
}
return await this.runQmdSearchViaMcporter({
@@ -1370,7 +1336,6 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore,
collection: collectionNames[0],
timeoutMs: this.qmd.limits.timeoutMs,
signal: searchSignal,
});
}
const tool = this.resolveQmdMcpTool(qmdSearchCommand);
@@ -1383,7 +1348,6 @@ export class QmdMemoryManager implements MemorySearchManager {
limit,
minScore,
collectionNames,
signal: searchSignal,
});
}
return await this.runQmdSearchViaMcporter({
@@ -1396,25 +1360,20 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore,
collection: collectionNames[0],
timeoutMs: this.qmd.limits.timeoutMs,
signal: searchSignal,
});
}
const collectionGroups = await this.resolveCollectionSearchGroups(
collectionNames,
searchSignal,
);
const collectionGroups = await this.resolveCollectionSearchGroups(collectionNames);
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
limit,
collectionGroups,
qmdSearchCommand,
searchSignal,
);
}
const args = this.buildSearchArgs(qmdSearchCommand, trimmed, limit);
args.push(...this.buildCollectionFilterArgs(collectionGroups[0] ?? collectionNames));
return await this.runQmdSearch(args, qmdSearchCommand, searchSignal);
return await this.runQmdSearch(args, qmdSearchCommand);
} catch (err) {
if (allowMissingCollectionRepair && this.isMissingCollectionSearchError(err)) {
throw err;
@@ -1430,24 +1389,20 @@ export class QmdMemoryManager implements MemorySearchManager {
`qmd ${qmdSearchCommand} does not support configured flags; retrying search with qmd query`,
);
try {
const collectionGroups = await this.resolveCollectionSearchGroups(
collectionNames,
searchSignal,
);
const collectionGroups = await this.resolveCollectionSearchGroups(collectionNames);
if (collectionGroups.length > 1) {
return await this.runQueryAcrossCollectionGroups(
trimmed,
limit,
collectionGroups,
"query",
searchSignal,
);
}
const fallbackArgs = this.buildSearchArgs("query", trimmed, limit);
fallbackArgs.push(
...this.buildCollectionFilterArgs(collectionGroups[0] ?? collectionNames),
);
return await this.runQmdSearch(fallbackArgs, "query", searchSignal);
return await this.runQmdSearch(fallbackArgs, "query");
} catch (fallbackErr) {
log.warn(`qmd query fallback failed: ${String(fallbackErr)}`);
throw fallbackErr instanceof Error ? fallbackErr : new Error(String(fallbackErr));
@@ -2181,7 +2136,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private async runQmd(
args: string[],
opts?: { timeoutMs?: number; discardOutput?: boolean; signal?: AbortSignal },
opts?: { timeoutMs?: number; discardOutput?: boolean },
): Promise<{ stdout: string; stderr: string }> {
return await runCliCommand({
commandSummary: `qmd ${args.join(" ")}`,
@@ -2197,17 +2152,15 @@ export class QmdMemoryManager implements MemorySearchManager {
maxOutputChars: this.maxQmdOutputChars,
// Large `qmd update` runs can easily exceed the output cap; keep only stderr.
discardStdout: opts?.discardOutput,
signal: opts?.signal,
});
}
private async runQmdSearch(
args: string[],
command: "query" | "search" | "vsearch",
signal?: AbortSignal,
): Promise<QmdQueryResult[]> {
try {
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs, signal });
const result = await this.runQmd(args, { timeoutMs: this.qmd.limits.timeoutMs });
return parseQmdQueryJson(result.stdout, result.stderr);
} catch (err) {
const recovered = this.parseFailedQmdSearchJson(err, command);
@@ -2350,7 +2303,7 @@ export class QmdMemoryManager implements MemorySearchManager {
private async runMcporter(
args: string[],
opts?: { timeoutMs?: number; signal?: AbortSignal },
opts?: { timeoutMs?: number },
): Promise<{ stdout: string; stderr: string }> {
const spawnInvocation = resolveCliSpawnInvocation({
command: "mcporter",
@@ -2366,16 +2319,12 @@ export class QmdMemoryManager implements MemorySearchManager {
cwd: this.workspaceDir,
timeoutMs: opts?.timeoutMs,
maxOutputChars: this.maxQmdOutputChars,
signal: opts?.signal,
});
}
private async runQmdSearchViaMcporter(
params: QmdMcporterSearchParams,
): Promise<QmdQueryResult[]> {
if (params.signal?.aborted) {
throw asAbortError(params.signal);
}
await this.ensureMcporterDaemonStarted(params.mcporter);
// If the version is already known as v1 but we received a stale "query" tool name
@@ -2432,10 +2381,7 @@ export class QmdMemoryManager implements MemorySearchManager {
"--timeout",
String(Math.max(0, params.timeoutMs)),
],
{
timeoutMs: resolveQmdMcporterSearchProcessTimeoutMs(params.timeoutMs),
signal: params.signal,
},
{ timeoutMs: resolveQmdMcporterSearchProcessTimeoutMs(params.timeoutMs) },
);
// If we got here with the v2 "query" tool, confirm v2 for future calls.
if (useUnifiedQueryTool && this.qmdMcpToolVersion === null) {
@@ -2464,7 +2410,6 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore: params.minScore,
collection: params.collection,
timeoutMs: params.timeoutMs,
signal: params.signal,
});
}
throw err;
@@ -2596,19 +2541,15 @@ export class QmdMemoryManager implements MemorySearchManager {
const exportDir = this.sessionExporter.dir;
await fs.mkdir(exportDir, { recursive: true });
const exportRoot = await root(exportDir);
const corpusEntries = await listSessionTranscriptCorpusEntriesForAgent(this.agentId);
const files = await listSessionFilesForAgent(this.agentId);
const keep = new Set<string>();
const tracked = new Set<string>();
const artifactMappings: QmdSessionArtifactMapping[] = [];
const cutoff = this.sessionExporter.retentionMs
? Date.now() - this.sessionExporter.retentionMs
: null;
for (const corpusEntry of corpusEntries) {
const sessionFile = corpusEntry.sessionFile;
const entry = await buildSessionEntry(sessionFile, {
generatedByDreamingNarrative: corpusEntry.generatedByDreamingNarrative === true,
generatedByCronRun: corpusEntry.generatedByCronRun === true,
});
for (const sessionFile of files) {
const entry = await buildSessionEntry(sessionFile);
if (!entry) {
continue;
}
@@ -2618,12 +2559,7 @@ export class QmdMemoryManager implements MemorySearchManager {
const targetName = `${path.basename(sessionFile, ".jsonl")}.md`;
const target = path.join(exportDir, targetName);
tracked.add(sessionFile);
const identity = this.buildSessionArtifactMapping(
sessionFile,
targetName,
target,
corpusEntry,
);
const identity = this.buildSessionArtifactMapping(sessionFile, targetName, target);
if (identity) {
artifactMappings.push(identity);
}
@@ -2666,12 +2602,11 @@ export class QmdMemoryManager implements MemorySearchManager {
sessionFile: string,
artifactPath: string,
target: string,
corpusEntry?: SessionTranscriptCorpusEntry,
): QmdSessionArtifactMapping | null {
if (!this.sessionExporter) {
return null;
}
const identity = corpusEntry ?? resolveSessionIdentityForTranscriptFile(sessionFile);
const identity = resolveSessionIdentityForTranscriptFile(sessionFile);
if (!identity?.agentId) {
return null;
}
@@ -3367,39 +3302,28 @@ export class QmdMemoryManager implements MemorySearchManager {
]);
}
private async resolveCollectionSearchGroups(
collectionNames: string[],
signal?: AbortSignal,
): Promise<string[][]> {
private async resolveCollectionSearchGroups(collectionNames: string[]): Promise<string[][]> {
if (collectionNames.length <= 1) {
return [collectionNames];
}
if (!(await this.supportsQmdMultiCollectionFilters(signal))) {
if (!(await this.supportsQmdMultiCollectionFilters())) {
return collectionNames.map((collectionName) => [collectionName]);
}
return this.groupCollectionNamesBySource(collectionNames);
}
private async supportsQmdMultiCollectionFilters(signal?: AbortSignal): Promise<boolean> {
if (signal?.aborted) {
throw asAbortError(signal);
}
private async supportsQmdMultiCollectionFilters(): Promise<boolean> {
if (this.multiCollectionFilterSupported !== null) {
return this.multiCollectionFilterSupported;
}
try {
const result = await this.runQmd(["--help"], {
timeoutMs: Math.min(this.qmd.limits.timeoutMs, 5_000),
signal,
});
const helpText = `${result.stdout}\n${result.stderr}`;
this.multiCollectionFilterSupported =
/\b(?:one or more collections|collection\(s\)|multiple -c flags)\b/i.test(helpText);
} catch (err) {
// Cancellation says nothing about QMD capabilities; leave the probe uncached.
if (signal?.aborted) {
throw asAbortError(signal);
}
this.multiCollectionFilterSupported = false;
log.debug(`qmd multi-collection filter probe failed: ${String(err)}`);
}
@@ -3411,7 +3335,6 @@ export class QmdMemoryManager implements MemorySearchManager {
limit: number,
collectionGroups: string[][],
command: "query" | "search" | "vsearch",
signal?: AbortSignal,
): Promise<QmdQueryResult[]> {
log.debug(
`qmd ${command} multi-source collection grouping active (${collectionGroups.length} groups)`,
@@ -3420,7 +3343,7 @@ export class QmdMemoryManager implements MemorySearchManager {
for (const collectionNames of collectionGroups) {
const args = this.buildSearchArgs(command, query, limit);
args.push(...this.buildCollectionFilterArgs(collectionNames));
const parsed = await this.runQmdSearch(args, command, signal);
const parsed = await this.runQmdSearch(args, command);
for (const entry of parsed) {
const defaultCollection = collectionNames.length === 1 ? collectionNames[0] : undefined;
const normalizedHints = this.normalizeDocHints({
@@ -3503,7 +3426,6 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
signal: params.signal,
})
: await this.runQmdSearchViaMcporter({
mcporter: this.qmd.mcporter,
@@ -3515,7 +3437,6 @@ export class QmdMemoryManager implements MemorySearchManager {
minScore: params.minScore,
collection: collectionName,
timeoutMs: this.qmd.limits.timeoutMs,
signal: params.signal,
});
for (const entry of parsed) {
if (typeof entry.docid !== "string" || !entry.docid.trim()) {

View File

@@ -328,29 +328,6 @@ describe("getMemorySearchManager caching", () => {
expect(createQmdManagerMock.mock.calls).toHaveLength(1);
});
it("keeps the cached QMD manager active when the caller cancels a search", async () => {
const agentId = "cancelled-search";
const cfg = createQmdCfg(agentId);
const controller = new AbortController();
const abortError = new Error("memory_search timed out after 15s");
mockPrimary.search.mockImplementationOnce(async () => {
controller.abort(abortError);
throw abortError;
});
const first = await getMemorySearchManager({ cfg, agentId });
const firstManager = requireManager(first);
await expect(firstManager.search("hello", { signal: controller.signal })).rejects.toBe(
abortError,
);
expect(mockPrimary.close).not.toHaveBeenCalled();
expect(fallbackSearch).not.toHaveBeenCalled();
const second = await getMemorySearchManager({ cfg, agentId });
expect(second.manager).toBe(first.manager);
expect(createQmdManagerMock).toHaveBeenCalledTimes(1);
});
it("evicts failed qmd wrapper so next call retries qmd", async () => {
const retryAgentId = "retry-agent";
const {

View File

@@ -475,7 +475,6 @@ class FallbackMemoryManager implements MemorySearchManager {
qmdSearchModeOverride?: "query" | "search" | "vsearch";
onDebug?: (debug: MemorySearchRuntimeDebug) => void;
sources?: MemorySource[];
signal?: AbortSignal;
},
) {
this.ensureOpen();
@@ -483,11 +482,6 @@ class FallbackMemoryManager implements MemorySearchManager {
try {
return await this.deps.primary.search(query, opts);
} catch (err) {
// Caller cancellation is request-scoped, not a QMD health failure.
// Keep the shared manager active for concurrent and later searches.
if (opts?.signal?.aborted) {
throw err;
}
this.primaryFailed = true;
this.lastError = formatErrorMessage(err);
log.warn(`qmd memory failed; switching to builtin index: ${this.lastError}`);

View File

@@ -1,6 +1,6 @@
{
"id": "minimax",
"icon": "https://cdn.simpleicons.org/minimax",
"icon": "https://cdn.simpleicons.org/minimax/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "mistral",
"icon": "https://cdn.simpleicons.org/mistralai",
"icon": "https://cdn.simpleicons.org/mistralai/111111",
"activation": {
"onStartup": false
},

View File

@@ -2,7 +2,7 @@
"id": "nextcloud-talk",
"name": "Nextcloud Talk",
"description": "OpenClaw Nextcloud Talk channel plugin for conversations.",
"icon": "https://cdn.simpleicons.org/nextcloud",
"icon": "https://cdn.simpleicons.org/nextcloud/111111",
"activation": {
"onStartup": false
},

View File

@@ -36,25 +36,20 @@ function requireFirstFetchParams(): {
return fetchParams as { auditContext?: string; url?: string };
}
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(payload), {
status: 200,
headers: { "content-type": "application/json" },
...init,
});
}
describe("nextcloud talk room info", () => {
it("resolves direct rooms from the room info endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: 1,
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: 1,
},
},
},
}),
}),
},
release,
});
@@ -81,13 +76,16 @@ describe("nextcloud talk room info", () => {
it("normalizes signed decimal room type strings through the shared parser", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: "+01",
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: "+01",
},
},
},
}),
}),
},
release: vi.fn(async () => {}),
});
@@ -108,13 +106,16 @@ describe("nextcloud talk room info", () => {
it("does not coerce partial room type strings", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: "1direct",
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: "1direct",
},
},
},
}),
}),
},
release: vi.fn(async () => {}),
});
@@ -135,13 +136,16 @@ describe("nextcloud talk room info", () => {
it("does not classify negative room types as group rooms", async () => {
fetchWithSsrFGuard.mockResolvedValue({
response: jsonResponse({
ocs: {
data: {
type: -1,
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: -1,
},
},
},
}),
}),
},
release: vi.fn(async () => {}),
});

View File

@@ -1,6 +1,6 @@
{
"id": "nvidia",
"icon": "https://cdn.simpleicons.org/nvidia",
"icon": "https://cdn.simpleicons.org/nvidia/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "ollama",
"icon": "https://cdn.simpleicons.org/ollama",
"icon": "https://cdn.simpleicons.org/ollama/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "opencode-go",
"icon": "https://cdn.simpleicons.org/opencode",
"icon": "https://cdn.simpleicons.org/opencode/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "opencode",
"icon": "https://cdn.simpleicons.org/opencode",
"icon": "https://cdn.simpleicons.org/opencode/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "openrouter",
"icon": "https://cdn.simpleicons.org/openrouter",
"icon": "https://cdn.simpleicons.org/openrouter/111111",
"activation": {
"onStartup": false
},

View File

@@ -1,6 +1,6 @@
{
"id": "perplexity",
"icon": "https://cdn.simpleicons.org/perplexity",
"icon": "https://cdn.simpleicons.org/perplexity/111111",
"activation": {
"onStartup": false
},

View File

@@ -115,40 +115,6 @@ describe("crabline transport", () => {
direction: "outbound",
text: "assistant via fake telegram",
});
await transport.state.reset();
const delivery = transport.buildAgentDelivery({ target: "dm:qa-operator" });
const { response: directResponse, release: directRelease } = await fetchWithSsrFGuard({
url: `${telegram?.apiRoot}/bot${telegram?.botToken}/sendMessage`,
init: {
body: JSON.stringify({
chat_id: delivery.to,
text: "assistant after reset",
}),
headers: { "content-type": "application/json" },
method: "POST",
},
policy: { allowPrivateNetwork: true },
auditContext: "qa-lab-crabline-transport-reset-test",
});
await directRelease();
expect(directResponse.ok).toBe(true);
await expect(
transport.state.waitFor({
direction: "outbound",
kind: "message-text",
textIncludes: "assistant after reset",
timeoutMs: 1_000,
}),
).resolves.toMatchObject({
conversation: {
id: "qa-operator",
kind: "direct",
},
direction: "outbound",
text: "assistant after reset",
});
} finally {
await transport.cleanup?.();
}

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