mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-24 08:12:29 +08:00
Compare commits
1 Commits
main
...
feature/sk
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8242ed1a33 |
17
.github/workflows/ci-build-artifacts-testbox.yml
vendored
17
.github/workflows/ci-build-artifacts-testbox.yml
vendored
@@ -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 "$@"
|
||||
|
||||
17
.github/workflows/ci-check-arm-testbox.yml
vendored
17
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -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 "$@"
|
||||
|
||||
17
.github/workflows/ci-check-testbox.yml
vendored
17
.github/workflows/ci-check-testbox.yml
vendored
@@ -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 "$@"
|
||||
|
||||
76
.github/workflows/ci.yml
vendored
76
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
34
.github/workflows/crabbox-hydrate.yml
vendored
34
.github/workflows/crabbox-hydrate.yml
vendored
@@ -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 "$@"
|
||||
|
||||
12
.github/workflows/maturity-scorecard.yml
vendored
12
.github/workflows/maturity-scorecard.yml
vendored
@@ -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}`);
|
||||
|
||||
18
.github/workflows/openclaw-release-checks.yml
vendored
18
.github/workflows/openclaw-release-checks.yml
vendored
@@ -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
|
||||
|
||||
7
.github/workflows/qa-profile-evidence.yml
vendored
7
.github/workflows/qa-profile-evidence.yml
vendored
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, _):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
10
docs/ci.md
10
docs/ci.md
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
53
extensions/acpx/npm-shrinkwrap.json
generated
53
extensions/acpx/npm-shrinkwrap.json
generated
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "alibaba",
|
||||
"icon": "https://cdn.simpleicons.org/alibabacloud",
|
||||
"icon": "https://cdn.simpleicons.org/alibabacloud/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "anthropic",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "cloudflare-ai-gateway",
|
||||
"icon": "https://cdn.simpleicons.org/cloudflare",
|
||||
"icon": "https://cdn.simpleicons.org/cloudflare/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
]);
|
||||
|
||||
@@ -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 ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "copilot-proxy",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.";
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "deepgram",
|
||||
"icon": "https://cdn.simpleicons.org/deepgram",
|
||||
"icon": "https://cdn.simpleicons.org/deepgram/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "deepseek",
|
||||
"icon": "https://cdn.simpleicons.org/deepseek",
|
||||
"icon": "https://cdn.simpleicons.org/deepseek/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "duckduckgo",
|
||||
"icon": "https://cdn.simpleicons.org/duckduckgo",
|
||||
"icon": "https://cdn.simpleicons.org/duckduckgo/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "elevenlabs",
|
||||
"icon": "https://cdn.simpleicons.org/elevenlabs",
|
||||
"icon": "https://cdn.simpleicons.org/elevenlabs/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "google",
|
||||
"icon": "https://cdn.simpleicons.org/google",
|
||||
"icon": "https://cdn.simpleicons.org/google/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "huggingface",
|
||||
"icon": "https://cdn.simpleicons.org/huggingface",
|
||||
"icon": "https://cdn.simpleicons.org/huggingface/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "imessage",
|
||||
"icon": "https://cdn.simpleicons.org/imessage",
|
||||
"icon": "https://cdn.simpleicons.org/imessage/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "lmstudio",
|
||||
"icon": "https://cdn.simpleicons.org/lmstudio",
|
||||
"icon": "https://cdn.simpleicons.org/lmstudio/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "mattermost",
|
||||
"icon": "https://cdn.simpleicons.org/mattermost",
|
||||
"icon": "https://cdn.simpleicons.org/mattermost/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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$/, ""),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "minimax",
|
||||
"icon": "https://cdn.simpleicons.org/minimax",
|
||||
"icon": "https://cdn.simpleicons.org/minimax/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "mistral",
|
||||
"icon": "https://cdn.simpleicons.org/mistralai",
|
||||
"icon": "https://cdn.simpleicons.org/mistralai/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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 () => {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "nvidia",
|
||||
"icon": "https://cdn.simpleicons.org/nvidia",
|
||||
"icon": "https://cdn.simpleicons.org/nvidia/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "ollama",
|
||||
"icon": "https://cdn.simpleicons.org/ollama",
|
||||
"icon": "https://cdn.simpleicons.org/ollama/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "opencode-go",
|
||||
"icon": "https://cdn.simpleicons.org/opencode",
|
||||
"icon": "https://cdn.simpleicons.org/opencode/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "opencode",
|
||||
"icon": "https://cdn.simpleicons.org/opencode",
|
||||
"icon": "https://cdn.simpleicons.org/opencode/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "openrouter",
|
||||
"icon": "https://cdn.simpleicons.org/openrouter",
|
||||
"icon": "https://cdn.simpleicons.org/openrouter/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "perplexity",
|
||||
"icon": "https://cdn.simpleicons.org/perplexity",
|
||||
"icon": "https://cdn.simpleicons.org/perplexity/111111",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user