mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-07 14:31:35 +08:00
Compare commits
1 Commits
node-worke
...
fix/fix-pl
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19ccab136f |
@@ -1,468 +0,0 @@
|
||||
name: Mantis Discord Thread Attachment
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
candidate_ref:
|
||||
description: Ref, tag, or SHA expected to preserve filePath attachments
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
baseline_ref:
|
||||
description: Display label for the synthetic baseline; the workflow reverts only the thread attachment fix
|
||||
required: false
|
||||
default: synthetic-reverted-thread-filepath-fix
|
||||
type: string
|
||||
pr_number:
|
||||
description: Optional bug or fix PR number to receive the QA evidence comment
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
concurrency:
|
||||
group: mantis-discord-thread-attachment-${{ github.event.issue.number || inputs.pr_number || inputs.candidate_ref || github.run_id }}-${{ github.run_attempt }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
PNPM_VERSION: "10.33.0"
|
||||
OPENCLAW_BUILD_PRIVATE_QA: "1"
|
||||
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
|
||||
|
||||
jobs:
|
||||
authorize_actor:
|
||||
name: Authorize workflow actor
|
||||
if: >-
|
||||
${{
|
||||
github.event_name == 'workflow_dispatch' ||
|
||||
(
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
(
|
||||
contains(github.event.comment.body, '@Mantis') ||
|
||||
contains(github.event.comment.body, '@mantis') ||
|
||||
contains(github.event.comment.body, '/mantis')
|
||||
)
|
||||
)
|
||||
}}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Require maintainer-level repository access
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const allowed = new Set(["admin", "maintain", "write"]);
|
||||
const { owner, repo } = context.repo;
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: context.actor,
|
||||
});
|
||||
const permission = data.permission;
|
||||
core.info(`Actor ${context.actor} permission: ${permission}`);
|
||||
if (!allowed.has(permission)) {
|
||||
core.setFailed(
|
||||
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
|
||||
);
|
||||
}
|
||||
|
||||
resolve_request:
|
||||
name: Resolve Mantis request
|
||||
needs: authorize_actor
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
baseline_ref: ${{ steps.resolve.outputs.baseline_ref }}
|
||||
candidate_ref: ${{ steps.resolve.outputs.candidate_ref }}
|
||||
pr_number: ${{ steps.resolve.outputs.pr_number }}
|
||||
request_source: ${{ steps.resolve.outputs.request_source }}
|
||||
should_run: ${{ steps.resolve.outputs.should_run }}
|
||||
steps:
|
||||
- name: Resolve refs and target PR
|
||||
id: resolve
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
script: |
|
||||
const defaultBaseline = "synthetic-reverted-thread-filepath-fix";
|
||||
const eventName = context.eventName;
|
||||
|
||||
function setOutput(name, value) {
|
||||
core.setOutput(name, value ?? "");
|
||||
core.info(`${name}=${value ?? ""}`);
|
||||
}
|
||||
|
||||
if (eventName === "workflow_dispatch") {
|
||||
const inputs = context.payload.inputs ?? {};
|
||||
setOutput("should_run", "true");
|
||||
setOutput("baseline_ref", inputs.baseline_ref || defaultBaseline);
|
||||
setOutput("candidate_ref", inputs.candidate_ref || "main");
|
||||
setOutput("pr_number", inputs.pr_number || "");
|
||||
setOutput("request_source", "workflow_dispatch");
|
||||
return;
|
||||
}
|
||||
|
||||
if (eventName !== "issue_comment") {
|
||||
core.setFailed(`Unsupported event: ${eventName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const issue = context.payload.issue;
|
||||
const body = context.payload.comment?.body ?? "";
|
||||
if (!issue?.pull_request) {
|
||||
core.setFailed("Mantis issue_comment trigger requires a pull request comment.");
|
||||
return;
|
||||
}
|
||||
|
||||
const normalized = body.toLowerCase();
|
||||
const requested =
|
||||
(normalized.includes("@mantis") || normalized.includes("/mantis")) &&
|
||||
normalized.includes("discord") &&
|
||||
normalized.includes("thread") &&
|
||||
(normalized.includes("attachment") ||
|
||||
normalized.includes("filepath") ||
|
||||
normalized.includes("file path"));
|
||||
if (!requested) {
|
||||
core.notice("Comment mentioned Mantis but did not request the Discord thread attachment scenario.");
|
||||
setOutput("should_run", "false");
|
||||
setOutput("baseline_ref", "");
|
||||
setOutput("candidate_ref", "");
|
||||
setOutput("pr_number", "");
|
||||
setOutput("request_source", "unsupported_issue_comment");
|
||||
return;
|
||||
}
|
||||
|
||||
const { owner, repo } = context.repo;
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: issue.number,
|
||||
});
|
||||
const candidateMatch = body.match(/(?:candidate|head)[\s:=]+([^\s`]+)/i);
|
||||
const rawCandidate = candidateMatch?.[1];
|
||||
const candidate =
|
||||
rawCandidate && !["head", "pr", "pr-head"].includes(rawCandidate.toLowerCase())
|
||||
? rawCandidate
|
||||
: pr.head.sha;
|
||||
|
||||
setOutput("should_run", "true");
|
||||
setOutput("baseline_ref", defaultBaseline);
|
||||
setOutput("candidate_ref", candidate);
|
||||
setOutput("pr_number", String(issue.number));
|
||||
setOutput("request_source", "issue_comment");
|
||||
|
||||
await github.rest.reactions.createForIssueComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: context.payload.comment.id,
|
||||
content: "eyes",
|
||||
}).catch((error) => core.warning(`Could not add eyes reaction: ${error.message}`));
|
||||
|
||||
validate_candidate:
|
||||
name: Validate selected candidate
|
||||
needs: resolve_request
|
||||
if: ${{ needs.resolve_request.outputs.should_run == 'true' }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
outputs:
|
||||
candidate_revision: ${{ steps.validate.outputs.candidate_revision }}
|
||||
steps:
|
||||
- name: Checkout harness ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Validate candidate ref is trusted
|
||||
id: validate
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CANDIDATE_REF: ${{ needs.resolve_request.outputs.candidate_ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
revision="$(git rev-parse "${CANDIDATE_REF}^{commit}")"
|
||||
reason=""
|
||||
if git merge-base --is-ancestor "$revision" refs/remotes/origin/main; then
|
||||
reason="main-ancestor"
|
||||
elif git tag --points-at "$revision" | grep -Eq '^v'; then
|
||||
reason="release-tag"
|
||||
else
|
||||
pr_head_count="$(
|
||||
gh api \
|
||||
-H "Accept: application/vnd.github+json" \
|
||||
"repos/${GITHUB_REPOSITORY}/commits/${revision}/pulls" \
|
||||
--jq '[.[] | select(.state == "open" and .head.repo.full_name == "'"${GITHUB_REPOSITORY}"'" and .head.sha == "'"${revision}"'")] | length'
|
||||
)"
|
||||
if [[ "$pr_head_count" != "0" ]]; then
|
||||
reason="open-pr-head"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$reason" ]]; then
|
||||
echo "Candidate ref '${CANDIDATE_REF}' resolved to ${revision}, which is not trusted for this secret-bearing Mantis run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "candidate_revision=${revision}" >> "$GITHUB_OUTPUT"
|
||||
{
|
||||
echo "Candidate: \`${CANDIDATE_REF}\`"
|
||||
echo "Candidate SHA: \`${revision}\`"
|
||||
echo "Candidate trust reason: \`${reason}\`"
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
run_thread_attachment:
|
||||
name: Run Discord thread attachment before/after
|
||||
needs: [resolve_request, validate_candidate]
|
||||
if: ${{ needs.resolve_request.outputs.should_run == 'true' }}
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 120
|
||||
environment: qa-live-shared
|
||||
outputs:
|
||||
comparison_status: ${{ steps.run_mantis.outputs.comparison_status }}
|
||||
output_dir: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
steps:
|
||||
- name: Checkout harness ref
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
pnpm-version: ${{ env.PNPM_VERSION }}
|
||||
install-bun: "true"
|
||||
|
||||
- name: Build Mantis harness
|
||||
run: pnpm build
|
||||
|
||||
- name: Prepare baseline and candidate worktrees
|
||||
shell: bash
|
||||
env:
|
||||
CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
worktree_root=".artifacts/qa-e2e/mantis/discord-thread-attachment-worktrees"
|
||||
mkdir -p "$worktree_root"
|
||||
git worktree add --detach "$worktree_root/baseline" "$CANDIDATE_SHA"
|
||||
git worktree add --detach "$worktree_root/candidate" "$CANDIDATE_SHA"
|
||||
|
||||
baseline_file="$worktree_root/baseline/extensions/discord/src/actions/handle-action.guild-admin.ts"
|
||||
node - "$baseline_file" <<'NODE'
|
||||
const fs = require("node:fs");
|
||||
const file = process.argv[2];
|
||||
let text = fs.readFileSync(file, "utf8");
|
||||
const mediaReadFileContext = '\n | "mediaReadFile"';
|
||||
const mediaFallback = [
|
||||
' const mediaUrl =',
|
||||
' readStringParam(actionParams, "media", { trim: false }) ??',
|
||||
' readStringParam(actionParams, "path", { trim: false }) ??',
|
||||
' readStringParam(actionParams, "filePath", { trim: false });',
|
||||
'',
|
||||
].join("\n");
|
||||
const mediaOnly = ' const mediaUrl = readStringParam(actionParams, "media", { trim: false });\n';
|
||||
const optionForwarding = [
|
||||
' cfg,',
|
||||
' { mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile },',
|
||||
'',
|
||||
].join("\n");
|
||||
if (!text.includes(mediaReadFileContext)) {
|
||||
throw new Error("Could not find mediaReadFile context entry to synthesize baseline.");
|
||||
}
|
||||
if (!text.includes(mediaFallback)) {
|
||||
throw new Error("Could not find media/path/filePath fallback to synthesize baseline.");
|
||||
}
|
||||
if (!text.includes(optionForwarding)) {
|
||||
throw new Error("Could not find mediaLocalRoots/mediaReadFile forwarding to synthesize baseline.");
|
||||
}
|
||||
text = text.replace(mediaReadFileContext, "");
|
||||
text = text.replace(mediaFallback, mediaOnly);
|
||||
text = text.replace(optionForwarding, " cfg,\n");
|
||||
fs.writeFileSync(file, text);
|
||||
NODE
|
||||
|
||||
for lane in baseline candidate; do
|
||||
lane_dir="$worktree_root/${lane}"
|
||||
echo "Installing ${lane} worktree dependencies"
|
||||
pnpm --dir "$lane_dir" install --frozen-lockfile
|
||||
echo "Building ${lane} worktree"
|
||||
pnpm --dir "$lane_dir" build
|
||||
done
|
||||
|
||||
- name: Run baseline and candidate
|
||||
id: run_mantis
|
||||
shell: bash
|
||||
env:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
|
||||
OPENCLAW_QA_DISCORD_CAPTURE_CONTENT: "1"
|
||||
CANDIDATE_SHA: ${{ needs.validate_candidate.outputs.candidate_revision }}
|
||||
BASELINE_LABEL: ${{ needs.resolve_request.outputs.baseline_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
require_var() {
|
||||
local key="$1"
|
||||
if [[ -z "${!key:-}" ]]; then
|
||||
echo "Missing required ${key}." >&2
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
require_var OPENCLAW_QA_CONVEX_SITE_URL
|
||||
require_var OPENCLAW_QA_CONVEX_SECRET_CI
|
||||
|
||||
root=".artifacts/qa-e2e/mantis/discord-thread-attachment"
|
||||
worktree_root=".artifacts/qa-e2e/mantis/discord-thread-attachment-worktrees"
|
||||
mkdir -p "$root"
|
||||
echo "output_dir=${root}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
run_lane() {
|
||||
local lane="$1"
|
||||
local repo_root="${GITHUB_WORKSPACE}/${worktree_root}/${lane}"
|
||||
local output_dir=".artifacts/qa-e2e/mantis/discord-thread-attachment/${lane}"
|
||||
pnpm --dir "$repo_root" openclaw qa discord \
|
||||
--repo-root "$repo_root" \
|
||||
--output-dir "$output_dir" \
|
||||
--provider-mode mock-openai \
|
||||
--credential-source convex \
|
||||
--credential-role ci \
|
||||
--scenario discord-thread-reply-filepath-attachment \
|
||||
--allow-failures
|
||||
rm -rf "$root/$lane"
|
||||
mkdir -p "$root/$lane"
|
||||
cp -a "$repo_root/$output_dir/." "$root/$lane/"
|
||||
}
|
||||
|
||||
run_lane baseline
|
||||
run_lane candidate
|
||||
|
||||
baseline_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/baseline/discord-qa-summary.json")"
|
||||
candidate_status="$(jq -r '.scenarios[] | select(.id == "discord-thread-reply-filepath-attachment") | .status' "$root/candidate/discord-qa-summary.json")"
|
||||
comparison_status="fail"
|
||||
if [[ "$baseline_status" == "fail" && "$candidate_status" == "pass" ]]; then
|
||||
comparison_status="pass"
|
||||
fi
|
||||
echo "comparison_status=${comparison_status}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
jq -n \
|
||||
--arg baselineRef "$BASELINE_LABEL" \
|
||||
--arg candidateRef "$CANDIDATE_SHA" \
|
||||
--arg baselineStatus "$baseline_status" \
|
||||
--arg candidateStatus "$candidate_status" \
|
||||
--argjson pass "$([[ "$comparison_status" == "pass" ]] && echo true || echo false)" \
|
||||
'{
|
||||
scenario: "discord-thread-reply-filepath-attachment",
|
||||
transport: "discord",
|
||||
pass: $pass,
|
||||
baseline: { ref: $baselineRef, status: $baselineStatus, reproduced: ($baselineStatus == "fail"), expected: "thread reply omits filePath attachment" },
|
||||
candidate: { ref: $candidateRef, status: $candidateStatus, fixed: ($candidateStatus == "pass"), expected: "thread reply includes filePath attachment" }
|
||||
}' > "$root/comparison.json"
|
||||
|
||||
{
|
||||
echo "# Mantis Discord Thread Attachment"
|
||||
echo
|
||||
echo "- Scenario: \`discord-thread-reply-filepath-attachment\`"
|
||||
echo "- Baseline: \`${BASELINE_LABEL}\`"
|
||||
echo "- Candidate: \`${CANDIDATE_SHA}\`"
|
||||
echo "- Baseline status: \`${baseline_status}\`"
|
||||
echo "- Candidate status: \`${candidate_status}\`"
|
||||
echo "- Result: \`${comparison_status}\`"
|
||||
echo "- Baseline screenshot: \`baseline/discord-thread-reply-filepath-attachment-attachment.png\`"
|
||||
echo "- Candidate screenshot: \`candidate/discord-thread-reply-filepath-attachment-attachment.png\`"
|
||||
} > "$root/mantis-report.md"
|
||||
|
||||
jq -n \
|
||||
--arg baselineRef "$BASELINE_LABEL" \
|
||||
--arg candidateRef "$CANDIDATE_SHA" \
|
||||
--arg baselineStatus "$baseline_status" \
|
||||
--arg candidateStatus "$candidate_status" \
|
||||
--argjson pass "$([[ "$comparison_status" == "pass" ]] && echo true || echo false)" \
|
||||
'{
|
||||
schemaVersion: 1,
|
||||
id: "discord-thread-attachment",
|
||||
title: "Mantis Discord Thread Attachment QA",
|
||||
summary: "Mantis reproduced the Discord thread-reply filePath attachment bug with a synthetic baseline that reverts only the thread attachment fix, then verified the candidate preserves the attachment.",
|
||||
scenario: "discord-thread-reply-filepath-attachment",
|
||||
comparison: {
|
||||
pass: $pass,
|
||||
baseline: { ref: $baselineRef, status: $baselineStatus, expected: "thread reply omits filePath attachment" },
|
||||
candidate: { ref: $candidateRef, status: $candidateStatus, expected: "thread reply includes filePath attachment" }
|
||||
},
|
||||
artifacts: [
|
||||
{ kind: "timeline", lane: "baseline", label: "Baseline missing filePath attachment", path: "baseline/discord-thread-reply-filepath-attachment-attachment.png", targetPath: "baseline.png", alt: "Baseline Discord thread reply without filePath attachment", width: 420 },
|
||||
{ kind: "timeline", lane: "candidate", label: "Candidate includes filePath attachment", path: "candidate/discord-thread-reply-filepath-attachment-attachment.png", targetPath: "candidate.png", alt: "Candidate Discord thread reply with filePath attachment", width: 420 },
|
||||
{ kind: "metadata", lane: "run", label: "Comparison JSON", path: "comparison.json", targetPath: "comparison.json" },
|
||||
{ kind: "report", lane: "run", label: "Mantis report", path: "mantis-report.md", targetPath: "mantis-report.md" }
|
||||
]
|
||||
}' > "$root/mantis-evidence.json"
|
||||
|
||||
cat "$root/mantis-report.md" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload Mantis thread attachment artifacts
|
||||
id: upload_artifact
|
||||
if: ${{ always() && steps.run_mantis.outputs.output_dir != '' }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: mantis-discord-thread-attachment-${{ github.run_id }}-${{ github.run_attempt }}
|
||||
path: ${{ steps.run_mantis.outputs.output_dir }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
- name: Create Mantis GitHub App token
|
||||
id: mantis_app_token
|
||||
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' }}
|
||||
uses: actions/create-github-app-token@v3
|
||||
with:
|
||||
app-id: ${{ secrets.MANTIS_GITHUB_APP_ID }}
|
||||
private-key: ${{ secrets.MANTIS_GITHUB_APP_PRIVATE_KEY }}
|
||||
owner: ${{ github.repository_owner }}
|
||||
repositories: ${{ github.event.repository.name }}
|
||||
permission-contents: write
|
||||
permission-issues: write
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Comment PR with inline QA evidence
|
||||
if: ${{ always() && needs.resolve_request.outputs.pr_number != '' && steps.run_mantis.outputs.output_dir != '' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.mantis_app_token.outputs.token }}
|
||||
TARGET_PR: ${{ needs.resolve_request.outputs.pr_number }}
|
||||
ARTIFACT_URL: ${{ steps.upload_artifact.outputs.artifact-url }}
|
||||
REQUEST_SOURCE: ${{ needs.resolve_request.outputs.request_source }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
root=".artifacts/qa-e2e/mantis/discord-thread-attachment"
|
||||
if [[ ! -f "$root/mantis-evidence.json" ]]; then
|
||||
echo "No Mantis evidence manifest found; skipping PR evidence comment."
|
||||
exit 0
|
||||
fi
|
||||
artifact_url_args=()
|
||||
if [[ -n "${ARTIFACT_URL:-}" ]]; then
|
||||
artifact_url_args=(--artifact-url "$ARTIFACT_URL")
|
||||
fi
|
||||
node scripts/mantis/publish-pr-evidence.mjs \
|
||||
--manifest "$root/mantis-evidence.json" \
|
||||
--target-pr "$TARGET_PR" \
|
||||
--artifact-root "mantis/discord-thread-attachment/pr-${TARGET_PR}/run-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
|
||||
--marker "<!-- mantis-discord-thread-attachment -->" \
|
||||
"${artifact_url_args[@]}" \
|
||||
--run-url "https://github.com/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
|
||||
--request-source "$REQUEST_SOURCE"
|
||||
|
||||
- name: Fail when Mantis comparison failed
|
||||
if: ${{ steps.run_mantis.outputs.comparison_status != 'pass' }}
|
||||
run: |
|
||||
echo "Mantis comparison failed." >&2
|
||||
exit 1
|
||||
14
.github/workflows/mantis-scenario.yml
vendored
14
.github/workflows/mantis-scenario.yml
vendored
@@ -10,7 +10,6 @@ on:
|
||||
type: choice
|
||||
options:
|
||||
- discord-status-reactions-tool-only
|
||||
- discord-thread-reply-filepath-attachment
|
||||
- slack-desktop-smoke
|
||||
baseline_ref:
|
||||
description: Optional baseline ref for before/after scenarios
|
||||
@@ -65,19 +64,6 @@ jobs:
|
||||
fi
|
||||
gh "${args[@]}"
|
||||
;;
|
||||
discord-thread-reply-filepath-attachment)
|
||||
args=(
|
||||
workflow run mantis-discord-thread-attachment.yml
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
--ref main
|
||||
-f "baseline_ref=${BASELINE_REF:-synthetic-reverted-thread-filepath-fix}"
|
||||
-f "candidate_ref=${CANDIDATE_REF}"
|
||||
)
|
||||
if [[ -n "${PR_NUMBER:-}" ]]; then
|
||||
args+=(-f "pr_number=${PR_NUMBER}")
|
||||
fi
|
||||
gh "${args[@]}"
|
||||
;;
|
||||
slack-desktop-smoke)
|
||||
args=(
|
||||
workflow run mantis-slack-desktop-smoke.yml
|
||||
|
||||
35
CHANGELOG.md
35
CHANGELOG.md
@@ -6,18 +6,14 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Changes
|
||||
|
||||
- PR triage: mark external pull requests with `proof: supplied` when Barnacle finds structured real behavior proof, keep stale negative proof labels in sync across CRLF-edited PR bodies, and let ClawSweeper own the stronger `proof: sufficient` judgement.
|
||||
- Sessions CLI: show the selected agent runtime in the `openclaw sessions` table so terminal output matches the runtime visibility already present in JSON/status surfaces. Thanks @vincentkoc.
|
||||
- Google Meet/Voice Call: make Twilio dial-in joins speak through the realtime Gemini voice bridge with paced audio streaming, backpressure-aware buffering, barge-in queue clearing, same-session agent consult routing, duplicate-consult coalescing, and no TwiML fallback during realtime speech, giving Meet participants a much snappier OpenClaw voice agent. (#77064) Thanks @scoootscooob.
|
||||
- Voice Call/realtime: add opt-in OpenClaw agent voice context capsules and consult-cadence guidance so Gemini/OpenAI realtime calls can sound like the configured agent without consulting the full agent on every ordinary turn. Thanks @scoootscooob.
|
||||
- Docker/Gateway: harden the gateway container by dropping `NET_RAW` and `NET_ADMIN` capabilities and enabling `no-new-privileges` in the bundled `docker-compose.yml`. Thanks @VintageAyu.
|
||||
- Telegram: accept plugin-owned numeric forum-topic targets in the agent message tool and keep reply-dispatch provider chunks behind a real stable runtime alias during in-place package updates. Fixes #77137. Thanks @richardmqq.
|
||||
- Telegram/streaming: keep draft preview rotation from reusing a pre-tool assistant preview after visible tool or media output lands between compaction replay and the next assistant message. Thanks @vincentkoc.
|
||||
- Channels/WhatsApp: support explicit WhatsApp Channel/Newsletter `@newsletter` outbound message targets with channel session metadata instead of DM routing. Fixes #13417; carries forward the narrow outbound target idea from #13424. Thanks @vincentkoc and @agentz-manfred.
|
||||
- TTS/telephony: honor provider voice/model overrides in telephony synthesis providers so Google Meet agent speech logs match the backend that actually produced the audio. Thanks @vincentkoc.
|
||||
- Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc.
|
||||
- Google Meet: preserve `realtime.introMessage: ""` so realtime Chrome joins can stay silent instead of restoring the default spoken intro. Thanks @vincentkoc.
|
||||
- CLI/migrate: add bulk on/off and skip controls to interactive Codex skill migration, leaving conflicting skill copies unchecked by default. (#77597) Thanks @kevinslin.
|
||||
- OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc.
|
||||
- Models/auth: add `openclaw models auth list [--provider <id>] [--json]` so users can inspect saved per-agent auth profiles without dumping secrets or hitting the old “too many arguments” path. Thanks @vincentkoc.
|
||||
- Cron CLI: add `openclaw cron list --agent <id>`, normalize the requested agent id, and include jobs without a stored agent id under the configured default agent while keeping `cron list` unfiltered when no agent is supplied. Fixes #77118. Thanks @zhanggttry.
|
||||
@@ -38,8 +34,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and default sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics.
|
||||
- Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc.
|
||||
- QA/Mantis: add `pnpm openclaw qa mantis slack-desktop-smoke` to run Slack live QA inside a Crabbox VNC desktop, open Slack Web, and capture desktop screenshots beside the Slack QA artifacts.
|
||||
- QA/Mantis: add an opt-in Discord thread attachment before/after scenario that creates a real thread, calls `message.thread-reply` with `filePath`, and captures baseline/candidate screenshot evidence.
|
||||
- Discord: preserve `filePath` and `path` attachments when replying to a thread with the message tool.
|
||||
- QA/Mantis: add visual desktop tasks with Crabbox MP4 recording, screenshot capture, and optional image-understanding assertions, and preserve video artifacts in Mantis before/after reports.
|
||||
- QA/WhatsApp: add `pnpm openclaw qa whatsapp` for live DM canary and pairing-gate coverage using two pre-linked WhatsApp Web sessions from the QA credential pool.
|
||||
- QA/Mantis: pass the runtime env through desktop-browser Crabbox and artifact-copy child commands, so embedded Mantis callers can provide Crabbox credentials without mutating the parent process. Thanks @vincentkoc.
|
||||
@@ -59,12 +53,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugins/update: make package upgrades swap pnpm/npm-prefix installs cleanly, keep legacy plugin install runtime chunks working, and on the beta channel fall back default-line npm plugins to default/latest when plugin beta releases are missing or fail install validation. Thanks @vincentkoc and @joshavant.
|
||||
- Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys.
|
||||
- Sandbox/Windows: accept drive-absolute Docker bind sources while keeping sandbox blocked-path and allowed-root policy comparisons Windows-case-insensitive. (#42174) Thanks @6607changchun.
|
||||
- Plugin SDK: add `openclaw/plugin-sdk/channel-message` lifecycle helpers for `defineChannelMessageAdapter`, `deliverInboundReplyWithMessageSendContext`, send/receive/live/state contracts, durable final-delivery capability derivation, capability proof helpers, and normalized message receipts.
|
||||
- Plugin SDK: add `createChannelMessageAdapterFromOutbound` so channel plugins can derive durable message adapters from proven outbound adapters without duplicating send/receipt bridge code.
|
||||
- Plugin SDK: add `actions.prepareSendPayload(...)` so channel plugins can shape message-tool sends into durable payloads while core owns queueing, hooks, retry, recovery, and acknowledgements.
|
||||
- Plugin SDK: make the legacy `channel-reply-pipeline` subpath a compatibility wrapper over the shared reply core while steering root compat deprecations toward `plugin-sdk/channel-message`.
|
||||
- Plugin SDK: move Discord, Slack, Mattermost, and Matrix live-preview finalization onto `plugin-sdk/channel-message` and attach message receipts to Telegram finalized previews plus Teams native stream finals, so preview edits and stream finals are represented in the message lifecycle instead of draft-only helpers.
|
||||
- Telegram: persist the polling restart watermark after successful update dispatch instead of at handler entry, leaving failed updates retryable while still coalescing completed offsets safely.
|
||||
- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc.
|
||||
- Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output.
|
||||
- Gateway/startup: keep model-catalog test helpers, run-session lookup code, QR pairing helpers, and TypeBox memory-tool schema construction out of hot startup import paths, reducing default gateway benchmark plugin-load and memory pressure.
|
||||
@@ -98,21 +86,11 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Plugins/diagnostics: make source-only TypeScript package warnings actionable by explaining that missing compiled runtime output is a publisher packaging issue and pointing users to update/reinstall or disable/uninstall the plugin. Fixes #77835. Thanks @googlerest.
|
||||
- TUI: skip the generic CLI respawn wrapper for interactive launches, exit cleanly on terminal loss, and refuse to restore heartbeat sessions as the remembered chat session, preventing stale heartbeat history and orphaned `openclaw-tui` processes on first boot. Thanks @vincentkoc.
|
||||
- Doctor/sessions: move heartbeat-poisoned default main session store entries to recovery keys and clear stale TUI restore pointers, so `doctor --fix` can repair instances already stuck on `agent:main:main` heartbeat history. Thanks @vincentkoc.
|
||||
- Agents/context engines: keep hidden OpenClaw runtime-context custom messages out of context-engine assemble, afterTurn, and ingest hooks so transcript reconstruction plugins only see conversation messages. Thanks @vincentkoc.
|
||||
- Gateway/shutdown: cancel delayed post-ready maintenance during close and suppress maintenance/cron startup after quick restarts, preventing orphaned background timers. Thanks @vincentkoc.
|
||||
- Agents/generated media: treat attachment-style message tool actions as completed chat sends, preventing duplicate fallback media posts when generated files were already uploaded.
|
||||
- Control UI/sessions: show each session's agent runtime in the Sessions table and allow filtering by runtime labels, matching the Agents panel runtime wording. Thanks @vincentkoc.
|
||||
- Discord/streaming: show live reasoning text in progress drafts instead of a bare `Reasoning` status line.
|
||||
- Gateway/status: avoid marking fast repeated health/status samples as event-loop degraded from CPU/utilization alone until the Gateway has accumulated a sustained sampling window. Thanks @shakkernerd.
|
||||
- Plugins/update: keep installed official npm and ClawHub plugins such as Codex, Discord, WhatsApp, and diagnostics plugins synced during host updates even when disabled or previously exact-pinned, while preserving third-party plugin pins. Thanks @vincentkoc.
|
||||
- Doctor/status: warn when `OPENCLAW_GATEWAY_TOKEN` would shadow a different active `gateway.auth.token` source for local CLI commands, while avoiding false positives when config points at the same env token. Fixes #74271. Thanks @yelog.
|
||||
- Gateway/HTTP: avoid loading managed outgoing-image media handlers for unrelated requests, so disabled OpenAI-compatible routes return 404 without waiting on lazy media sidecars. Thanks @vincentkoc.
|
||||
- Gateway/OpenAI-compatible: send the assistant role SSE chunk as soon as streaming chat-completion headers are accepted, so cold agent setup cannot leave `/v1/chat/completions` clients with a bodyless 200 response until their idle timeout fires.
|
||||
- Agents/media: avoid direct generated-media completion fallback while the announce-agent run is still pending, so async video and music completions do not duplicate raw media messages. (#77754)
|
||||
- WebChat/Codex media: stage Codex app-server generated local images into managed media before Gateway display, so Codex-home image paths no longer hit `LocalMediaAccessError` while keeping Codex home out of the display allowlist. Thanks @frankekn.
|
||||
- TUI/sessions: bound the session picker to recent rows and use exact lookup-style refreshes for the active session, so dusty stores no longer make TUI hydrate weeks-old transcripts before becoming responsive. Thanks @vincentkoc.
|
||||
- Doctor/gateway: report recent supervisor restart handoffs in `openclaw doctor --deep`, using the installed service environment when available so service-managed clean exits are visible in guided diagnostics. Thanks @shakkernerd.
|
||||
- Gateway/status: show recent supervisor restart handoffs in `openclaw gateway status --deep`, including JSON details, so clean service-managed restarts are reported as restart handoffs instead of opaque stopped-service diagnostics. Thanks @shakkernerd.
|
||||
@@ -120,21 +98,13 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp responsiveness: stop only verified stale local TUI clients when they degrade the Gateway event loop and delay replies. Thanks @vincentkoc.
|
||||
- Hooks/session-memory: add collision suffixes to fallback memory filenames so repeated `/new` or `/reset` captures in the same minute do not overwrite the earlier session archive. Thanks @vincentkoc.
|
||||
- Agents/config: remove the ambiguous legacy `main` agent dir helper from runtime paths; model, auth, gateway, bundled plugin, and test helpers now resolve default/session agent dirs through `agents.list`/agent-scope helpers while plugin SDK keeps a deprecated compatibility export.
|
||||
- CLI/status: show the selected agent runtime/harness in `openclaw status` session rows so terminal status matches the `/status` runtime line. Thanks @vincentkoc.
|
||||
- CLI/sessions: prune old unreferenced transcript, compaction checkpoint, and trajectory artifacts during normal `sessions cleanup`, so gateway restart or crash orphans do not accumulate indefinitely outside `sessions.json`. Fixes #77608. Thanks @slideshow-dingo.
|
||||
- Doctor/Codex: repair legacy `openai-codex/*` routes in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel overrides, and stale session pins to canonical `openai/*`, selecting `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth; otherwise select `agentRuntime.id: "pi"`. Thanks @vincentkoc.
|
||||
- Video generation: wait up to 20 minutes for slow fal/MiniMax queue-backed jobs, stop forwarding unsupported Google Veo generated-audio options, and normalize MiniMax `720P` requests to its supported `768P` resolution with the usual override warning/details instead of failing fallback.
|
||||
- Video generation: accept provider-specific aspect-ratio and resolution hints at the tool boundary, normalize `720P` to MiniMax's supported `768P`, and stop sending Google `generateAudio` on Gemini video requests so provider fallback can recover from model-specific parameter differences. Thanks @vincentkoc.
|
||||
- Channels/durable delivery: preserve channel-specific final reply semantics when using durable sends, including Telegram selected quotes and silent error replies plus WhatsApp message-sending cancellations.
|
||||
- Channels/message lifecycle: build legacy channel delivery results from message receipts and add receipts to BlueBubbles, Feishu, Google Chat, iMessage, IRC, LINE, Nextcloud Talk, QQ Bot, Signal, Synology Chat, Tlon, Twitch, WhatsApp, Zalo, and Zalo Personal send results and owner-path reply delivery plus Discord, Matrix, Mattermost, Slack, and Teams send results while preserving existing message id compatibility.
|
||||
- iMessage: run durable final replies through the iMessage outbound sanitizer before sending, matching direct auto-reply delivery and preventing assistant-internal scaffolding from leaking through queued delivery.
|
||||
- OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc.
|
||||
- Hooks/session-memory: run reset memory capture off the command reply path and make model-generated memory filename slugs opt-in with `llmSlug: true`, so `/new` and `/reset` no longer block WhatsApp and other message-channel reset replies on hook housekeeping or a nested model call. Thanks @vincentkoc.
|
||||
- CLI/plugins: handle closed stdin during `plugins uninstall` confirmation prompt and exit 1 with actionable `--force` guidance instead of crashing with Node exit 13 unsettled top-level await. Fixes #73562. (#73566) Thanks @ai-hpc.
|
||||
- CLI/channels: skip config, proxy, channel-option catalog, banner-config, and plugin startup bootstrap for the bare `openclaw channels` parent-help command, so it exits promptly after printing help instead of loading configured channel plugins. Thanks @vincentkoc.
|
||||
- CLI/gateway: pause non-TTY stdin after full CLI command completion and stop `openclaw agent` from falling back to embedded mode after gateway request/auth failures, so parent help commands exit cleanly and scoped delivery probes surface the real Gateway error immediately. Thanks @vincentkoc.
|
||||
- Gateway/model catalog: cache empty read-only model catalog results until reload, so TUI and control-plane refresh loops cannot hammer plugin metadata reads when no usable models are currently discovered. Thanks @vincentkoc.
|
||||
- CLI/update: make dev-channel preflight lint opt-in and constrained when enabled, so `openclaw update --channel dev` no longer walks back otherwise-good main commits when Ubuntu hosts OOM-kill or fail parallel oxlint shards. Thanks @vincentkoc.
|
||||
- Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting.
|
||||
- Google Meet: log the concrete agent-mode TTS provider, model, voice, output format, and sample rate after speech synthesis, so Meet logs show which voice backend spoke each reply.
|
||||
- Google Meet: log the resolved audio provider model when starting Chrome and paired-node Meet talk-back bridges, so agent-mode joins show the STT model and bidi joins show the realtime voice model.
|
||||
@@ -349,7 +319,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Plugin tools: honor explicit tool denylists while selecting plugin tool runtimes, so denied plugin tools are not materialized for direct command or gateway surfaces before later policy filtering. Thanks @vincentkoc.
|
||||
- Plugin tools: filter factory-returned tools by manifest per-tool optional policy, so optional sibling tools from a shared runtime factory stay hidden unless explicitly allowed. Thanks @vincentkoc.
|
||||
- Agents/transcripts: retry context-overflow compaction from the current transcript only after the inbound user turn was actually persisted, and keep WebChat agent-run live delivery from writing duplicate Pi-managed assistant turns. Fixes #76424. (#77033)
|
||||
- Messaging: queue assembled channel-turn final replies before sending to reduce response loss when the gateway restarts between assistant completion and channel delivery. Refs #77000.
|
||||
- Agents/bootstrap: keep pending `BOOTSTRAP.md` and bootstrap truncation notices in system-prompt Project Context instead of copying setup text or raw warning diagnostics into WebChat user/runtime context. Fixes #76946.
|
||||
- Channels/CLI: keep `openclaw channels list --json` usable when provider usage fetching fails, and report per-provider usage errors without aborting the channel list. Refs #67595.
|
||||
- Agents/messaging: deliver distinct final commentary after same-target `message` tool sends while still deduping text/media already sent by the tool, so short closing remarks are no longer silently dropped. Fixes #76915. Thanks @hclsys.
|
||||
@@ -386,8 +355,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Agents/messaging: surface CLI subprocess watchdog/turn timeout messages to chat users when verbose failures are off, instead of collapsing them into generic external-run failure copy. Fixes #77007. (#77015) Thanks @neeravmakwana.
|
||||
- Agents/sessions: after embedded Pi runs, append assistant-visible reply text to session JSONL only when Pi did not already persist an equivalent tail assistant entry, without re-mirroring the user prompt Pi owns. Fixes #77823. (#77839) Thanks @neeravmakwana.
|
||||
- Plugins/CLI: load the install-records ledger when listing channel-catalog entries, so npm-installed third-party channel plugins resolve through `openclaw channels login`/`channels add` instead of failing with `Unsupported channel`. (#77269) Thanks @pumpkinxing1.
|
||||
- Memory wiki/Security: enforce session visibility on shared-memory `wiki_search` and `wiki_get` so sandboxed subagents cannot read transcript content from sibling or parent sessions. Fixes GHSA-72fw-cqh5-f324. Thanks @zsxsoft.
|
||||
- Exec approvals: enforce allowlist `argPattern` argument restrictions on Linux and macOS as well as Windows, so an entry like `{ pattern: "python3", argPattern: "^safe\\.py$" }` no longer silently relaxes to a path-only match on non-Windows hosts. (#75143) Thanks @eleqtrizit.
|
||||
|
||||
## 2026.5.3-1
|
||||
|
||||
@@ -433,7 +400,6 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Update: repair doctor-migratable legacy config before persisting `openclaw update --channel ...`, so old Slack/Telegram streaming keys do not block switching to beta after a package update. Thanks @vincentkoc.
|
||||
- Web fetch: late-bind `web_fetch` config and provider fallback metadata from the active runtime snapshot, matching `web_search` so long-lived tools do not use stale fetch provider settings. Thanks @vincentkoc.
|
||||
- Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda.
|
||||
- Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals.
|
||||
@@ -453,7 +419,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Google Meet: grant Chrome media permissions against the actual Meet tab, start the local realtime audio bridge only after Meet joins, expose realtime transcripts in status/logs, and force explicit audio responses with current OpenAI realtime output-audio events so BlackHole capture does not keep the OpenClaw participant muted or silent.
|
||||
- Memory/LanceDB: declare `apache-arrow` in the bundled memory plugin package so LanceDB installs include its runtime peer. Fixes #76910. Thanks @afiqfiles-max.
|
||||
- CLI/devices: retry explicit device-pair approval with `operator.admin` after a pairing-scope ownership denial, so existing admin-capable paired-device tokens can recover new Control UI/browser pairing after upgrades instead of requiring manual JSON edits. Fixes #76956. Thanks @neo19482.
|
||||
- CLI/devices: stop local pairing fallback when the active Gateway names a pending request that is absent from the local pairing store, so profile or state-dir mismatches no longer make `openclaw devices list/approve` inspect the wrong store while a real device stays blocked. Thanks @vincentkoc.
|
||||
- Google Meet: use the local call-control microphone button instead of disabled remote participant mute buttons, and block realtime speech when the OpenClaw Meet microphone remains muted.
|
||||
- Google Meet: refresh realtime browser state during status and retry delayed speech after Meet finishes joining, so a just-opened in-call tab no longer leaves speech stuck behind stale `not-in-call` health.
|
||||
- Plugins/install: recover the install ledger from the managed npm root when `plugins/installs.json` is empty or partial, so reinstalling Discord and Codex no longer makes the other installed plugin disappear.
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
fe061b6f35adb2b152d8f48244a94d4934b335143cc5f5aebb8cc96e5ba8b287 plugin-sdk-api-baseline.json
|
||||
495248d5981456192aaf7da2ed23d5951eaa6d9e59d70c716ab91c3da3620e73 plugin-sdk-api-baseline.jsonl
|
||||
2164ea491c61e643f0a9c68f7b9bd2e41ab338eb93bbdf301da2fae548722581 plugin-sdk-api-baseline.json
|
||||
c07c3719218a12482e2a76e6b9654da2ddddf75d8d70145cdaef3da2b2eaccef plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -27,14 +27,6 @@
|
||||
"source": "OpenClaw App SDK API design",
|
||||
"target": "OpenClaw 应用 SDK API 设计"
|
||||
},
|
||||
{
|
||||
"source": "Message lifecycle refactor",
|
||||
"target": "消息生命周期重构"
|
||||
},
|
||||
{
|
||||
"source": "Channel message API",
|
||||
"target": "频道消息 API"
|
||||
},
|
||||
{
|
||||
"source": "Azure Speech",
|
||||
"target": "Azure Speech"
|
||||
@@ -735,18 +727,6 @@
|
||||
"source": "Codex Harness Context Engine Port",
|
||||
"target": "Codex Harness Context Engine Port"
|
||||
},
|
||||
{
|
||||
"source": "Plugin refactor plan",
|
||||
"target": "插件重构计划"
|
||||
},
|
||||
{
|
||||
"source": "Retry policy",
|
||||
"target": "重试策略"
|
||||
},
|
||||
{
|
||||
"source": "Channel turn kernel",
|
||||
"target": "频道轮次内核"
|
||||
},
|
||||
{
|
||||
"source": "/gateway/configuration#strict-validation",
|
||||
"target": "/gateway/configuration#strict-validation"
|
||||
|
||||
@@ -756,8 +756,6 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
<Accordion title="Long polling vs webhook">
|
||||
Default is long polling. For webhook mode set `channels.telegram.webhookUrl` and `channels.telegram.webhookSecret`; optional `webhookPath`, `webhookHost`, `webhookPort` (defaults `/telegram-webhook`, `127.0.0.1`, `8787`).
|
||||
|
||||
In long-polling mode OpenClaw persists its restart watermark only after an update dispatches successfully. If a handler fails, that update remains retryable in the same process and is not written as completed for restart dedupe.
|
||||
|
||||
The local listener binds to `127.0.0.1:8787`. For public ingress, either put a reverse proxy in front of the local port or set `webhookHost: "0.0.0.0"` intentionally.
|
||||
|
||||
Webhook mode validates request guards, the Telegram secret token, and the JSON body before returning `200` to Telegram.
|
||||
|
||||
@@ -149,11 +149,6 @@ openclaw plugins install "@tencent-weixin/openclaw-weixin" --force
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
If startup reports that the installed plugin package `requires compiled runtime
|
||||
output for TypeScript entry`, the npm package was published without the compiled
|
||||
JavaScript runtime files OpenClaw needs. Update/reinstall after the plugin
|
||||
publisher ships a fixed package, or temporarily disable/uninstall the plugin.
|
||||
|
||||
Temporary disable:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -46,7 +46,6 @@ Notes:
|
||||
- Doctor also scans `~/.openclaw/cron/jobs.json` (or `cron.store`) for legacy cron job shapes and can rewrite them in place before the scheduler has to auto-normalize them at runtime.
|
||||
- On Linux, doctor warns when the user's crontab still runs legacy `~/.openclaw/bin/ensure-whatsapp.sh`; that script is no longer maintained and can log false WhatsApp gateway outages when cron lacks the systemd user-bus environment.
|
||||
- When WhatsApp is enabled, doctor checks for a degraded Gateway event loop with local `openclaw-tui` clients still running. `doctor --fix` stops only verified local TUI clients so WhatsApp replies are not queued behind stale TUI refresh loops.
|
||||
- Doctor rewrites legacy `openai-codex/*` model refs to canonical `openai/*` refs across primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale session route pins. `--fix` selects `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth; otherwise it selects `agentRuntime.id: "pi"` so the route stays on the default OpenClaw runner.
|
||||
- Doctor cleans legacy plugin dependency staging state created by older OpenClaw versions. It also repairs missing downloadable plugins that are referenced by config, such as `plugins.entries`, configured channels, configured provider/search settings, or configured agent runtimes. During package updates, doctor skips package-manager plugin repair until the package swap is complete; rerun `openclaw doctor --fix` afterward if a configured plugin still needs recovery. If the download fails, doctor reports the install error and preserves the configured plugin entry for the next repair attempt.
|
||||
- Doctor repairs stale plugin config by removing missing plugin ids from `plugins.allow`/`plugins.entries`, plus matching dangling channel config, heartbeat targets, and channel model overrides when plugin discovery is healthy.
|
||||
- Doctor quarantines invalid plugin config by disabling the affected `plugins.entries.<id>` entry and removing its invalid `config` payload. Gateway startup already skips only that bad plugin so other plugins and channels can keep running.
|
||||
|
||||
@@ -119,10 +119,9 @@ your personal Codex CLI state by default.
|
||||
|
||||
Running `openclaw migrate codex` in an interactive terminal previews the full
|
||||
plan, then opens a checkbox selector for skill copy items before the final
|
||||
apply confirmation. Use `Toggle all on` or `Toggle all off` for bulk selection;
|
||||
planned skills start checked, conflict skills start unchecked, and `Skip for now`
|
||||
leaves skills unchanged without applying. For scripted or exact runs, pass
|
||||
`--skill <name>` once per skill, for example:
|
||||
apply confirmation. All skills start selected; uncheck any skill you do not want
|
||||
copied into this agent. For scripted or exact runs, pass `--skill <name>` once
|
||||
per skill, for example:
|
||||
|
||||
```bash
|
||||
openclaw migrate codex --dry-run --skill gog-vault77-google-workspace
|
||||
|
||||
@@ -148,7 +148,7 @@ manually.
|
||||
Dev only.
|
||||
</Step>
|
||||
<Step title="Preflight build (dev only)">
|
||||
Runs the TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest buildable commit. Set `OPENCLAW_UPDATE_PREFLIGHT_LINT=1` to also run lint during this preflight; lint runs in constrained serial mode because user update hosts are often smaller than CI runners.
|
||||
Runs lint and TypeScript build in a temp worktree. If the tip fails, walks back up to 10 commits to find the newest clean build.
|
||||
</Step>
|
||||
<Step title="Rebase">
|
||||
Rebases onto the selected commit (dev only).
|
||||
|
||||
@@ -21,67 +21,11 @@ Treat them differently from normal config:
|
||||
|
||||
## Currently documented flags
|
||||
|
||||
| Surface | Key | Use it when | More |
|
||||
| ------------------------------- | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Local model runtime | `agents.defaults.experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
|
||||
| Agent command runtime isolation | `agents.defaults.experimental.runtimeIsolation` | You want `/agent` command attempts to run in a Node worker compartment while testing parallel-agent isolation | [Agent command runtime isolation](#agent-command-runtime-isolation) |
|
||||
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
|
||||
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |
|
||||
|
||||
## Agent command runtime isolation
|
||||
|
||||
`agents.defaults.experimental.runtimeIsolation.mode: "worker"` runs `/agent`
|
||||
command attempts in a Node worker thread. The parent process still owns command
|
||||
routing, model fallback policy, final session-store updates, delivery, and
|
||||
lifecycle reporting; the worker owns the in-repo command runtime attempt itself.
|
||||
|
||||
Normal inbound Gateway replies remain on the in-process embedded runner for now.
|
||||
That path owns live streaming and delivery callbacks in the parent process and
|
||||
needs a dedicated callback bridge before it can move into this worker
|
||||
compartment.
|
||||
|
||||
This is a compartment boundary, not a general speed switch. It can help when
|
||||
several in-repo command agents run at once and you want each run to have its own
|
||||
event loop, worker lifetime, and future filesystem permission scope. It will not
|
||||
make remote model calls faster, and CLI/ACP harnesses such as Codex may still
|
||||
spawn their own child processes inside the worker.
|
||||
|
||||
Session-store writes still go through the normal `updateSessionStore(...)` path.
|
||||
That writer uses a `sessions.json.lock` file lock so worker-thread updates for
|
||||
different agents do not overwrite each other when they share the same store.
|
||||
|
||||
### Enable
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
experimental: {
|
||||
runtimeIsolation: {
|
||||
mode: "worker",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
For developer-only overrides, `OPENCLAW_AGENT_RUNTIME_WORKER=1` forces the
|
||||
worker path and `OPENCLAW_AGENT_RUNTIME_WORKER=0` forces the in-process path.
|
||||
The older `OPENCLAW_AGENT_WORKER_EXPERIMENT` env var is also accepted while the
|
||||
experiment is in flight.
|
||||
|
||||
### Worker permissions
|
||||
|
||||
`runtimeIsolation.permissions: true` also starts the worker with Node permission
|
||||
flags scoped to the agent workspace, agent directory, session transcript,
|
||||
session store and lock files, OpenClaw runtime bundle/development source,
|
||||
bundled plugin source, and runtime dependencies.
|
||||
|
||||
Keep this off unless you are explicitly testing filesystem hardening. Node
|
||||
permission behavior is stricter and more runtime-sensitive than worker
|
||||
isolation itself, so package reads or child-process based harnesses may need
|
||||
additional design before this becomes broadly usable.
|
||||
| Surface | Key | Use it when | More |
|
||||
| ------------------------ | --------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| Local model runtime | `agents.defaults.experimental.localModelLean` | A smaller or stricter local backend chokes on OpenClaw's full default tool surface | [Local Models](/gateway/local-models) |
|
||||
| Memory search | `agents.defaults.memorySearch.experimental.sessionMemory` | You want `memory_search` to index prior session transcripts and accept the extra storage/indexing cost | [Memory configuration reference](/reference/memory-config#session-memory-search-experimental) |
|
||||
| Structured planning tool | `tools.experimental.planTool` | You want the structured `update_plan` tool exposed for multi-step work tracking in compatible runtimes and UIs | [Gateway configuration reference](/gateway/config-tools#toolsexperimental) |
|
||||
|
||||
## Local model lean mode
|
||||
|
||||
|
||||
@@ -1,202 +0,0 @@
|
||||
---
|
||||
summary: "Operator runbook for Mantis Slack desktop QA: GitHub dispatch, local CLI, warm VNC leases, hydrate modes, timing interpretation, artifacts, and failure handling."
|
||||
read_when:
|
||||
- Running Mantis Slack desktop QA from GitHub or locally
|
||||
- Debugging slow Mantis Slack desktop runs
|
||||
- Choosing source, prehydrated, or warm-lease mode
|
||||
- Posting screenshot and video evidence to a PR
|
||||
title: "Mantis Slack Desktop Runbook"
|
||||
---
|
||||
|
||||
Mantis Slack desktop QA is the real-UI lane for Slack-class bugs that need a
|
||||
Linux desktop, VNC rescue, Slack Web, a real OpenClaw gateway, screenshots,
|
||||
videos, and a PR evidence comment.
|
||||
|
||||
Use it when unit tests or the headless Slack live lane cannot prove the bug.
|
||||
|
||||
## Storage Model
|
||||
|
||||
Mantis uses three different storage layers:
|
||||
|
||||
- Provider image: owned by Crabbox and stored in the cloud provider account.
|
||||
It contains machine capabilities such as Chrome/Chromium, ffmpeg, scrot,
|
||||
Node/corepack/pnpm, native build tools, and empty cache directories.
|
||||
- Warm lease state: owned by the current operator session. It can contain a
|
||||
logged-in browser profile, `/var/cache/crabbox/pnpm`, and a prepared source
|
||||
checkout while the lease is alive.
|
||||
- Mantis artifacts: owned by the OpenClaw run. They live under
|
||||
`.artifacts/qa-e2e/mantis/...`, then GitHub Actions uploads them and the
|
||||
Mantis GitHub App comments inline evidence on the PR.
|
||||
|
||||
Never put secrets, browser cookies, Slack login state, repository checkouts,
|
||||
`node_modules`, or `dist/` into a prebaked provider image.
|
||||
|
||||
## GitHub Dispatch
|
||||
|
||||
Run the workflow from `main`:
|
||||
|
||||
```bash
|
||||
gh workflow run mantis-slack-desktop-smoke.yml \
|
||||
--ref main \
|
||||
-f candidate_ref=<trusted-ref-or-sha> \
|
||||
-f pr_number=<pr-number> \
|
||||
-f scenario_id=slack-canary \
|
||||
-f crabbox_provider=aws \
|
||||
-f keep_vm=false \
|
||||
-f hydrate_mode=source
|
||||
```
|
||||
|
||||
Allowed `candidate_ref` values are intentionally narrow because the workflow
|
||||
uses live credentials: current `main` ancestry, release tags, or an open PR head
|
||||
from `openclaw/openclaw`.
|
||||
|
||||
The workflow writes:
|
||||
|
||||
- uploaded artifact: `mantis-slack-desktop-smoke-<run-id>-<attempt>`;
|
||||
- inline PR comment from the Mantis GitHub App;
|
||||
- `slack-desktop-smoke.png`;
|
||||
- `slack-desktop-smoke.mp4`;
|
||||
- `slack-desktop-smoke-preview.gif`;
|
||||
- `slack-desktop-smoke-change.mp4`;
|
||||
- `mantis-slack-desktop-smoke-summary.json`;
|
||||
- `mantis-slack-desktop-smoke-report.md`;
|
||||
- remote logs such as `slack-desktop-command.log`, `openclaw-gateway.log`,
|
||||
`chrome.log`, and `ffmpeg.log`.
|
||||
|
||||
The PR comment is updated in place by the hidden
|
||||
`<!-- mantis-slack-desktop-smoke -->` marker.
|
||||
|
||||
## Local CLI
|
||||
|
||||
Cold source proof:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa mantis slack-desktop-smoke \
|
||||
--provider aws \
|
||||
--class standard \
|
||||
--gateway-setup \
|
||||
--credential-source convex \
|
||||
--credential-role maintainer \
|
||||
--provider-mode live-frontier \
|
||||
--model openai/gpt-5.4 \
|
||||
--alt-model openai/gpt-5.4 \
|
||||
--scenario slack-canary \
|
||||
--hydrate-mode source
|
||||
```
|
||||
|
||||
Keep the VM for VNC rescue:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa mantis slack-desktop-smoke \
|
||||
--provider aws \
|
||||
--class standard \
|
||||
--gateway-setup \
|
||||
--scenario slack-canary \
|
||||
--keep-lease
|
||||
```
|
||||
|
||||
Open VNC:
|
||||
|
||||
```bash
|
||||
crabbox vnc --provider aws --id <cbx_id> --open
|
||||
```
|
||||
|
||||
Reuse a warm lease:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa mantis slack-desktop-smoke \
|
||||
--provider aws \
|
||||
--lease-id <cbx_id-or-slug> \
|
||||
--gateway-setup \
|
||||
--scenario slack-canary \
|
||||
--hydrate-mode source
|
||||
```
|
||||
|
||||
Use `--hydrate-mode prehydrated` only when the reused remote workspace already
|
||||
has `node_modules` and a built `dist/`. Mantis fails closed if those are
|
||||
missing.
|
||||
|
||||
## Hydrate Modes
|
||||
|
||||
| Mode | Use when | Remote behavior | Tradeoff |
|
||||
| ------------- | ----------------------------------------- | ------------------------------------------------------------------------------------- | -------------------------------------------------------- |
|
||||
| `source` | Normal PR proof, cold machines, CI | Runs `pnpm install --frozen-lockfile --prefer-offline` and `pnpm build` inside the VM | Slowest, strongest source-checkout proof |
|
||||
| `prehydrated` | You intentionally prepared a reused lease | Requires existing `node_modules` and `dist/`; skips install/build | Fast, but only valid for operator-controlled warm leases |
|
||||
|
||||
GitHub Actions always prepares the candidate checkout before the VM run. Its
|
||||
pnpm store is cached by OS, Node version, and lockfile. The VM source run also
|
||||
uses `/var/cache/crabbox/pnpm` when present.
|
||||
|
||||
## Timing Interpretation
|
||||
|
||||
`mantis-slack-desktop-smoke-report.md` includes phase timings:
|
||||
|
||||
- `crabbox.warmup`: cloud provider boot, desktop/browser readiness, and SSH.
|
||||
- `crabbox.inspect`: lease metadata lookup.
|
||||
- `credentials.prepare`: Convex credential lease acquisition.
|
||||
- `crabbox.remote_run`: sync, browser launch, OpenClaw install/build or
|
||||
hydrate validation, gateway startup, screenshot, and video capture.
|
||||
- `artifacts.copy`: rsync back from the VM.
|
||||
|
||||
`crabbox.remote_run` can be marked `accepted` when Crabbox returns a non-zero
|
||||
remote status after Mantis has copied metadata proving that the OpenClaw gateway
|
||||
is alive and the setup completed. Treat `accepted` as pass-with-explanation,
|
||||
not a failed scenario.
|
||||
|
||||
If the run is slow:
|
||||
|
||||
- warmup dominates: prebake or promote a better Crabbox provider image;
|
||||
- remote_run dominates in `source`: use a warm lease, improve pnpm store reuse,
|
||||
or move machine prerequisites into the provider image;
|
||||
- remote_run dominates in `prehydrated`: the remote workspace was not actually
|
||||
ready, or the gateway/browser/Slack setup is slow;
|
||||
- artifact copy dominates: inspect video size and artifact directory contents.
|
||||
|
||||
## Evidence Checklist
|
||||
|
||||
A good PR comment should show:
|
||||
|
||||
- scenario id and candidate SHA;
|
||||
- GitHub Actions run URL;
|
||||
- artifact URL;
|
||||
- inline screenshot;
|
||||
- inline animated preview when available;
|
||||
- full MP4 and trimmed MP4 links;
|
||||
- pass/fail status;
|
||||
- timing summary in the attached report.
|
||||
|
||||
Do not commit screenshots or videos into the repository. Keep them in GitHub
|
||||
Actions artifacts or the PR comment.
|
||||
|
||||
## Failure Handling
|
||||
|
||||
If the workflow fails before the VM run, inspect the Actions job first. Typical
|
||||
causes are untrusted `candidate_ref`, missing environment secrets, or candidate
|
||||
install/build failure.
|
||||
|
||||
If the VM run fails but screenshots were copied back, inspect:
|
||||
|
||||
```bash
|
||||
cat mantis-slack-desktop-smoke-report.md
|
||||
cat mantis-slack-desktop-smoke-summary.json
|
||||
cat slack-desktop-command.log
|
||||
cat openclaw-gateway.log
|
||||
cat chrome.log
|
||||
cat ffmpeg.log
|
||||
```
|
||||
|
||||
If the run kept the lease, open VNC with the report's `crabbox vnc ...` command.
|
||||
Stop the lease when done:
|
||||
|
||||
```bash
|
||||
crabbox stop --provider aws <cbx_id-or-slug>
|
||||
```
|
||||
|
||||
If Slack login expired, repair it in VNC on a kept lease and rerun with
|
||||
`--lease-id`. Do not bake that browser profile into a provider image.
|
||||
|
||||
Related docs:
|
||||
|
||||
- [QA overview](qa-e2e-automation.md)
|
||||
- [Slack channel](../channels/slack.md)
|
||||
- [Testing](../help/testing.md)
|
||||
@@ -89,23 +89,6 @@ directory, installs dependencies, builds each ref, runs the scenario with
|
||||
and `mantis-report.md`. For the first Discord scenario, a successful verification
|
||||
means baseline status is `fail` and candidate status is `pass`.
|
||||
|
||||
The second Discord before/after probe targets thread attachments:
|
||||
|
||||
```bash
|
||||
pnpm openclaw qa mantis run \
|
||||
--transport discord \
|
||||
--scenario discord-thread-reply-filepath-attachment \
|
||||
--baseline <bug-ref> \
|
||||
--candidate <fix-ref> \
|
||||
--output-dir .artifacts/qa-e2e/mantis/local-discord-thread-attachment
|
||||
```
|
||||
|
||||
That scenario posts a parent message with the driver bot, creates a real Discord
|
||||
thread, calls OpenClaw's `message.thread-reply` action with a repo-local
|
||||
`filePath`, then polls the thread for the SUT reply and attachment filename. The
|
||||
baseline screenshot shows the reply with no attachment; the candidate screenshot
|
||||
shows the expected `mantis-thread-report.md` attachment.
|
||||
|
||||
The first VM/browser primitive is the desktop smoke:
|
||||
|
||||
```bash
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -206,7 +206,6 @@ parent stays quiet until the child completion event delivers the real reply.
|
||||
|
||||
## Related
|
||||
|
||||
- [Message lifecycle refactor](/concepts/message-lifecycle-refactor) - target durable send and receive design
|
||||
- [Streaming](/concepts/streaming) — real-time message delivery
|
||||
- [Retry](/concepts/retry) — message delivery retry behavior
|
||||
- [Queue](/concepts/queue) — message processing queue
|
||||
|
||||
@@ -29,26 +29,26 @@ Current pieces:
|
||||
Every QA flow runs under `pnpm openclaw qa <subcommand>`. Many have `pnpm qa:*`
|
||||
script aliases; both forms are supported.
|
||||
|
||||
| Command | Purpose |
|
||||
| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qa run` | Bundled QA self-check; writes a Markdown report. |
|
||||
| `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. |
|
||||
| `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). |
|
||||
| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report. |
|
||||
| `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). |
|
||||
| `qa manual` | Run a one-off prompt against the selected provider/model lane. |
|
||||
| `qa ui` | Start the QA debugger UI and local QA bus (alias: `pnpm qa:lab:ui`). |
|
||||
| `qa docker-build-image` | Build the prebaked QA Docker image. |
|
||||
| `qa docker-scaffold` | Write a docker-compose scaffold for the QA dashboard + gateway lane. |
|
||||
| `qa up` | Build the QA site, start the Docker-backed stack, print the URL (alias: `pnpm qa:lab:up`; `:fast` variant adds `--use-prebuilt-image --bind-ui-dist --skip-ui-build`). |
|
||||
| `qa aimock` | Start only the AIMock provider server. |
|
||||
| `qa mock-openai` | Start only the scenario-aware `mock-openai` provider server. |
|
||||
| `qa credentials doctor` / `add` / `list` / `remove` | Manage the shared Convex credential pool. |
|
||||
| `qa matrix` | Live transport lane against a disposable Tuwunel homeserver. See [Matrix QA](/concepts/qa-matrix). |
|
||||
| `qa telegram` | Live transport lane against a real private Telegram group. |
|
||||
| `qa discord` | Live transport lane against a real private Discord guild channel. |
|
||||
| `qa slack` | Live transport lane against a real private Slack channel. |
|
||||
| `qa mantis` | Before and after verification runner for live transport bugs, with Discord status-reactions evidence, Crabbox desktop/browser smoke, and Slack-in-VNC smoke. See [Mantis](/concepts/mantis) and [Mantis Slack Desktop Runbook](/concepts/mantis-slack-desktop-runbook). |
|
||||
| Command | Purpose |
|
||||
| --------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `qa run` | Bundled QA self-check; writes a Markdown report. |
|
||||
| `qa suite` | Run repo-backed scenarios against the QA gateway lane. Aliases: `pnpm openclaw qa suite --runner multipass` for a disposable Linux VM. |
|
||||
| `qa coverage` | Print the markdown scenario-coverage inventory (`--json` for machine output). |
|
||||
| `qa parity-report` | Compare two `qa-suite-summary.json` files and write the agentic parity report. |
|
||||
| `qa character-eval` | Run the character QA scenario across multiple live models with a judged report. See [Reporting](#reporting). |
|
||||
| `qa manual` | Run a one-off prompt against the selected provider/model lane. |
|
||||
| `qa ui` | Start the QA debugger UI and local QA bus (alias: `pnpm qa:lab:ui`). |
|
||||
| `qa docker-build-image` | Build the prebaked QA Docker image. |
|
||||
| `qa docker-scaffold` | Write a docker-compose scaffold for the QA dashboard + gateway lane. |
|
||||
| `qa up` | Build the QA site, start the Docker-backed stack, print the URL (alias: `pnpm qa:lab:up`; `:fast` variant adds `--use-prebuilt-image --bind-ui-dist --skip-ui-build`). |
|
||||
| `qa aimock` | Start only the AIMock provider server. |
|
||||
| `qa mock-openai` | Start only the scenario-aware `mock-openai` provider server. |
|
||||
| `qa credentials doctor` / `add` / `list` / `remove` | Manage the shared Convex credential pool. |
|
||||
| `qa matrix` | Live transport lane against a disposable Tuwunel homeserver. See [Matrix QA](/concepts/qa-matrix). |
|
||||
| `qa telegram` | Live transport lane against a real private Telegram group. |
|
||||
| `qa discord` | Live transport lane against a real private Discord guild channel. |
|
||||
| `qa slack` | Live transport lane against a real private Slack channel. |
|
||||
| `qa mantis` | Before and after verification runner for live transport bugs, with Discord status-reactions evidence, Crabbox desktop/browser smoke, and Slack-in-VNC smoke. See [Mantis](/concepts/mantis). |
|
||||
|
||||
## Operator flow
|
||||
|
||||
@@ -111,17 +111,6 @@ pnpm openclaw qa matrix --profile fast --fail-fast
|
||||
|
||||
The full CLI reference, profile/scenario catalog, env vars, and artifact layout for this lane live in [Matrix QA](/concepts/qa-matrix). At a glance: it provisions a disposable Tuwunel homeserver in Docker, registers temporary driver/SUT/observer users, runs the real Matrix plugin inside a child QA gateway scoped to that transport (no `qa-channel`), then writes a Markdown report, JSON summary, observed-events artifact, and combined output log under `.artifacts/qa-e2e/matrix-<timestamp>/`.
|
||||
|
||||
The scenarios cover transport behavior that unit tests cannot prove end to end: mention gating, allow-bot policies, allowlists, top-level and threaded replies, DM routing, reaction handling, inbound edit suppression, restart replay dedupe, homeserver interruption recovery, approval metadata delivery, media handling, and Matrix E2EE bootstrap/recovery/verification flows. The E2EE CLI profile also drives `openclaw matrix encryption setup` and verification commands through the same disposable homeserver before checking gateway replies.
|
||||
|
||||
Discord also has Mantis-only opt-in scenarios for bug reproduction. Use
|
||||
`--scenario discord-status-reactions-tool-only` for the explicit status reaction
|
||||
timeline, or `--scenario discord-thread-reply-filepath-attachment` to create a
|
||||
real Discord thread and verify that `message.thread-reply` preserves a
|
||||
`filePath` attachment. These scenarios stay out of the default live Discord lane
|
||||
because they are before/after repro probes rather than broad smoke coverage.
|
||||
|
||||
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`. Scheduled and default manual runs execute the fast Matrix profile with live frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans out into the five profile shards so the exhaustive catalog can run in parallel while keeping one artifact directory per shard.
|
||||
|
||||
For transport-real Telegram, Discord, and Slack smoke lanes:
|
||||
|
||||
```bash
|
||||
@@ -160,10 +149,6 @@ With `--gateway-setup`, Mantis leaves a persistent OpenClaw Slack gateway
|
||||
running inside the VM on port `38973`; without it, the command runs the normal
|
||||
bot-to-bot Slack QA lane and exits after artifact capture.
|
||||
|
||||
The operator checklist, GitHub workflow dispatch command, evidence-comment
|
||||
contract, hydrate-mode decision table, timing interpretation, and failure
|
||||
handling steps live in [Mantis Slack Desktop Runbook](/concepts/mantis-slack-desktop-runbook).
|
||||
|
||||
For an agent/CV style desktop task, run:
|
||||
|
||||
```bash
|
||||
@@ -206,7 +191,7 @@ Live transport lanes share one contract instead of each inventing their own scen
|
||||
| Matrix | x | x | x | x | x | x | x | x | x | | |
|
||||
| Telegram | x | x | x | | | | | | | x | |
|
||||
| Discord | x | x | x | | | | | | | | x |
|
||||
| Slack | x | x | x | x | x | x | x | x | | | |
|
||||
| Slack | x | x | x | | | | | | | | |
|
||||
|
||||
This keeps `qa-channel` as the broad product-behavior suite while Matrix,
|
||||
Telegram, and future live transports share one explicit transport-contract
|
||||
@@ -360,11 +345,6 @@ Scenarios (`extensions/qa-lab/src/live-transports/slack/slack-live.runtime.ts:39
|
||||
|
||||
- `slack-canary`
|
||||
- `slack-mention-gating`
|
||||
- `slack-allowlist-block`
|
||||
- `slack-top-level-reply-shape`
|
||||
- `slack-restart-resume`
|
||||
- `slack-thread-follow-up`
|
||||
- `slack-thread-isolation`
|
||||
|
||||
Output artifacts:
|
||||
|
||||
@@ -383,7 +363,7 @@ The lane needs two distinct Slack apps in one workspace, plus a channel both bot
|
||||
|
||||
Prefer a Slack workspace dedicated to QA over reusing a production workspace.
|
||||
|
||||
The SUT manifest below intentionally narrows the bundled Slack plugin's production install (`extensions/slack/src/setup-shared.ts:10`) to the permissions and events covered by the live Slack QA suite. For the production-channel setup as users see it, see [Slack channel quick setup](/channels/slack#quick-setup); the QA Driver/SUT pair is intentionally separate because the lane needs two distinct bot user ids in one workspace.
|
||||
The SUT manifest below mirrors the bundled Slack plugin's production install (`extensions/slack/src/setup-shared.ts:10`). For the production-channel setup as users see it, see [Slack channel quick setup](/channels/slack#quick-setup); the QA Driver/SUT pair is intentionally separate because the lane needs two distinct bot user ids in one workspace.
|
||||
|
||||
**1. Create the Driver app**
|
||||
|
||||
@@ -416,7 +396,7 @@ Copy the _Bot User OAuth Token_ (`xoxb-...`) — that becomes `driverBotToken`.
|
||||
|
||||
**2. Create the SUT app**
|
||||
|
||||
Repeat _Create New App → From a manifest_ in the same workspace. This QA app intentionally uses a narrower version of the bundled Slack plugin's production manifest (`extensions/slack/src/setup-shared.ts:10`): reaction scopes and events are omitted because the live Slack QA suite does not cover reaction handling yet.
|
||||
Repeat _Create New App → From a manifest_ in the same workspace. The scope set mirrors the bundled Slack plugin's production install (`extensions/slack/src/setup-shared.ts:10`):
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -457,6 +437,8 @@ Repeat _Create New App → From a manifest_ in the same workspace. This QA app i
|
||||
"mpim:write",
|
||||
"pins:read",
|
||||
"pins:write",
|
||||
"reactions:read",
|
||||
"reactions:write",
|
||||
"usergroups:read",
|
||||
"users:read"
|
||||
]
|
||||
@@ -476,7 +458,9 @@ Repeat _Create New App → From a manifest_ in the same workspace. This QA app i
|
||||
"message.im",
|
||||
"message.mpim",
|
||||
"pin_added",
|
||||
"pin_removed"
|
||||
"pin_removed",
|
||||
"reaction_added",
|
||||
"reaction_removed"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,7 +242,6 @@ Use the same shape under another compact progress channel key, for example `chan
|
||||
|
||||
## Related
|
||||
|
||||
- [Message lifecycle refactor](/concepts/message-lifecycle-refactor) - target shared preview, edit, stream, and finalization design
|
||||
- [Progress drafts](/concepts/progress-drafts) — visible work-in-progress messages that update during long turns
|
||||
- [Messages](/concepts/messages) — message lifecycle and delivery
|
||||
- [Retry](/concepts/retry) — retry behavior on delivery failure
|
||||
|
||||
@@ -5,6 +5,10 @@ read_when:
|
||||
title: "TypeBox"
|
||||
---
|
||||
|
||||
# TypeBox as protocol source of truth
|
||||
|
||||
Last updated: 2026-01-10
|
||||
|
||||
TypeBox is a TypeScript-first schema library. We use it to define the **Gateway
|
||||
WebSocket protocol** (handshake, request/response, server events). Those schemas
|
||||
drive **runtime validation**, **JSON Schema export**, and **Swift codegen** for
|
||||
|
||||
@@ -62,7 +62,7 @@ You can override this behavior:
|
||||
[WhatsApp +1555 +30s 2026-01-18T05:19Z] follow-up
|
||||
```
|
||||
|
||||
## System prompt: current date and time
|
||||
## System prompt: Current Date & Time
|
||||
|
||||
If the user timezone is known, the system prompt includes a dedicated
|
||||
**Current Date & Time** section with the **time zone only** (no clock/time format)
|
||||
|
||||
@@ -1163,7 +1163,6 @@
|
||||
"group": "Messages and delivery",
|
||||
"pages": [
|
||||
"concepts/messages",
|
||||
"concepts/message-lifecycle-refactor",
|
||||
"concepts/streaming",
|
||||
"concepts/progress-drafts",
|
||||
"concepts/retry",
|
||||
@@ -1206,7 +1205,6 @@
|
||||
"plugins/building-plugins",
|
||||
"plugins/hooks",
|
||||
"plugins/sdk-channel-plugins",
|
||||
"plugins/sdk-channel-message",
|
||||
"plugins/sdk-provider-plugins",
|
||||
"plugins/adding-capabilities",
|
||||
"plugins/compatibility",
|
||||
|
||||
@@ -108,7 +108,6 @@ cat ~/.openclaw/openclaw.json
|
||||
- Gateway runtime checks (service installed but not running; cached launchd label).
|
||||
- Channel status warnings (probed from the running gateway).
|
||||
- WhatsApp responsiveness checks for degraded Gateway event-loop health with local TUI clients still running; `--fix` stops only verified local TUI clients.
|
||||
- Codex route repair for legacy `openai-codex/*` model refs in primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and session route pins; `--fix` rewrites them to `openai/*` and selects `agentRuntime.id: "codex"` only when the Codex plugin is installed, enabled, contributes the `codex` harness, and has usable OAuth. Otherwise it selects `agentRuntime.id: "pi"`.
|
||||
- Supervisor config audit (launchd/systemd/schtasks) with optional repair.
|
||||
- Embedded proxy environment cleanup for gateway services that captured shell `HTTP_PROXY` / `HTTPS_PROXY` / `NO_PROXY` values during install or update.
|
||||
- Gateway runtime best-practice checks (Node vs Bun, version-manager paths).
|
||||
@@ -261,22 +260,21 @@ That stages grounded durable candidates into the short-term dreaming store while
|
||||
<Accordion title="2e. Codex OAuth provider overrides">
|
||||
If you previously added legacy OpenAI transport settings under `models.providers.openai-codex`, they can shadow the built-in Codex OAuth provider path that newer releases use automatically. Doctor warns when it sees those old transport settings alongside Codex OAuth so you can remove or rewrite the stale transport override and get the built-in routing/fallback behavior back. Custom proxies and header-only overrides are still supported and do not trigger this warning.
|
||||
</Accordion>
|
||||
<Accordion title="2f. Codex route repair">
|
||||
Doctor checks for legacy `openai-codex/*` model refs. Native Codex harness routing uses canonical `openai/*` model refs plus `agentRuntime.id: "codex"` so the turn goes through the Codex app-server harness instead of the OpenClaw PI OpenAI path.
|
||||
<Accordion title="2f. Codex plugin route warnings">
|
||||
When the bundled Codex plugin is enabled, doctor also checks whether `openai-codex/*` primary model refs still resolve through the default PI runner. That combination is valid when you want Codex OAuth/subscription auth through PI, but it is easy to confuse with the native Codex app-server harness. Doctor warns and points to the explicit app-server shape: `openai/*` plus `agentRuntime.id: "codex"` or `OPENCLAW_AGENT_RUNTIME=codex`.
|
||||
|
||||
In `--fix` / `--repair` mode, doctor rewrites affected default-agent and per-agent refs, including primary models, fallbacks, heartbeat/subagent/compaction overrides, hooks, channel model overrides, and stale persisted session route state:
|
||||
Doctor does not repair this automatically because both routes are valid:
|
||||
|
||||
- `openai-codex/gpt-*` becomes `openai/gpt-*`.
|
||||
- The matching agent runtime becomes `agentRuntime.id: "codex"` only when Codex is installed, enabled, contributes the `codex` harness, and has usable OAuth.
|
||||
- Otherwise the matching agent runtime becomes `agentRuntime.id: "pi"`.
|
||||
- Existing model fallback lists are preserved with their legacy entries rewritten; copied per-model settings move from the legacy key to the canonical `openai/*` key.
|
||||
- Persisted session `modelProvider`/`providerOverride`, `model`/`modelOverride`, fallback notices, auth-profile pins, and Codex harness pins are repaired across all discovered agent session stores.
|
||||
- `openai-codex/*` + PI means "use Codex OAuth/subscription auth through the normal OpenClaw runner."
|
||||
- `openai/*` + `agentRuntime.id: "codex"` means "run the embedded turn through native Codex app-server."
|
||||
- `/codex ...` means "control or bind a native Codex conversation from chat."
|
||||
- `/acp ...` or `runtime: "acp"` means "use the external ACP/acpx adapter."
|
||||
|
||||
If the warning appears, choose the route you intended and edit config manually. Keep the warning as-is when PI Codex OAuth is intentional.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="2g. Session route cleanup">
|
||||
Doctor also scans discovered agent session stores for stale auto-created route state after you move configured models or runtime away from a plugin-owned route such as Codex.
|
||||
Doctor also scans the active sessions store for stale auto-created route state after you move the configured default/fallback model or runtime away from a plugin-owned route such as Codex.
|
||||
|
||||
`openclaw doctor --fix` can clear auto-created stale state such as `modelOverrideSource: "auto"` model pins, runtime model metadata, pinned harness ids, CLI session bindings, and auto auth-profile overrides when their owning route is no longer configured. Explicit user or legacy session model choices are reported for manual review and left untouched; switch them with `/model ...`, `/new`, or reset the session when that route is no longer intended.
|
||||
|
||||
|
||||
@@ -466,7 +466,7 @@ Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-a
|
||||
~/.openclaw/agents/<agentId>/agent/auth-profiles.json
|
||||
```
|
||||
|
||||
To inspect saved profiles without dumping secrets, run `openclaw models auth list` (optionally `--provider <id>` or `--json`). See [Models CLI](/cli/models#auth-profiles) for details.
|
||||
To inspect saved profiles without dumping secrets, run `openclaw models auth list` (optionally `--provider <id>` or `--json`). See [Models CLI](/cli/models#openclaw-models-auth-list) for details.
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@ read_when:
|
||||
title: "Azure"
|
||||
---
|
||||
|
||||
# OpenClaw on Azure Linux VM
|
||||
|
||||
This guide sets up an Azure Linux VM with the Azure CLI, applies Network Security Group (NSG) hardening, configures Azure Bastion for SSH access, and installs OpenClaw.
|
||||
|
||||
## What you will do
|
||||
|
||||
@@ -6,6 +6,8 @@ read_when:
|
||||
title: "Kubernetes"
|
||||
---
|
||||
|
||||
# OpenClaw on Kubernetes
|
||||
|
||||
A minimal starting point for running OpenClaw on Kubernetes — not a production-ready deployment. It covers the core resources and is meant to be adapted to your environment.
|
||||
|
||||
## Why not Helm?
|
||||
|
||||
@@ -98,11 +98,11 @@ openclaw channels login
|
||||
|
||||
On macOS, Podman machine may make the browser appear non-local to the gateway.
|
||||
If the Control UI reports device-auth errors after launch, use the Tailscale guidance in
|
||||
[Podman and Tailscale](#podman--tailscale).
|
||||
[Podman + Tailscale](#podman--tailscale).
|
||||
|
||||
<a id="podman--tailscale"></a>
|
||||
|
||||
## Podman and Tailscale
|
||||
## Podman + Tailscale
|
||||
|
||||
For HTTPS or remote browser access, follow the main Tailscale docs.
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ Note: Binary detection is best-effort across macOS/Linux/Windows; ensure the CLI
|
||||
}
|
||||
```
|
||||
|
||||
## Notes and limits
|
||||
## Notes & limits
|
||||
|
||||
- Provider auth follows the standard model auth order (auth profiles, env vars, `models.providers.*.apiKey`).
|
||||
- Groq setup details: [Groq](/providers/groq).
|
||||
|
||||
@@ -51,7 +51,7 @@ The WhatsApp channel runs via **Baileys Web**. This document captures the curren
|
||||
- If the active primary image model already supports vision natively, OpenClaw skips the `[Image]` summary block and passes the original image to the model instead.
|
||||
- By default only the first matching image/audio/video attachment is processed; set `tools.media.<cap>.attachments` to process multiple attachments.
|
||||
|
||||
## Limits and errors
|
||||
## Limits & Errors
|
||||
|
||||
**Outbound send caps (WhatsApp web send)**
|
||||
|
||||
|
||||
@@ -337,7 +337,7 @@ const compactResult = await compactEmbeddedPiSessionDirect({
|
||||
});
|
||||
```
|
||||
|
||||
## Authentication and model resolution
|
||||
## Authentication & Model Resolution
|
||||
|
||||
### Auth profiles
|
||||
|
||||
@@ -418,7 +418,7 @@ if (cfg?.agents?.defaults?.contextPruning?.mode === "cache-ttl") {
|
||||
}
|
||||
```
|
||||
|
||||
## Streaming and block replies
|
||||
## Streaming & Block Replies
|
||||
|
||||
### Block chunking
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ Native companion apps for Windows are also planned; the Gateway is recommended v
|
||||
- Windows: [Windows](/platforms/windows)
|
||||
- Linux: [Linux](/platforms/linux)
|
||||
|
||||
## VPS and hosting
|
||||
## VPS & hosting
|
||||
|
||||
- VPS hub: [VPS hosting](/vps)
|
||||
- Fly.io: [Fly.io](/install/fly)
|
||||
|
||||
@@ -65,7 +65,7 @@ socket path is in use. You can override with:
|
||||
export PEEKABOO_BRIDGE_SOCKET=/path/to/bridge.sock
|
||||
```
|
||||
|
||||
## Security and permissions
|
||||
## Security & permissions
|
||||
|
||||
- The bridge validates **caller code signatures**; an allowlist of TeamIDs is
|
||||
enforced (Peekaboo host TeamID + OpenClaw app TeamID).
|
||||
|
||||
@@ -13,7 +13,7 @@ agent (with a session switcher for other sessions).
|
||||
- **Remote mode**: forwards the Gateway control port over SSH and uses that
|
||||
tunnel as the data plane.
|
||||
|
||||
## Launch and debugging
|
||||
## Launch & debugging
|
||||
|
||||
- Manual: Lobster menu → “Open Chat”.
|
||||
- Auto‑open for testing:
|
||||
|
||||
@@ -162,7 +162,7 @@ If `openclaw doctor` detects state under:
|
||||
|
||||
it will warn and recommend moving back to a local path.
|
||||
|
||||
## Build and dev workflow (native)
|
||||
## Build & dev workflow (native)
|
||||
|
||||
- `cd apps/macos && swift build`
|
||||
- `swift run OpenClaw` (or Xcode)
|
||||
|
||||
@@ -87,10 +87,9 @@ If your config uses `plugins.allow`, include `codex` there too:
|
||||
}
|
||||
```
|
||||
|
||||
Do not use `openai-codex/gpt-*` in config. That prefix is a legacy route that
|
||||
`openclaw doctor --fix` rewrites to `openai/gpt-*` across primary models,
|
||||
fallbacks, heartbeat/subagent/compaction overrides, hooks, channel overrides,
|
||||
and stale persisted session route pins.
|
||||
Do not use `openai-codex/gpt-*` when you mean native Codex runtime. That prefix
|
||||
is the explicit "Codex OAuth through PI" route. Config changes apply to new or
|
||||
reset sessions; existing sessions keep their recorded runtime.
|
||||
|
||||
## What this plugin changes
|
||||
|
||||
@@ -107,9 +106,7 @@ The bundled `codex` plugin contributes several separate capabilities:
|
||||
Enabling the plugin makes those capabilities available. It does **not**:
|
||||
|
||||
- start using Codex for every OpenAI model
|
||||
- convert `openai-codex/*` model refs into the native runtime without doctor
|
||||
verifying that Codex is installed, enabled, contributes the `codex` harness,
|
||||
and is OAuth-ready
|
||||
- convert `openai-codex/*` model refs into the native runtime
|
||||
- make ACP/acpx the default Codex path
|
||||
- hot-switch existing sessions that already recorded a PI runtime
|
||||
- replace OpenClaw channel delivery, session files, auth-profile storage, or
|
||||
@@ -148,10 +145,10 @@ want native app-server execution. Legacy `codex/*` model refs still auto-select
|
||||
the harness for compatibility, but runtime-backed legacy provider prefixes are
|
||||
not shown as normal model/provider choices.
|
||||
|
||||
If any configured model route is still `openai-codex/*`, `openclaw doctor --fix`
|
||||
rewrites it to `openai/*`. For matching agent routes, it sets the agent runtime
|
||||
to `codex` only when the Codex plugin is installed, enabled, contributes the
|
||||
`codex` harness, and has usable OAuth; otherwise it sets the runtime to `pi`.
|
||||
If the `codex` plugin is enabled but the primary model is still
|
||||
`openai-codex/*`, `openclaw doctor` warns instead of changing the route. That is
|
||||
intentional: `openai-codex/*` remains the PI Codex OAuth/subscription path, and
|
||||
native app-server execution stays an explicit runtime choice.
|
||||
|
||||
## Route map
|
||||
|
||||
@@ -161,18 +158,15 @@ Use this table before changing config:
|
||||
| ---------------------------------------------------- | -------------------------- | -------------------------------------- | ---------------------------- | ------------------------------ |
|
||||
| ChatGPT/Codex subscription with native Codex runtime | `openai/gpt-*` | `agentRuntime.id: "codex"` | Codex OAuth or Codex account | `Runtime: OpenAI Codex` |
|
||||
| OpenAI API through normal OpenClaw runner | `openai/gpt-*` | omitted or `runtime: "pi"` | OpenAI API key | `Runtime: OpenClaw Pi Default` |
|
||||
| Legacy config that needs doctor repair | `openai-codex/gpt-*` | repaired to `codex` or `pi` | Existing configured auth | Recheck after `doctor --fix` |
|
||||
| ChatGPT/Codex subscription through PI | `openai-codex/gpt-*` | omitted or `runtime: "pi"` | OpenAI Codex OAuth provider | `Runtime: OpenClaw Pi Default` |
|
||||
| Mixed providers with conservative auto mode | provider-specific refs | `agentRuntime.id: "auto"` | Per selected provider | Depends on selected runtime |
|
||||
| Explicit Codex ACP adapter session | ACP prompt/model dependent | `sessions_spawn` with `runtime: "acp"` | ACP backend auth | ACP task/session status |
|
||||
|
||||
The important split is provider versus runtime:
|
||||
|
||||
- `openai-codex/*` is a legacy route that doctor rewrites.
|
||||
- `agentRuntime.id: "codex"` requires the Codex harness and fails closed if it
|
||||
is unavailable.
|
||||
- `agentRuntime.id: "auto"` lets registered harnesses claim matching provider
|
||||
routes, but canonical OpenAI refs are still PI-owned unless a harness supports
|
||||
that provider/model pair.
|
||||
- `openai-codex/*` answers "which provider/auth route should PI use?"
|
||||
- `agentRuntime.id: "codex"` answers "which loop should execute this
|
||||
embedded turn?"
|
||||
- `/codex ...` answers "which native Codex conversation should this chat bind
|
||||
or control?"
|
||||
- ACP answers "which external harness process should acpx launch?"
|
||||
@@ -181,30 +175,33 @@ The important split is provider versus runtime:
|
||||
|
||||
OpenAI-family routes are prefix-specific. For the common subscription plus
|
||||
native Codex runtime setup, use `openai/*` with `agentRuntime.id: "codex"`.
|
||||
Treat `openai-codex/*` as legacy config that doctor should rewrite:
|
||||
Use `openai-codex/*` only when you intentionally want Codex OAuth through PI:
|
||||
|
||||
| Model ref | Runtime path | Use when |
|
||||
| --------------------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------- |
|
||||
| `openai/gpt-5.4` | OpenAI provider through OpenClaw/PI plumbing | You want current direct OpenAI Platform API access with `OPENAI_API_KEY`. |
|
||||
| `openai-codex/gpt-5.5` | Legacy route repaired by doctor | You are on old config; run `openclaw doctor --fix` to rewrite it. |
|
||||
| `openai-codex/gpt-5.5` | OpenAI Codex OAuth through OpenClaw/PI | You want ChatGPT/Codex subscription auth with the default PI runner. |
|
||||
| `openai/gpt-5.5` + `agentRuntime.id: "codex"` | Codex app-server harness | You want ChatGPT/Codex subscription auth with native Codex execution. |
|
||||
|
||||
GPT-5.5 can appear on both direct OpenAI API-key and Codex subscription routes
|
||||
when your account exposes them. Use `openai/gpt-5.5` with the Codex app-server
|
||||
harness for native Codex runtime, or `openai/gpt-5.5` without a Codex runtime
|
||||
override for direct API-key traffic.
|
||||
harness for native Codex runtime, `openai-codex/gpt-5.5` for PI OAuth, or
|
||||
`openai/gpt-5.5` without a Codex runtime override for direct API-key traffic.
|
||||
|
||||
Legacy `codex/gpt-*` refs remain accepted as compatibility aliases. Doctor
|
||||
compatibility migration rewrites legacy runtime refs to canonical model refs
|
||||
and records the runtime policy separately. New native app-server harness configs
|
||||
should use `openai/gpt-*` plus `agentRuntime.id: "codex"`.
|
||||
compatibility migration rewrites legacy primary runtime refs to canonical model
|
||||
refs and records the runtime policy separately, while fallback-only legacy refs
|
||||
are left unchanged because runtime is configured for the whole agent container.
|
||||
New PI Codex OAuth configs should use `openai-codex/gpt-*`; new native
|
||||
app-server harness configs should use `openai/gpt-*` plus
|
||||
`agentRuntime.id: "codex"`.
|
||||
|
||||
`agents.defaults.imageModel` follows the same prefix split. Use
|
||||
`openai/gpt-*` for the normal OpenAI route and `codex/gpt-*` when image
|
||||
understanding should run through a bounded Codex app-server turn. Do not use
|
||||
`openai-codex/gpt-*`; doctor rewrites that legacy prefix to `openai/gpt-*`. The
|
||||
Codex app-server model must advertise image input support; text-only Codex
|
||||
models fail before the media turn starts.
|
||||
`openai-codex/gpt-*` when image understanding should run through the OpenAI
|
||||
Codex OAuth provider path. Use `codex/gpt-*` when image understanding should run
|
||||
through a bounded Codex app-server turn. The Codex app-server model must
|
||||
advertise image input support; text-only Codex models fail before the media turn
|
||||
starts.
|
||||
|
||||
Use `/status` to confirm the effective harness for the current session. If the
|
||||
selection is surprising, enable debug logging for the `agents/harness` subsystem
|
||||
@@ -214,20 +211,22 @@ in `auto` mode, each plugin candidate's support result.
|
||||
|
||||
### What doctor warnings mean
|
||||
|
||||
`openclaw doctor` warns when configured model refs or persisted session route
|
||||
state still use `openai-codex/*`. `openclaw doctor --fix` rewrites those routes
|
||||
to:
|
||||
`openclaw doctor` warns when all of these are true:
|
||||
|
||||
- `openai/<model>`
|
||||
- `agentRuntime.id: "codex"` when Codex is installed, enabled, contributes the
|
||||
`codex` harness, and has usable OAuth
|
||||
- `agentRuntime.id: "pi"` otherwise
|
||||
- the bundled `codex` plugin is enabled or allowed
|
||||
- an agent's primary model is `openai-codex/*`
|
||||
- that agent's effective runtime is not `codex`
|
||||
|
||||
The `codex` route forces the native Codex harness. The `pi` route keeps the
|
||||
agent on the default OpenClaw runner instead of enabling or installing Codex as
|
||||
a side effect of legacy-route cleanup.
|
||||
Doctor also repairs stale persisted session pins across discovered agent session
|
||||
stores so old conversations do not stay wedged on the removed route.
|
||||
That warning exists because users often expect "Codex plugin enabled" to imply
|
||||
"native Codex app-server runtime." OpenClaw does not make that leap. The warning
|
||||
means:
|
||||
|
||||
- **No change is required** if you intended ChatGPT/Codex OAuth through PI.
|
||||
- Change the model to `openai/<model>` and set
|
||||
`agentRuntime.id: "codex"` if you intended native app-server
|
||||
execution.
|
||||
- Existing sessions still need `/new` or `/reset` after a runtime change,
|
||||
because session runtime pins are sticky.
|
||||
|
||||
Harness selection is not a live session control. When an embedded turn runs,
|
||||
OpenClaw records the selected harness id on that session and keeps using it for
|
||||
@@ -350,7 +349,7 @@ Agents should route user requests by intent, not by the word "Codex" alone:
|
||||
| "File a support report for a bad Codex run" | `/diagnostics [note]` |
|
||||
| "Only send Codex feedback for this attached thread" | `/codex diagnostics [note]` |
|
||||
| "Use my ChatGPT/Codex subscription with Codex runtime" | `openai/*` plus `agentRuntime.id: "codex"` |
|
||||
| "Repair old `openai-codex/*` config/session pins" | `openclaw doctor --fix` |
|
||||
| "Use my ChatGPT/Codex subscription through PI" | `openai-codex/*` model refs |
|
||||
| "Run Codex through ACP/acpx" | ACP `sessions_spawn({ runtime: "acp", ... })` |
|
||||
| "Start Claude Code/Gemini/OpenCode/Cursor in a thread" | ACP/acpx, not `/codex` and not native sub-agents |
|
||||
|
||||
|
||||
@@ -1,424 +0,0 @@
|
||||
---
|
||||
summary: "Message lifecycle API for channel plugins, including durable sends, receipts, live preview, receive ack policy, and legacy migration"
|
||||
title: "Channel message API"
|
||||
read_when:
|
||||
- You are building or refactoring a messaging channel plugin
|
||||
- You need durable final reply delivery, receipts, live preview finalization, or receive acknowledgement policy
|
||||
- You are migrating from legacy reply pipeline or inbound reply dispatch helpers
|
||||
---
|
||||
|
||||
# Channel Message API
|
||||
|
||||
Channel plugins should expose one `message` adapter from
|
||||
`openclaw/plugin-sdk/channel-message`. The adapter describes the native message
|
||||
lifecycle that the platform supports:
|
||||
|
||||
```text
|
||||
receive -> route and record -> agent turn -> durable final send
|
||||
send -> render batch -> platform I/O -> receipt -> lifecycle side effects
|
||||
live preview -> final edit or fallback -> receipt
|
||||
```
|
||||
|
||||
Core owns queueing, durability, generic retry policy, hooks, receipts, and the
|
||||
shared `message` tool. The plugin owns native send/edit/delete calls, target
|
||||
normalization, platform threading, selected quotes, notification flags, account
|
||||
state, and platform-specific side effects.
|
||||
|
||||
Use this page together with [Building channel plugins](/plugins/sdk-channel-plugins).
|
||||
|
||||
The `channel-message` subpath is intentionally cheap enough for hot plugin
|
||||
bootstrap files such as `channel.ts`: it exposes adapter contracts, capability
|
||||
proofs, receipts, and compatibility facades without loading outbound delivery.
|
||||
Runtime delivery helpers are available from
|
||||
`openclaw/plugin-sdk/channel-message-runtime` for monitor/send code paths that
|
||||
are already doing asynchronous message I/O.
|
||||
|
||||
## Minimal Adapter
|
||||
|
||||
Most new channel plugins can start with a small adapter:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
defineChannelMessageAdapter,
|
||||
createMessageReceiptFromOutboundResults,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
|
||||
export const demoMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "demo",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
replyTo: true,
|
||||
thread: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: async ({ cfg, to, text, accountId, replyToId, threadId, signal }) => {
|
||||
const sent = await sendDemoMessage({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? undefined,
|
||||
threadId: threadId == null ? undefined : String(threadId),
|
||||
signal,
|
||||
});
|
||||
|
||||
return {
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "demo", messageId: sent.id, conversationId: to }],
|
||||
kind: "text",
|
||||
threadId: threadId == null ? undefined : String(threadId),
|
||||
replyToId: replyToId ?? undefined,
|
||||
}),
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Then attach it to the channel plugin:
|
||||
|
||||
```typescript
|
||||
export const demoPlugin = createChatChannelPlugin({
|
||||
base: {
|
||||
id: "demo",
|
||||
message: demoMessageAdapter,
|
||||
// other channel plugin fields
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Only declare capabilities that the adapter really preserves. Every declared
|
||||
capability should have a contract test.
|
||||
|
||||
## Outbound Bridge
|
||||
|
||||
If the channel already has a compatible `outbound` adapter, prefer deriving the
|
||||
message adapter instead of duplicating send code:
|
||||
|
||||
```typescript
|
||||
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
|
||||
|
||||
const demoMessageAdapter = createChannelMessageAdapterFromOutbound({
|
||||
id: "demo",
|
||||
outbound: demoOutboundAdapter,
|
||||
});
|
||||
```
|
||||
|
||||
The bridge converts old outbound send results into `MessageReceipt` values. New
|
||||
code should pass receipts end to end and only derive legacy ids at compatibility
|
||||
edges with `listMessageReceiptPlatformIds(...)` or
|
||||
`resolveMessageReceiptPrimaryId(...)`.
|
||||
If no receive policy is supplied, `createChannelMessageAdapterFromOutbound(...)`
|
||||
uses `manual` receive acknowledgement policy. That makes plugin-owned platform
|
||||
acknowledgement explicit without changing channels that acknowledge webhooks,
|
||||
sockets, or polling offsets outside generic receive context.
|
||||
|
||||
## Message Tool Sends
|
||||
|
||||
The shared `message(action="send")` path should use the same core delivery
|
||||
lifecycle as final replies. If a channel needs provider-specific shaping for the
|
||||
tool send, implement `actions.prepareSendPayload(...)` instead of sending from
|
||||
`actions.handleAction(...)`.
|
||||
|
||||
`prepareSendPayload(...)` receives the normalized core `ReplyPayload` plus the
|
||||
full action context. Return a payload with channel-specific data in
|
||||
`payload.channelData.<channel>` and let core call `sendMessage(...)`,
|
||||
`deliverOutboundPayloads(...)`, the write-ahead queue, message-sending hooks,
|
||||
retry, recovery, and ack cleanup.
|
||||
|
||||
Return `null` only when the send cannot be represented as a durable payload, for
|
||||
example because it contains a non-serializable component factory. Core will keep
|
||||
the legacy plugin action fallback for compatibility, but new channel send
|
||||
features should be expressible as durable payload data.
|
||||
|
||||
```typescript
|
||||
export const demoActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: () => ({ actions: ["send"], capabilities: ["presentation"] }),
|
||||
prepareSendPayload: ({ ctx, payload }) => {
|
||||
if (ctx.action !== "send") {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
...payload,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
demo: {
|
||||
...(payload.channelData?.demo as object | undefined),
|
||||
nativeCard: ctx.params.card,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
The outbound adapter then reads `payload.channelData.demo` inside `sendPayload`.
|
||||
This keeps platform-specific rendering in the plugin while core still owns
|
||||
persist, retry, recover, hooks, and ack.
|
||||
|
||||
Prepared `message(action="send")` payloads and generic final-reply delivery use
|
||||
core delivery with best-effort queueing by default. Required durable queueing is
|
||||
only valid after core verifies the channel can reconcile a send whose outcome is
|
||||
unknown after a crash. If the adapter cannot implement `reconcileUnknownSend`,
|
||||
keep the prepared send path best-effort; core will still try the write-ahead
|
||||
queue, but queue persistence or uncertain crash recovery is not part of the
|
||||
required delivery contract.
|
||||
|
||||
## Durable Final Capabilities
|
||||
|
||||
Durable final delivery is opt in per side effect. Core will only use generic
|
||||
durable delivery when the adapter declares every capability needed by the
|
||||
payload and delivery options.
|
||||
|
||||
| Capability | Declare when |
|
||||
| ---------------------- | ------------------------------------------------------------------------------------ |
|
||||
| `text` | The adapter can send text and return a receipt. |
|
||||
| `media` | Media sends return receipts for every visible platform message. |
|
||||
| `payload` | The adapter preserves rich reply payload semantics, not only text and one media URL. |
|
||||
| `replyTo` | Native reply targets reach the platform. |
|
||||
| `thread` | Native thread, topic, or channel thread targets reach the platform. |
|
||||
| `silent` | Notification suppression reaches the platform. |
|
||||
| `nativeQuote` | Selected quote metadata reaches the platform. |
|
||||
| `messageSendingHooks` | Core message-sending hooks can cancel or rewrite content before platform I/O. |
|
||||
| `batch` | Multi-part rendered batches are replayable as one durable plan. |
|
||||
| `reconcileUnknownSend` | The adapter can resolve `unknown_after_send` recovery without blind replay. |
|
||||
| `afterSendSuccess` | Channel-local after-send side effects run once. |
|
||||
| `afterCommit` | Channel-local after-commit side effects run once. |
|
||||
|
||||
Best-effort final delivery does not require `reconcileUnknownSend`; it uses the
|
||||
shared lifecycle when the adapter preserves the payload's visible semantics, and
|
||||
falls back to direct platform I/O if queue persistence is unavailable. Required
|
||||
durable final delivery must explicitly require `reconcileUnknownSend`. If the
|
||||
adapter cannot determine whether a started/unknown send reached the platform,
|
||||
do not declare that capability; core will reject required durable delivery
|
||||
before queueing.
|
||||
|
||||
When a caller needs durable delivery, derive requirements instead of building
|
||||
maps by hand:
|
||||
|
||||
```typescript
|
||||
import { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-message";
|
||||
|
||||
const requiredCapabilities = deriveDurableFinalDeliveryRequirements({
|
||||
payload,
|
||||
replyToId,
|
||||
threadId,
|
||||
silent,
|
||||
payloadTransport: true,
|
||||
extraCapabilities: {
|
||||
nativeQuote: hasSelectedQuote(payload),
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
`messageSendingHooks` is required by default. Set `messageSendingHooks: false`
|
||||
only for a path that intentionally cannot run global message-sending hooks.
|
||||
|
||||
## Durable Send Contract
|
||||
|
||||
A durable final send has stricter semantics than legacy channel-owned delivery:
|
||||
|
||||
- Create the durable intent before platform I/O.
|
||||
- If durable delivery returns a handled result, do not fall back to legacy send.
|
||||
- Treat hook cancellation and no-send results as terminal.
|
||||
- Treat `unsupported` as a pre-intent result only.
|
||||
- For required durability, fail before platform I/O if the queue cannot record
|
||||
that platform send has started.
|
||||
- For required final delivery and required prepared message-tool sends,
|
||||
preflight `reconcileUnknownSend`; recovery must be able to ack an
|
||||
already-sent message or replay only after the adapter proves the original send
|
||||
did not happen.
|
||||
- For `best_effort`, queue write failures may fall back to direct platform I/O.
|
||||
- Forward abort signals to media loading and platform sends.
|
||||
- Run after-commit hooks after queue ack; direct best-effort fallback runs them
|
||||
after successful platform I/O because there is no durable queue commit.
|
||||
- Return receipts for every visible platform message id.
|
||||
- Use `reconcileUnknownSend` when a platform can check whether an uncertain send
|
||||
already reached the user.
|
||||
|
||||
This contract avoids duplicate sends after crashes and avoids bypassing
|
||||
message-sending cancellation hooks.
|
||||
|
||||
## Receipts
|
||||
|
||||
`MessageReceipt` is the new internal record of what the platform accepted:
|
||||
|
||||
```typescript
|
||||
type MessageReceipt = {
|
||||
primaryPlatformMessageId?: string;
|
||||
platformMessageIds: string[];
|
||||
parts: MessageReceiptPart[];
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
editToken?: string;
|
||||
deleteToken?: string;
|
||||
sentAt: number;
|
||||
raw?: readonly MessageReceiptSourceResult[];
|
||||
};
|
||||
```
|
||||
|
||||
Use `createMessageReceiptFromOutboundResults(...)` when adapting an existing
|
||||
send result. Use `createPreviewMessageReceipt(...)` when a live preview message
|
||||
becomes the final receipt. Avoid adding new owner-local `messageIds` fields.
|
||||
Legacy `ChannelDeliveryResult.messageIds` is still produced at compatibility
|
||||
edges.
|
||||
|
||||
## Live Preview
|
||||
|
||||
Channels that stream draft previews or progress updates should declare live
|
||||
capabilities:
|
||||
|
||||
```typescript
|
||||
const demoMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "demo",
|
||||
live: {
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
previewFinalization: true,
|
||||
progressUpdates: true,
|
||||
quietFinalization: true,
|
||||
},
|
||||
finalizer: {
|
||||
capabilities: {
|
||||
finalEdit: true,
|
||||
normalFallback: true,
|
||||
discardPending: true,
|
||||
previewReceipt: true,
|
||||
retainOnAmbiguousFailure: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use `defineFinalizableLivePreviewAdapter(...)` and
|
||||
`deliverWithFinalizableLivePreviewAdapter(...)` for runtime finalization. The
|
||||
finalizer decides whether the final reply edits the preview in place, sends a
|
||||
normal fallback, discards pending preview state, keeps an ambiguous failed edit
|
||||
without duplicating the message, and returns the final receipt.
|
||||
|
||||
## Receive Ack Policy
|
||||
|
||||
Inbound receivers that control platform acknowledgement timing should declare
|
||||
receive policy:
|
||||
|
||||
```typescript
|
||||
const demoMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "demo",
|
||||
receive: {
|
||||
defaultAckPolicy: "after_agent_dispatch",
|
||||
supportedAckPolicies: ["after_receive_record", "after_agent_dispatch"],
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Adapters that do not declare receive policy default to:
|
||||
|
||||
```typescript
|
||||
{
|
||||
receive: {
|
||||
defaultAckPolicy: "manual",
|
||||
supportedAckPolicies: ["manual"],
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
Use the default when the platform has no acknowledgement to defer, already
|
||||
acknowledges before asynchronous processing, or needs protocol-specific response
|
||||
semantics. Declare one of the staged policies only when the receiver actually
|
||||
uses receive context to move platform acknowledgement later.
|
||||
|
||||
Policies:
|
||||
|
||||
| Policy | Use when |
|
||||
| ---------------------- | ---------------------------------------------------------------------------------------- |
|
||||
| `after_receive_record` | The platform can be acknowledged after the inbound event is parsed and recorded. |
|
||||
| `after_agent_dispatch` | The platform should wait until the agent dispatch has been accepted. |
|
||||
| `after_durable_send` | The platform should wait until final delivery has a durable decision. |
|
||||
| `manual` | The plugin owns acknowledgement because platform semantics do not match a generic stage. |
|
||||
|
||||
Use `createMessageReceiveContext(...)` in receivers that defer ack state, and
|
||||
`shouldAckMessageAfterStage(...)` when the receiver needs to test whether a
|
||||
stage has satisfied the configured policy.
|
||||
|
||||
## Contract Tests
|
||||
|
||||
Capability declarations are part of the plugin contract. Back them with tests:
|
||||
|
||||
```typescript
|
||||
import {
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
verifyChannelMessageReceiveAckPolicyAdapterProofs,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
|
||||
it("backs declared message capabilities", async () => {
|
||||
await expect(
|
||||
verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "demo",
|
||||
adapter: demoMessageAdapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await demoMessageAdapter.send!.text!(textCtx);
|
||||
expect(result.receipt.platformMessageIds).toContain("msg-1");
|
||||
},
|
||||
replyTo: async () => {
|
||||
await demoMessageAdapter.send!.text!({ ...textCtx, replyToId: "parent-1" });
|
||||
expect(sendDemoMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
replyToId: "parent-1",
|
||||
}),
|
||||
);
|
||||
},
|
||||
messageSendingHooks: () => {
|
||||
expect(demoMessageAdapter.durableFinal!.capabilities!.messageSendingHooks).toBe(true);
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toContainEqual({ capability: "text", status: "verified" });
|
||||
});
|
||||
```
|
||||
|
||||
Add live and receive proof suites when the adapter declares those features. A
|
||||
missing proof should fail the test rather than silently widening the durable
|
||||
surface.
|
||||
|
||||
## Deprecated Compatibility APIs
|
||||
|
||||
These APIs remain importable for third-party compatibility. Do not use them for
|
||||
new channel code.
|
||||
|
||||
| Deprecated API | Replacement |
|
||||
| -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| `openclaw/plugin-sdk/channel-reply-pipeline` | `openclaw/plugin-sdk/channel-message` |
|
||||
| `createChannelTurnReplyPipeline(...)` | `createChannelMessageReplyPipeline(...)` for compatibility dispatchers, or a `message` adapter for new channel code |
|
||||
| `deliverDurableInboundReplyPayload(...)` | `deliverInboundReplyWithMessageSendContext(...)` from `openclaw/plugin-sdk/channel-message-runtime` |
|
||||
| `dispatchInboundReplyWithBase(...)` | `dispatchChannelMessageReplyWithBase(...)` only for compatibility dispatchers |
|
||||
| `recordInboundSessionAndDispatchReply(...)` | `recordChannelMessageReplyDispatch(...)` only for compatibility dispatchers |
|
||||
| `resolveChannelSourceReplyDeliveryMode(...)` | `resolveChannelMessageSourceReplyDeliveryMode(...)` |
|
||||
| `deliverFinalizableDraftPreview(...)` | `defineFinalizableLivePreviewAdapter(...)` plus `deliverWithFinalizableLivePreviewAdapter(...)` |
|
||||
| `DraftPreviewFinalizerDraft` | `LivePreviewFinalizerDraft` |
|
||||
| `DraftPreviewFinalizerResult` | `LivePreviewFinalizerResult` |
|
||||
|
||||
Compatibility dispatchers can still use `createReplyPrefixContext(...)`,
|
||||
`createReplyPrefixOptions(...)`, and `createTypingCallbacks(...)` through the
|
||||
message facade. New lifecycle code should avoid the old
|
||||
`channel-reply-pipeline` subpath.
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
1. Add `message: defineChannelMessageAdapter(...)` or
|
||||
`message: createChannelMessageAdapterFromOutbound(...)` to the channel plugin.
|
||||
2. Return `MessageReceipt` from text, media, and payload sends.
|
||||
3. Declare only capabilities backed by native behavior and tests.
|
||||
4. Replace hand-written durable requirement maps with
|
||||
`deriveDurableFinalDeliveryRequirements(...)`.
|
||||
5. Move preview finalization through the live preview helpers when the channel
|
||||
edits draft messages in place.
|
||||
6. Declare receive ack policy only when the receiver can really defer platform
|
||||
acknowledgement.
|
||||
7. Keep legacy reply dispatch helpers only at compatibility edges.
|
||||
@@ -34,46 +34,6 @@ shared `message` tool in core. Your plugin owns:
|
||||
Core owns the shared message tool, prompt wiring, the outer session-key shape,
|
||||
generic `:thread:` bookkeeping, and dispatch.
|
||||
|
||||
New channel plugins should also expose a `message` adapter with
|
||||
`defineChannelMessageAdapter` from `openclaw/plugin-sdk/channel-message`. The
|
||||
adapter declares which durable final-send capabilities the native transport
|
||||
actually supports and points text/media sends at the same transport functions as
|
||||
the legacy `outbound` adapter. Only declare a capability when a contract test
|
||||
proves the native side effect and returned receipt.
|
||||
For the full API contract, examples, capability matrix, receipt rules, live
|
||||
preview finalization, receive ack policy, tests, and migration table, see
|
||||
[Channel message API](/plugins/sdk-channel-message).
|
||||
If the existing `outbound` adapter already has the right send methods and
|
||||
capability metadata, use `createChannelMessageAdapterFromOutbound(...)` to
|
||||
derive the `message` adapter instead of hand-writing another bridge.
|
||||
Adapter sends should return `MessageReceipt` values. When compatibility code
|
||||
still needs legacy ids, derive them with `listMessageReceiptPlatformIds(...)`
|
||||
or `resolveMessageReceiptPrimaryId(...)` instead of keeping parallel
|
||||
`messageIds` fields in new lifecycle code.
|
||||
Preview-capable channels should also declare `message.live.capabilities` with
|
||||
the exact live lifecycle they own, such as `draftPreview`,
|
||||
`previewFinalization`, `progressUpdates`, `nativeStreaming`, or
|
||||
`quietFinalization`. Channels that finalize a draft preview in place should
|
||||
also declare `message.live.finalizer.capabilities`, such as `finalEdit`,
|
||||
`normalFallback`, `discardPending`, `previewReceipt`, and
|
||||
`retainOnAmbiguousFailure`, and route the runtime logic through
|
||||
`defineFinalizableLivePreviewAdapter(...)` plus
|
||||
`deliverWithFinalizableLivePreviewAdapter(...)`. Keep those capabilities backed
|
||||
by `verifyChannelMessageLiveCapabilityAdapterProofs(...)` and
|
||||
`verifyChannelMessageLiveFinalizerProofs(...)` tests so native preview,
|
||||
progress, edit, fallback/retention, cleanup, and receipt behavior cannot drift
|
||||
silently.
|
||||
Inbound receivers that defer platform acknowledgements should declare
|
||||
`message.receive.defaultAckPolicy` and `supportedAckPolicies` instead of hiding
|
||||
ack timing in monitor-local state. Cover every declared policy with
|
||||
`verifyChannelMessageReceiveAckPolicyAdapterProofs(...)`.
|
||||
|
||||
Legacy reply/turn helpers such as `createChannelTurnReplyPipeline`,
|
||||
`dispatchInboundReplyWithBase`, and `recordInboundSessionAndDispatchReply`
|
||||
remain available for compatibility dispatchers. Do not use those names for new
|
||||
channel code; new plugins should start with the `message` adapter, receipts, and
|
||||
receive/send lifecycle helpers on `openclaw/plugin-sdk/channel-message`.
|
||||
|
||||
If your channel supports typing indicators outside inbound replies, expose
|
||||
`heartbeat.sendTyping(...)` on the channel plugin. Core calls it with the
|
||||
resolved heartbeat delivery target before the heartbeat model run starts and
|
||||
@@ -90,13 +50,6 @@ Prefer returning an action-keyed map such as
|
||||
inherit another action's media args. A flat array still works for params that
|
||||
are intentionally shared across every exposed action.
|
||||
|
||||
If your channel needs provider-specific shaping for `message(action="send")`,
|
||||
prefer `actions.prepareSendPayload(...)`. Put native cards, blocks, embeds, or
|
||||
other durable data under `payload.channelData.<channel>` and let core perform
|
||||
the actual send through the outbound/message adapter. Use
|
||||
`actions.handleAction(...)` for send only as a compatibility fallback for
|
||||
payloads that cannot be serialized and retried.
|
||||
|
||||
If your platform stores extra scope inside conversation ids, keep that parsing
|
||||
in the plugin with `messaging.resolveSessionConversation(...)`. That is the
|
||||
canonical hook for mapping `rawId` to the base conversation id, optional thread
|
||||
|
||||
@@ -312,23 +312,17 @@ The kernel does not call the platform directly. The channel hands the kernel a `
|
||||
type ChannelTurnDeliveryAdapter = {
|
||||
deliver(payload: ReplyPayload, info: ChannelDeliveryInfo): Promise<ChannelDeliveryResult | void>;
|
||||
onError?(err: unknown, info: { kind: string }): void;
|
||||
durable?: false | DurableInboundReplyDeliveryOptions;
|
||||
};
|
||||
|
||||
type ChannelDeliveryResult = {
|
||||
messageIds?: string[];
|
||||
receipt?: MessageReceipt;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
visibleReplySent?: boolean;
|
||||
};
|
||||
```
|
||||
|
||||
`deliver` is called once per buffered reply chunk. During the message-lifecycle migration, assembled channel-turn delivery is channel-owned by default: an omitted `durable` field means the kernel must call `deliver` directly and must not route through generic outbound delivery. Set `durable` only after the channel has been audited to prove the generic send path preserves the old delivery behavior, including reply/thread targets, media handling, sent-message/self-echo caches, status cleanup, and returned message ids. `durable: false` remains a compatibility spelling for "use the channel-owned callback", but unmigrated channels should not need to add it. Return platform message ids when the channel has them so the dispatcher can preserve thread anchors and edit later chunks; newer delivery paths should also return `receipt` so recovery, preview finalization, and duplicate suppression can move off `messageIds`. For observe-only turns, return `{ visibleReplySent: false }` or use `createNoopChannelTurnDeliveryAdapter()`.
|
||||
|
||||
Channels using `runPrepared` with a fully channel-owned dispatcher do not have a `ChannelTurnDeliveryAdapter`. Those dispatchers are not durable by default. They should keep their direct delivery path until they explicitly opt in to the new send context with a complete target, replay-safe adapter, receipt contract, and channel side-effect hooks.
|
||||
|
||||
Public compatibility helpers such as `recordInboundSessionAndDispatchReply`, `dispatchInboundReplyWithBase`, and direct-DM helpers must stay behavior-preserving during migration. They should not call generic durable delivery before caller-owned `deliver` or `reply` callbacks.
|
||||
`deliver` is called once per buffered reply chunk. Return platform message ids when the channel has them so the dispatcher can preserve thread anchors and edit later chunks. For observe-only turns, return `{ visibleReplySent: false }` or use `createNoopChannelTurnDeliveryAdapter()`.
|
||||
|
||||
## Record options
|
||||
|
||||
@@ -394,7 +388,6 @@ Backward compatibility rules apply: new fact fields are additive, admission kind
|
||||
|
||||
## Related
|
||||
|
||||
- [Message lifecycle refactor](/concepts/message-lifecycle-refactor) for the planned send/receive/live lifecycle that will wrap this kernel
|
||||
- [Building channel plugins](/plugins/sdk-channel-plugins) for the broader channel plugin contract
|
||||
- [Plugin runtime helpers](/plugins/sdk-runtime) for other `runtime.*` surfaces
|
||||
- [Plugin internals](/plugins/architecture-internals) for load pipeline and registry mechanics
|
||||
|
||||
@@ -56,7 +56,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
|
||||
| `plugin-sdk/account-resolution` | Account lookup + default-fallback helpers |
|
||||
| `plugin-sdk/account-helpers` | Narrow account-list/account-action helpers |
|
||||
| `plugin-sdk/channel-pairing` | `createChannelPairingController` |
|
||||
| `plugin-sdk/channel-reply-pipeline` | Legacy reply pipeline helpers. New channel reply pipeline code should use `createChannelMessageReplyPipeline` and `resolveChannelMessageSourceReplyDeliveryMode` from `plugin-sdk/channel-message`. |
|
||||
| `plugin-sdk/channel-reply-pipeline` | `createChannelReplyPipeline`, `resolveChannelSourceReplyDeliveryMode` |
|
||||
| `plugin-sdk/channel-config-helpers` | `createHybridChannelConfigAdapter`, `resolveChannelDmAccess`, `resolveChannelDmAllowFrom`, `resolveChannelDmPolicy`, `normalizeChannelDmPolicy`, `normalizeLegacyDmAliases` |
|
||||
| `plugin-sdk/channel-config-schema` | Shared channel config schema primitives plus Zod and direct JSON/TypeBox builders |
|
||||
| `plugin-sdk/bundled-channel-config-schema` | Bundled OpenClaw channel config schemas for maintained bundled plugins only |
|
||||
@@ -64,11 +64,9 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
|
||||
| `plugin-sdk/telegram-command-config` | Telegram custom-command normalization/validation helpers with bundled-contract fallback |
|
||||
| `plugin-sdk/command-gating` | Narrow command authorization gate helpers |
|
||||
| `plugin-sdk/channel-policy` | `resolveChannelGroupRequireMention` |
|
||||
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, and legacy draft stream lifecycle helpers. New preview finalization code should use `plugin-sdk/channel-message`. |
|
||||
| `plugin-sdk/channel-message` | Cheap message lifecycle contract helpers such as `defineChannelMessageAdapter`, `createChannelMessageAdapterFromOutbound`, `createReplyPrefixContext`, `resolveChannelMessageSourceReplyDeliveryMode`, compatibility facades, durable-final capability derivation, capability proof helpers for send/receipt/side-effect capabilities, `MessageReceiveContext`, receive ack policy proofs, `defineFinalizableLivePreviewAdapter`, `deliverWithFinalizableLivePreviewAdapter`, live-preview and live-finalizer capability proofs, durable recovery state, `RenderedMessageBatch`, message receipt types, and receipt id helpers. See [Channel message API](/plugins/sdk-channel-message). Legacy `createChannelTurnReplyPipeline` remains only for compatibility dispatchers. |
|
||||
| `plugin-sdk/channel-message-runtime` | Runtime delivery helpers that may load outbound delivery, including `deliverInboundReplyWithMessageSendContext`, `sendDurableMessageBatch`, `withDurableMessageSendContext`, `dispatchChannelMessageReplyWithBase`, and `recordChannelMessageReplyDispatch`. Use from monitor/send runtime modules, not hot plugin bootstrap files. |
|
||||
| `plugin-sdk/channel-lifecycle` | `createAccountStatusSink`, `createChannelRunQueue`, draft stream lifecycle/finalization helpers |
|
||||
| `plugin-sdk/inbound-envelope` | Shared inbound route + envelope builder helpers |
|
||||
| `plugin-sdk/inbound-reply-dispatch` | Legacy shared inbound record-and-dispatch helpers, visible/final dispatch predicates, and deprecated `deliverDurableInboundReplyPayload` compatibility for prepared channel dispatchers. New channel receive/dispatch code should import runtime lifecycle helpers from `plugin-sdk/channel-message-runtime`. |
|
||||
| `plugin-sdk/inbound-reply-dispatch` | Shared inbound record-and-dispatch helpers |
|
||||
| `plugin-sdk/messaging-targets` | Target parsing/matching helpers |
|
||||
| `plugin-sdk/outbound-media` | Shared outbound media loading helpers |
|
||||
| `plugin-sdk/outbound-send-deps` | Lightweight outbound send dependency lookup for channel adapters |
|
||||
|
||||
@@ -6,42 +6,21 @@ read_when:
|
||||
- You need Model Studio or DashScope API key setup for video generation
|
||||
---
|
||||
|
||||
OpenClaw ships a bundled `alibaba` plugin that registers a video-generation provider for Wan models on Alibaba Model Studio (the international name for DashScope). The plugin is enabled by default; you only need to set an API key.
|
||||
OpenClaw ships a bundled `alibaba` video-generation provider for Wan models on
|
||||
Alibaba Model Studio / DashScope.
|
||||
|
||||
| Property | Value |
|
||||
| ---------------- | ------------------------------------------------------------------------------- |
|
||||
| Provider id | `alibaba` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env vars | `MODELSTUDIO_API_KEY` → `DASHSCOPE_API_KEY` → `QWEN_API_KEY` (first match wins) |
|
||||
| Onboarding flag | `--auth-choice alibaba-model-studio-api-key` |
|
||||
| Direct CLI flag | `--alibaba-model-studio-api-key <key>` |
|
||||
| Default model | `alibaba/wan2.6-t2v` |
|
||||
| Default base URL | `https://dashscope-intl.aliyuncs.com` |
|
||||
- Provider: `alibaba`
|
||||
- Preferred auth: `MODELSTUDIO_API_KEY`
|
||||
- Also accepted: `DASHSCOPE_API_KEY`, `QWEN_API_KEY`
|
||||
- API: DashScope / Model Studio async video generation
|
||||
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step title="Set an API key">
|
||||
Use onboarding to store the key against the `alibaba` provider:
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice alibaba-model-studio-api-key
|
||||
openclaw onboard --auth-choice qwen-standard-api-key
|
||||
```
|
||||
|
||||
Or pass the key directly during install/onboarding:
|
||||
|
||||
```bash
|
||||
openclaw onboard --alibaba-model-studio-api-key <your-key>
|
||||
```
|
||||
|
||||
Or export any of the accepted env vars before starting the Gateway:
|
||||
|
||||
```bash
|
||||
export MODELSTUDIO_API_KEY=sk-...
|
||||
# or DASHSCOPE_API_KEY=...
|
||||
# or QWEN_API_KEY=...
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step title="Set a default video model">
|
||||
```json5
|
||||
@@ -56,86 +35,66 @@ OpenClaw ships a bundled `alibaba` plugin that registers a video-generation prov
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step title="Verify the provider is configured">
|
||||
<Step title="Verify the provider is available">
|
||||
```bash
|
||||
openclaw models list --provider alibaba
|
||||
```
|
||||
|
||||
The list should include all five bundled Wan models. If `MODELSTUDIO_API_KEY` is unresolved, `openclaw models status --json` reports the missing credential under `auth.unusableProfiles`.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
<Note>
|
||||
The Alibaba plugin and the [Qwen plugin](/providers/qwen) both authenticate against DashScope and accept overlapping env vars. Use `alibaba/...` model ids to drive the dedicated Wan video surface; use `qwen/...` ids when you want Qwen's chat, embedding, or media-understanding surface.
|
||||
Any of the accepted auth keys (`MODELSTUDIO_API_KEY`, `DASHSCOPE_API_KEY`, `QWEN_API_KEY`) will work. The `qwen-standard-api-key` onboarding choice configures the shared DashScope credential.
|
||||
</Note>
|
||||
|
||||
## Built-in Wan models
|
||||
|
||||
The bundled `alibaba` provider currently registers:
|
||||
|
||||
| Model ref | Mode |
|
||||
| -------------------------- | ------------------------- |
|
||||
| `alibaba/wan2.6-t2v` | Text-to-video (default) |
|
||||
| `alibaba/wan2.6-t2v` | Text-to-video |
|
||||
| `alibaba/wan2.6-i2v` | Image-to-video |
|
||||
| `alibaba/wan2.6-r2v` | Reference-to-video |
|
||||
| `alibaba/wan2.6-r2v-flash` | Reference-to-video (fast) |
|
||||
| `alibaba/wan2.7-r2v` | Reference-to-video |
|
||||
|
||||
## Capabilities and limits
|
||||
## Current limits
|
||||
|
||||
The bundled provider mirrors DashScope's Wan video API caps. All three modes share the same per-request video count and duration cap; only the input shape differs.
|
||||
|
||||
| Mode | Max output videos | Max input images | Max input videos | Max duration | Supported controls |
|
||||
| ------------------ | ----------------- | ---------------- | ---------------- | ------------ | --------------------------------------------------------- |
|
||||
| Text-to-video | 1 | n/a | n/a | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
|
||||
| Image-to-video | 1 | 1 | n/a | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
|
||||
| Reference-to-video | 1 | n/a | 4 | 10 s | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
|
||||
|
||||
When a request omits `durationSeconds`, the provider sends DashScope's accepted default of **5 seconds**. Set `durationSeconds` explicitly on the [video generation tool](/tools/video-generation) to extend up to 10 s.
|
||||
| Parameter | Limit |
|
||||
| --------------------- | --------------------------------------------------------- |
|
||||
| Output videos | Up to **1** per request |
|
||||
| Input images | Up to **1** |
|
||||
| Input videos | Up to **4** |
|
||||
| Duration | Up to **10 seconds** |
|
||||
| Supported controls | `size`, `aspectRatio`, `resolution`, `audio`, `watermark` |
|
||||
| Reference image/video | Remote `http(s)` URLs only |
|
||||
|
||||
<Warning>
|
||||
Reference image and video inputs must be remote `http(s)` URLs. Local file paths are not accepted by DashScope's reference modes; upload to object storage first or use the [media tool](/tools/media-overview) flow that already produces a public URL.
|
||||
Reference image/video mode currently requires **remote http(s) URLs**. Local file paths are not supported for reference inputs.
|
||||
</Warning>
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Override the DashScope base URL">
|
||||
The provider defaults to the international DashScope endpoint. To target the China-region endpoint, set:
|
||||
<Accordion title="Relationship to Qwen">
|
||||
The bundled `qwen` provider also uses Alibaba-hosted DashScope endpoints for
|
||||
Wan video generation. Use:
|
||||
|
||||
```json5
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
alibaba: {
|
||||
baseUrl: "https://dashscope.aliyuncs.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
- `qwen/...` when you want the canonical Qwen provider surface
|
||||
- `alibaba/...` when you want the direct vendor-owned Wan video surface
|
||||
|
||||
The provider strips trailing slashes before constructing AIGC task URLs.
|
||||
See the [Qwen provider docs](/providers/qwen) for more detail.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Auth env priority">
|
||||
OpenClaw resolves the Alibaba API key from environment variables in this order, taking the first non-empty value:
|
||||
<Accordion title="Auth key priority">
|
||||
OpenClaw checks for auth keys in this order:
|
||||
|
||||
1. `MODELSTUDIO_API_KEY`
|
||||
1. `MODELSTUDIO_API_KEY` (preferred)
|
||||
2. `DASHSCOPE_API_KEY`
|
||||
3. `QWEN_API_KEY`
|
||||
|
||||
Configured `auth.profiles` entries (set via `openclaw models auth login`) override env-var resolution. See [Auth profiles in the models FAQ](/help/faq-models#what-is-an-auth-profile) for profile rotation, cooldown, and override mechanics.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Relationship to the Qwen plugin">
|
||||
Both bundled plugins talk to DashScope and accept overlapping API keys. Use:
|
||||
|
||||
- `alibaba/wan*.*` ids to drive the dedicated Wan video provider documented on this page.
|
||||
- `qwen/*` ids for Qwen chat, embedding, and media understanding (see [Qwen](/providers/qwen)).
|
||||
|
||||
Setting `MODELSTUDIO_API_KEY` once authenticates both plugins because the auth env var list intentionally overlaps; you do not need to onboard each plugin separately.
|
||||
Any of these will authenticate the `alibaba` provider.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -147,12 +106,9 @@ When a request omits `durationSeconds`, the provider sends DashScope's accepted
|
||||
Shared video tool parameters and provider selection.
|
||||
</Card>
|
||||
<Card title="Qwen" href="/providers/qwen" icon="microchip">
|
||||
Qwen chat, embedding, and media-understanding setup on the same DashScope auth.
|
||||
Qwen provider setup and DashScope integration.
|
||||
</Card>
|
||||
<Card title="Configuration reference" href="/gateway/config-agents#agent-defaults" icon="gear">
|
||||
Agent defaults and model configuration.
|
||||
</Card>
|
||||
<Card title="Models FAQ" href="/help/faq-models" icon="circle-question">
|
||||
Auth profiles, switching models, and resolving "no profile" errors.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -6,56 +6,34 @@ read_when:
|
||||
- You need the Cerebras API key env var or CLI auth choice
|
||||
---
|
||||
|
||||
[Cerebras](https://www.cerebras.ai) provides high-speed OpenAI-compatible inference on custom inference hardware. OpenClaw includes a bundled Cerebras provider plugin with a static four-model catalog.
|
||||
[Cerebras](https://www.cerebras.ai) provides high-speed OpenAI-compatible inference.
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Provider id | `cerebras` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `CEREBRAS_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice cerebras-api-key` |
|
||||
| Direct CLI flag | `--cerebras-api-key <key>` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Base URL | `https://api.cerebras.ai/v1` |
|
||||
| Default model | `cerebras/zai-glm-4.7` |
|
||||
| Property | Value |
|
||||
| -------- | ---------------------------- |
|
||||
| Provider | `cerebras` |
|
||||
| Auth | `CEREBRAS_API_KEY` |
|
||||
| API | OpenAI-compatible |
|
||||
| Base URL | `https://api.cerebras.ai/v1` |
|
||||
|
||||
## Getting started
|
||||
## Getting Started
|
||||
|
||||
<Steps>
|
||||
<Step title="Get an API key">
|
||||
Create an API key in the [Cerebras Cloud Console](https://cloud.cerebras.ai).
|
||||
</Step>
|
||||
<Step title="Run onboarding">
|
||||
<CodeGroup>
|
||||
|
||||
```bash Onboarding
|
||||
openclaw onboard --auth-choice cerebras-api-key
|
||||
```
|
||||
|
||||
```bash Direct flag
|
||||
openclaw onboard --non-interactive \
|
||||
--auth-choice cerebras-api-key \
|
||||
--cerebras-api-key "$CEREBRAS_API_KEY"
|
||||
```
|
||||
|
||||
```bash Env only
|
||||
export CEREBRAS_API_KEY=csk-...
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice cerebras-api-key
|
||||
```
|
||||
</Step>
|
||||
<Step title="Verify models are available">
|
||||
```bash
|
||||
openclaw models list --provider cerebras
|
||||
```
|
||||
|
||||
The list should include all four bundled models. If `CEREBRAS_API_KEY` is unresolved, `openclaw models status --json` reports the missing credential under `auth.unusableProfiles`.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Non-interactive setup
|
||||
### Non-Interactive Setup
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
@@ -64,28 +42,29 @@ openclaw onboard --non-interactive \
|
||||
--cerebras-api-key "$CEREBRAS_API_KEY"
|
||||
```
|
||||
|
||||
## Built-in catalog
|
||||
## Built-In Catalog
|
||||
|
||||
OpenClaw ships a static Cerebras catalog that mirrors the public OpenAI-compatible endpoint. All four models share a 128k context and 8,192 max-output tokens.
|
||||
OpenClaw ships a static Cerebras catalog for the public OpenAI-compatible endpoint:
|
||||
|
||||
| Model ref | Name | Reasoning | Notes |
|
||||
| ----------------------------------------- | -------------------- | --------- | -------------------------------------- |
|
||||
| `cerebras/zai-glm-4.7` | Z.ai GLM 4.7 | yes | Default model; preview reasoning model |
|
||||
| `cerebras/gpt-oss-120b` | GPT OSS 120B | yes | Production reasoning model |
|
||||
| `cerebras/qwen-3-235b-a22b-instruct-2507` | Qwen 3 235B Instruct | no | Preview non-reasoning model |
|
||||
| `cerebras/llama3.1-8b` | Llama 3.1 8B | no | Production speed-focused model |
|
||||
| Model ref | Name | Notes |
|
||||
| ----------------------------------------- | -------------------- | -------------------------------------- |
|
||||
| `cerebras/zai-glm-4.7` | Z.ai GLM 4.7 | Default model; preview reasoning model |
|
||||
| `cerebras/gpt-oss-120b` | GPT OSS 120B | Production reasoning model |
|
||||
| `cerebras/qwen-3-235b-a22b-instruct-2507` | Qwen 3 235B Instruct | Preview non-reasoning model |
|
||||
| `cerebras/llama3.1-8b` | Llama 3.1 8B | Production speed-focused model |
|
||||
|
||||
<Warning>
|
||||
Cerebras marks `zai-glm-4.7` and `qwen-3-235b-a22b-instruct-2507` as preview models, and `llama3.1-8b` plus `qwen-3-235b-a22b-instruct-2507` are documented for deprecation on May 27, 2026. Check Cerebras' supported-models page before relying on them for production workloads.
|
||||
Cerebras marks `zai-glm-4.7` and `qwen-3-235b-a22b-instruct-2507` as preview models, and `llama3.1-8b` / `qwen-3-235b-a22b-instruct-2507` are documented for deprecation on May 27, 2026. Check Cerebras' supported-models page before relying on them for production.
|
||||
</Warning>
|
||||
|
||||
## Manual config
|
||||
## Manual Config
|
||||
|
||||
The bundled plugin usually means you only need the API key. Use explicit `models.providers.cerebras` config when you want to override model metadata or run in `mode: "merge"` against the static catalog:
|
||||
The bundled plugin usually means you only need the API key. Use explicit
|
||||
`models.providers.cerebras` config when you want to override model metadata:
|
||||
|
||||
```json5
|
||||
{
|
||||
env: { CEREBRAS_API_KEY: "csk-..." },
|
||||
env: { CEREBRAS_API_KEY: "sk-..." },
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "cerebras/zai-glm-4.7" },
|
||||
@@ -109,22 +88,7 @@ The bundled plugin usually means you only need the API key. Use explicit `models
|
||||
```
|
||||
|
||||
<Note>
|
||||
If the Gateway runs as a daemon (launchd, systemd, Docker), make sure `CEREBRAS_API_KEY` is available to that process — for example in `~/.openclaw/.env` or through `env.shellEnv`. A key sitting only in `~/.profile` will not help a managed service unless the env is imported separately.
|
||||
If the Gateway runs as a daemon (launchd/systemd), make sure `CEREBRAS_API_KEY`
|
||||
is available to that process, for example in `~/.openclaw/.env` or through
|
||||
`env.shellEnv`.
|
||||
</Note>
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Model providers" href="/concepts/model-providers" icon="layers">
|
||||
Choosing providers, model refs, and failover behavior.
|
||||
</Card>
|
||||
<Card title="Thinking modes" href="/tools/thinking" icon="brain">
|
||||
Reasoning effort levels for the two reasoning-capable Cerebras models.
|
||||
</Card>
|
||||
<Card title="Configuration reference" href="/gateway/config-agents#agent-defaults" icon="gear">
|
||||
Agent defaults and model configuration.
|
||||
</Card>
|
||||
<Card title="Models FAQ" href="/help/faq-models" icon="circle-question">
|
||||
Auth profiles, switching models, and resolving "no profile" errors.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -4,61 +4,39 @@ title: "Fireworks"
|
||||
read_when:
|
||||
- You want to use Fireworks with OpenClaw
|
||||
- You need the Fireworks API key env var or default model id
|
||||
- You are debugging Kimi thinking-off behavior on Fireworks
|
||||
---
|
||||
|
||||
[Fireworks](https://fireworks.ai) exposes open-weight and routed models through an OpenAI-compatible API. OpenClaw includes a bundled Fireworks provider plugin that ships with two pre-cataloged Kimi models and accepts any Fireworks model or router id at runtime.
|
||||
[Fireworks](https://fireworks.ai) exposes open-weight and routed models through an OpenAI-compatible API. OpenClaw includes a bundled Fireworks provider plugin.
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| Provider id | `fireworks` (alias: `fireworks-ai`) |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `FIREWORKS_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice fireworks-api-key` |
|
||||
| Direct CLI flag | `--fireworks-api-key <key>` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Base URL | `https://api.fireworks.ai/inference/v1` |
|
||||
| Default model | `fireworks/accounts/fireworks/routers/kimi-k2p5-turbo` |
|
||||
| Default alias | `Kimi K2.5 Turbo` |
|
||||
| Property | Value |
|
||||
| ------------- | ------------------------------------------------------ |
|
||||
| Provider | `fireworks` |
|
||||
| Auth | `FIREWORKS_API_KEY` |
|
||||
| API | OpenAI-compatible chat/completions |
|
||||
| Base URL | `https://api.fireworks.ai/inference/v1` |
|
||||
| Default model | `fireworks/accounts/fireworks/routers/kimi-k2p5-turbo` |
|
||||
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step title="Set the Fireworks API key">
|
||||
<CodeGroup>
|
||||
<Step title="Set up Fireworks auth through onboarding">
|
||||
```bash
|
||||
openclaw onboard --auth-choice fireworks-api-key
|
||||
```
|
||||
|
||||
```bash Onboarding
|
||||
openclaw onboard --auth-choice fireworks-api-key
|
||||
```
|
||||
|
||||
```bash Direct flag
|
||||
openclaw onboard --non-interactive \
|
||||
--auth-choice fireworks-api-key \
|
||||
--fireworks-api-key "$FIREWORKS_API_KEY"
|
||||
```
|
||||
|
||||
```bash Env only
|
||||
export FIREWORKS_API_KEY=fw-...
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
Onboarding stores the key against the `fireworks` provider in your auth profiles and sets the **Fire Pass** Kimi K2.5 Turbo router as the default model.
|
||||
This stores your Fireworks key in OpenClaw config and sets the Fire Pass starter model as the default.
|
||||
|
||||
</Step>
|
||||
<Step title="Verify the model is available">
|
||||
```bash
|
||||
openclaw models list --provider fireworks
|
||||
```
|
||||
|
||||
The list should include `Kimi K2.6` and `Kimi K2.5 Turbo (Fire Pass)`. If `FIREWORKS_API_KEY` is unresolved, `openclaw models status --json` reports the missing credential under `auth.unusableProfiles`.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Non-interactive setup
|
||||
## Non-interactive example
|
||||
|
||||
For scripted or CI installs, pass everything on the command line:
|
||||
For scripted or CI setups, pass all values on the command line:
|
||||
|
||||
```bash
|
||||
openclaw onboard --non-interactive \
|
||||
@@ -71,25 +49,25 @@ openclaw onboard --non-interactive \
|
||||
|
||||
## Built-in catalog
|
||||
|
||||
| Model ref | Name | Input | Context | Max output | Thinking |
|
||||
| ------------------------------------------------------ | --------------------------- | ------------ | ------- | ---------- | -------------------- |
|
||||
| `fireworks/accounts/fireworks/models/kimi-k2p6` | Kimi K2.6 | text + image | 262,144 | 262,144 | Forced off |
|
||||
| `fireworks/accounts/fireworks/routers/kimi-k2p5-turbo` | Kimi K2.5 Turbo (Fire Pass) | text + image | 256,000 | 256,000 | Forced off (default) |
|
||||
| Model ref | Name | Input | Context | Max output | Notes |
|
||||
| ------------------------------------------------------ | --------------------------- | ---------- | ------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `fireworks/accounts/fireworks/models/kimi-k2p6` | Kimi K2.6 | text,image | 262,144 | 262,144 | Latest Kimi model on Fireworks. Thinking is disabled for Fireworks K2.6 requests; route through Moonshot directly if you need Kimi thinking output. |
|
||||
| `fireworks/accounts/fireworks/routers/kimi-k2p5-turbo` | Kimi K2.5 Turbo (Fire Pass) | text,image | 256,000 | 256,000 | Default bundled starter model on Fireworks |
|
||||
|
||||
<Note>
|
||||
OpenClaw pins all Fireworks Kimi models to `thinking: off` because Fireworks rejects Kimi thinking parameters in production. Routing the same model through [Moonshot](/providers/moonshot) directly preserves Kimi reasoning output. See [thinking modes](/tools/thinking) for switching between providers.
|
||||
</Note>
|
||||
<Tip>
|
||||
If Fireworks publishes a newer model such as a fresh Qwen or Gemma release, you can switch to it directly by using its Fireworks model id without waiting for a bundled catalog update.
|
||||
</Tip>
|
||||
|
||||
## Custom Fireworks model ids
|
||||
|
||||
OpenClaw accepts any Fireworks model or router id at runtime. Use the exact id shown by Fireworks and prefix it with `fireworks/`. Dynamic resolution clones the Fire Pass template (text + image input, OpenAI-compatible API, default cost zero) and disables thinking automatically when the id matches the Kimi pattern.
|
||||
OpenClaw accepts dynamic Fireworks model ids too. Use the exact model or router id shown by Fireworks and prefix it with `fireworks/`.
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "fireworks/accounts/fireworks/models/<your-model-id>",
|
||||
primary: "fireworks/accounts/fireworks/routers/kimi-k2p5-turbo",
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -103,41 +81,26 @@ OpenClaw accepts any Fireworks model or router id at runtime. Use the exact id s
|
||||
- Router model: `fireworks/accounts/fireworks/routers/kimi-k2p5-turbo`
|
||||
- Direct model: `fireworks/accounts/fireworks/models/<model-name>`
|
||||
|
||||
OpenClaw strips the `fireworks/` prefix when constructing the API request and sends the remaining path to the Fireworks endpoint as the OpenAI-compatible `model` field.
|
||||
OpenClaw strips the `fireworks/` prefix when building the API request and sends the remaining path to the Fireworks endpoint.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Why thinking is forced off for Kimi">
|
||||
Fireworks K2.6 returns a 400 if the request carries `reasoning_*` parameters even though Kimi supports thinking through Moonshot's own API. The bundled policy (`extensions/fireworks/thinking-policy.ts`) advertises only the `off` thinking level for Kimi model ids, so manual `/think` switches and provider-policy surfaces stay aligned with the runtime contract.
|
||||
|
||||
To use Kimi reasoning end-to-end, configure the [Moonshot provider](/providers/moonshot) and route the same model through it.
|
||||
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Environment availability for the daemon">
|
||||
If the Gateway runs as a managed service (launchd, systemd, Docker), the Fireworks key must be visible to that process — not just to your interactive shell.
|
||||
<Accordion title="Environment note">
|
||||
If the Gateway runs outside your interactive shell, make sure `FIREWORKS_API_KEY` is available to that process too.
|
||||
|
||||
<Warning>
|
||||
A key sitting only in `~/.profile` will not help a launchd or systemd daemon unless that environment is imported there too. Set the key in `~/.openclaw/.env` or via `env.shellEnv` to make it readable from the gateway process.
|
||||
A key sitting only in `~/.profile` will not help a launchd/systemd daemon unless that environment is imported there as well. Set the key in `~/.openclaw/.env` or via `env.shellEnv` to ensure the gateway process can read it.
|
||||
</Warning>
|
||||
|
||||
On macOS, `openclaw gateway install` already wires `~/.openclaw/.env` into the LaunchAgent environment file. Re-run install (or `openclaw doctor --fix`) after rotating the key.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Model providers" href="/concepts/model-providers" icon="layers">
|
||||
<Card title="Model selection" href="/concepts/model-providers" icon="layers">
|
||||
Choosing providers, model refs, and failover behavior.
|
||||
</Card>
|
||||
<Card title="Thinking modes" href="/tools/thinking" icon="brain">
|
||||
`/think` levels, provider policies, and routing reasoning-capable models.
|
||||
</Card>
|
||||
<Card title="Moonshot" href="/providers/moonshot" icon="moon">
|
||||
Run Kimi with native thinking output through Moonshot's own API.
|
||||
</Card>
|
||||
<Card title="Troubleshooting" href="/help/troubleshooting" icon="wrench">
|
||||
General troubleshooting and FAQ.
|
||||
</Card>
|
||||
|
||||
@@ -1,61 +1,37 @@
|
||||
---
|
||||
summary: "GLM model family overview and how to use it in OpenClaw"
|
||||
summary: "GLM model family overview + how to use it in OpenClaw"
|
||||
read_when:
|
||||
- You want GLM models in OpenClaw
|
||||
- You need the model naming convention and setup
|
||||
title: "GLM (Zhipu)"
|
||||
---
|
||||
|
||||
GLM is a model family (not a company) available through the [Z.AI](https://z.ai) platform. In OpenClaw, GLM models are accessed through the bundled `zai` provider with refs like `zai/glm-5.1`.
|
||||
# GLM models
|
||||
|
||||
| Property | Value |
|
||||
| ------------------- | --------------------------------------------------------------------------- |
|
||||
| Provider id | `zai` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env vars | `ZAI_API_KEY` or `Z_AI_API_KEY` |
|
||||
| Onboarding choices | `zai-api-key`, `zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn` |
|
||||
| API | OpenAI-compatible |
|
||||
| Default base URL | `https://api.z.ai/api/paas/v4` |
|
||||
| Suggested default | `zai/glm-5.1` |
|
||||
| Default image model | `zai/glm-4.6v` |
|
||||
GLM is a **model family** (not a company) available through the Z.AI platform. In OpenClaw, GLM
|
||||
models are accessed via the `zai` provider and model IDs like `zai/glm-5`.
|
||||
|
||||
## Getting started
|
||||
|
||||
<Steps>
|
||||
<Step title="Choose an auth route and run onboarding">
|
||||
Pick the onboarding choice that matches your Z.AI plan and region. The generic `zai-api-key` choice auto-detects the matching endpoint from the key shape; use the explicit regional choices when you want to force a specific Coding Plan or general API surface.
|
||||
Pick the onboarding choice that matches your Z.AI plan and region:
|
||||
|
||||
| Auth choice | Best for |
|
||||
| ------------------- | --------------------------------------------------- |
|
||||
| `zai-api-key` | Generic API key with endpoint auto-detection |
|
||||
| `zai-coding-global` | Coding Plan users (global) |
|
||||
| `zai-coding-cn` | Coding Plan users (China region) |
|
||||
| `zai-global` | General API (global) |
|
||||
| `zai-cn` | General API (China region) |
|
||||
| Auth choice | Best for |
|
||||
| ----------- | -------- |
|
||||
| `zai-api-key` | Generic API-key setup with endpoint auto-detection |
|
||||
| `zai-coding-global` | Coding Plan users (global) |
|
||||
| `zai-coding-cn` | Coding Plan users (China region) |
|
||||
| `zai-global` | General API (global) |
|
||||
| `zai-cn` | General API (China region) |
|
||||
|
||||
<CodeGroup>
|
||||
```bash
|
||||
# Example: generic auto-detect
|
||||
openclaw onboard --auth-choice zai-api-key
|
||||
|
||||
```bash Auto-detect
|
||||
openclaw onboard --auth-choice zai-api-key
|
||||
```
|
||||
|
||||
```bash Coding Plan (global)
|
||||
openclaw onboard --auth-choice zai-coding-global
|
||||
```
|
||||
|
||||
```bash Coding Plan (China)
|
||||
openclaw onboard --auth-choice zai-coding-cn
|
||||
```
|
||||
|
||||
```bash General API (global)
|
||||
openclaw onboard --auth-choice zai-global
|
||||
```
|
||||
|
||||
```bash General API (China)
|
||||
openclaw onboard --auth-choice zai-cn
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
# Example: Coding Plan global
|
||||
openclaw onboard --auth-choice zai-coding-global
|
||||
```
|
||||
|
||||
</Step>
|
||||
<Step title="Set GLM as the default model">
|
||||
@@ -80,42 +56,45 @@ openclaw onboard --auth-choice zai-cn
|
||||
```
|
||||
|
||||
<Tip>
|
||||
`zai-api-key` lets OpenClaw detect the matching Z.AI endpoint from the key shape and apply the correct base URL automatically. Use the explicit regional choices when you want to pin a specific Coding Plan or general API surface.
|
||||
`zai-api-key` lets OpenClaw detect the matching Z.AI endpoint from the key and
|
||||
apply the correct base URL automatically. Use the explicit regional choices when
|
||||
you want to force a specific Coding Plan or general API surface.
|
||||
</Tip>
|
||||
|
||||
## Built-in catalog
|
||||
|
||||
The bundled `zai` provider seeds 13 GLM model refs. All entries support reasoning unless marked otherwise; `glm-5v-turbo` and `glm-4.6v` accept image input as well as text.
|
||||
OpenClaw currently seeds the bundled `zai` provider with these GLM refs:
|
||||
|
||||
| Model ref | Notes |
|
||||
| -------------------- | -------------------------------------------------- |
|
||||
| `zai/glm-5.1` | Default model. Reasoning, text only, 202k context. |
|
||||
| `zai/glm-5` | Reasoning, text only, 202k context. |
|
||||
| `zai/glm-5-turbo` | Reasoning, text only, 202k context. |
|
||||
| `zai/glm-5v-turbo` | Reasoning, text + image, 202k context. |
|
||||
| `zai/glm-4.7` | Reasoning, text only, 204k context. |
|
||||
| `zai/glm-4.7-flash` | Reasoning, text only, 200k context. |
|
||||
| `zai/glm-4.7-flashx` | Reasoning, text only. |
|
||||
| `zai/glm-4.6` | Reasoning, text only. |
|
||||
| `zai/glm-4.6v` | Reasoning, text + image. Default image model. |
|
||||
| `zai/glm-4.5` | Reasoning, text only. |
|
||||
| `zai/glm-4.5-air` | Reasoning, text only. |
|
||||
| `zai/glm-4.5-flash` | Reasoning, text only. |
|
||||
| `zai/glm-4.5v` | Reasoning, text + image. |
|
||||
| Model | Model |
|
||||
| --------------- | ---------------- |
|
||||
| `glm-5.1` | `glm-4.7` |
|
||||
| `glm-5` | `glm-4.7-flash` |
|
||||
| `glm-5-turbo` | `glm-4.7-flashx` |
|
||||
| `glm-5v-turbo` | `glm-4.6` |
|
||||
| `glm-4.5` | `glm-4.6v` |
|
||||
| `glm-4.5-air` | |
|
||||
| `glm-4.5-flash` | |
|
||||
| `glm-4.5v` | |
|
||||
|
||||
<Note>
|
||||
GLM versions and availability can change. Run `openclaw models list --provider zai` to see the catalog rows known to your installed version, and check Z.AI's docs for newly added or deprecated models.
|
||||
The default bundled model ref is `zai/glm-5.1`. GLM versions and availability
|
||||
can change; check Z.AI's docs for the latest.
|
||||
</Note>
|
||||
|
||||
## Advanced configuration
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Endpoint auto-detection">
|
||||
When you use the `zai-api-key` auth choice, OpenClaw inspects the key shape to determine the correct Z.AI base URL. Explicit regional choices (`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) override auto-detection and pin the endpoint directly.
|
||||
When you use the `zai-api-key` auth choice, OpenClaw inspects the key format
|
||||
to determine the correct Z.AI base URL. Explicit regional choices
|
||||
(`zai-coding-global`, `zai-coding-cn`, `zai-global`, `zai-cn`) override
|
||||
auto-detection and pin the endpoint directly.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Provider details">
|
||||
GLM models are served by the `zai` runtime provider. For full provider configuration, regional endpoints, and additional capabilities, see the [Z.AI provider page](/providers/zai).
|
||||
GLM models are served by the `zai` runtime provider. For full provider
|
||||
configuration, regional endpoints, and additional capabilities, see
|
||||
[Z.AI provider docs](/providers/zai).
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -125,13 +104,7 @@ The bundled `zai` provider seeds 13 GLM model refs. All entries support reasonin
|
||||
<Card title="Z.AI provider" href="/providers/zai" icon="server">
|
||||
Full Z.AI provider configuration and regional endpoints.
|
||||
</Card>
|
||||
<Card title="Model providers" href="/concepts/model-providers" icon="layers">
|
||||
<Card title="Model selection" href="/concepts/model-providers" icon="layers">
|
||||
Choosing providers, model refs, and failover behavior.
|
||||
</Card>
|
||||
<Card title="Thinking modes" href="/tools/thinking" icon="brain">
|
||||
`/think` levels for the reasoning-capable GLM family.
|
||||
</Card>
|
||||
<Card title="Models FAQ" href="/help/faq-models" icon="circle-question">
|
||||
Auth profiles, switching models, and resolving "no profile" errors.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
---
|
||||
summary: "Groq setup (auth + model selection + Whisper transcription)"
|
||||
summary: "Groq setup (auth + model selection)"
|
||||
title: "Groq"
|
||||
read_when:
|
||||
- You want to use Groq with OpenClaw
|
||||
- You need the API key env var or CLI auth choice
|
||||
- You are configuring Whisper audio transcription on Groq
|
||||
---
|
||||
|
||||
[Groq](https://groq.com) provides ultra-fast inference on open-weight models (Llama, Gemma, Kimi, Qwen, GPT OSS, and more) using custom LPU hardware. OpenClaw includes a bundled Groq plugin that registers both an OpenAI-compatible chat provider and an audio media-understanding provider.
|
||||
[Groq](https://groq.com) provides ultra-fast inference on open-source models
|
||||
(Llama, Gemma, Mistral, and more) using custom LPU hardware. OpenClaw connects
|
||||
to Groq through its OpenAI-compatible API.
|
||||
|
||||
| Property | Value |
|
||||
| ---------------------- | ---------------------------------------- |
|
||||
| Provider id | `groq` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `GROQ_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice groq-api-key` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Base URL | `https://api.groq.com/openai/v1` |
|
||||
| Audio transcription | `whisper-large-v3-turbo` (default) |
|
||||
| Suggested chat default | `groq/llama-3.3-70b-versatile` |
|
||||
| Property | Value |
|
||||
| -------- | ----------------- |
|
||||
| Provider | `groq` |
|
||||
| Auth | `GROQ_API_KEY` |
|
||||
| API | OpenAI-compatible |
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -27,18 +23,9 @@ read_when:
|
||||
Create an API key at [console.groq.com/keys](https://console.groq.com/keys).
|
||||
</Step>
|
||||
<Step title="Set the API key">
|
||||
<CodeGroup>
|
||||
|
||||
```bash Onboarding
|
||||
openclaw onboard --auth-choice groq-api-key
|
||||
```
|
||||
|
||||
```bash Env only
|
||||
export GROQ_API_KEY=gsk_...
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```bash
|
||||
export GROQ_API_KEY="gsk_..."
|
||||
```
|
||||
</Step>
|
||||
<Step title="Set a default model">
|
||||
```json5
|
||||
@@ -51,11 +38,6 @@ export GROQ_API_KEY=gsk_...
|
||||
}
|
||||
```
|
||||
</Step>
|
||||
<Step title="Verify the catalog is reachable">
|
||||
```bash
|
||||
openclaw models list --provider groq
|
||||
```
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
### Config file example
|
||||
@@ -73,56 +55,37 @@ export GROQ_API_KEY=gsk_...
|
||||
|
||||
## Built-in catalog
|
||||
|
||||
OpenClaw ships a manifest-backed Groq catalog with both reasoning and non-reasoning entries. Run `openclaw models list --provider groq` to see the bundled rows for your installed version, or check [console.groq.com/docs/models](https://console.groq.com/docs/models) for Groq's authoritative list.
|
||||
OpenClaw ships a manifest-backed Groq catalog for fast provider-filtered model
|
||||
listing. Run `openclaw models list --all --provider groq` to see the bundled
|
||||
rows, or check
|
||||
[console.groq.com/docs/models](https://console.groq.com/docs/models).
|
||||
|
||||
| Model ref | Name | Reasoning | Input | Context |
|
||||
| ---------------------------------------------------- | ----------------------------- | --------- | ------------ | ------- |
|
||||
| `groq/llama-3.3-70b-versatile` | Llama 3.3 70B Versatile | no | text | 131,072 |
|
||||
| `groq/llama-3.1-8b-instant` | Llama 3.1 8B Instant | no | text | 131,072 |
|
||||
| `groq/meta-llama/llama-4-maverick-17b-128e-instruct` | Llama 4 Maverick 17B | no | text + image | 131,072 |
|
||||
| `groq/meta-llama/llama-4-scout-17b-16e-instruct` | Llama 4 Scout 17B | no | text + image | 131,072 |
|
||||
| `groq/llama3-70b-8192` | Llama 3 70B | no | text | 8,192 |
|
||||
| `groq/llama3-8b-8192` | Llama 3 8B | no | text | 8,192 |
|
||||
| `groq/gemma2-9b-it` | Gemma 2 9B | no | text | 8,192 |
|
||||
| `groq/mistral-saba-24b` | Mistral Saba 24B | no | text | 32,768 |
|
||||
| `groq/moonshotai/kimi-k2-instruct` | Kimi K2 Instruct | no | text | 131,072 |
|
||||
| `groq/moonshotai/kimi-k2-instruct-0905` | Kimi K2 Instruct 0905 | no | text | 262,144 |
|
||||
| `groq/openai/gpt-oss-120b` | GPT OSS 120B | yes | text | 131,072 |
|
||||
| `groq/openai/gpt-oss-20b` | GPT OSS 20B | yes | text | 131,072 |
|
||||
| `groq/openai/gpt-oss-safeguard-20b` | Safety GPT OSS 20B | yes | text | 131,072 |
|
||||
| `groq/qwen-qwq-32b` | Qwen QwQ 32B | yes | text | 131,072 |
|
||||
| `groq/qwen/qwen3-32b` | Qwen3 32B | yes | text | 131,072 |
|
||||
| `groq/deepseek-r1-distill-llama-70b` | DeepSeek R1 Distill Llama 70B | yes | text | 131,072 |
|
||||
| `groq/groq/compound` | Compound | yes | text | 131,072 |
|
||||
| `groq/groq/compound-mini` | Compound Mini | yes | text | 131,072 |
|
||||
| Model | Notes |
|
||||
| --------------------------- | ---------------------------------- |
|
||||
| **Llama 3.3 70B Versatile** | General-purpose, large context |
|
||||
| **Llama 3.1 8B Instant** | Fast, lightweight |
|
||||
| **Gemma 2 9B** | Compact, efficient |
|
||||
| **Mixtral 8x7B** | MoE architecture, strong reasoning |
|
||||
|
||||
<Tip>
|
||||
The catalog evolves with each OpenClaw release. `openclaw models list --provider groq` shows the rows known to your installed version; cross-check with [console.groq.com/docs/models](https://console.groq.com/docs/models) for newly-added or deprecated models.
|
||||
Use `openclaw models list --all --provider groq` for the manifest-backed Groq
|
||||
rows known to this OpenClaw version.
|
||||
</Tip>
|
||||
|
||||
## Reasoning models
|
||||
|
||||
OpenClaw maps its shared `/think` levels to Groq's model-specific `reasoning_effort` values:
|
||||
|
||||
- For `qwen/qwen3-32b`, disabled thinking sends `none` and enabled thinking sends `default`.
|
||||
- For Groq GPT OSS reasoning models (`openai/gpt-oss-*`), OpenClaw sends `low`, `medium`, or `high` based on `/think` level. Disabled thinking omits `reasoning_effort` because those models do not support a disabled value.
|
||||
- DeepSeek R1 Distill, Qwen QwQ, and Compound use Groq's native reasoning surface; `/think` controls visibility but the model always reasons.
|
||||
|
||||
See [Thinking modes](/tools/thinking) for the shared `/think` levels and how OpenClaw translates them per provider.
|
||||
OpenClaw maps its shared `/think` levels to Groq's model-specific
|
||||
`reasoning_effort` values. For `qwen/qwen3-32b`, disabled thinking sends
|
||||
`none` and enabled thinking sends `default`. For Groq GPT-OSS reasoning models,
|
||||
OpenClaw sends `low`, `medium`, or `high`; disabled thinking omits
|
||||
`reasoning_effort` because those models do not support a disabled value.
|
||||
|
||||
## Audio transcription
|
||||
|
||||
Groq's bundled plugin also registers an **audio media-understanding provider** so voice messages can be transcribed through the shared `tools.media.audio` surface.
|
||||
|
||||
| Property | Value |
|
||||
| ------------------ | ----------------------------------------- |
|
||||
| Shared config path | `tools.media.audio` |
|
||||
| Default base URL | `https://api.groq.com/openai/v1` |
|
||||
| Default model | `whisper-large-v3-turbo` |
|
||||
| Auto priority | 20 |
|
||||
| API endpoint | OpenAI-compatible `/audio/transcriptions` |
|
||||
|
||||
To make Groq the default audio backend:
|
||||
Groq also provides fast Whisper-based audio transcription. When configured as a
|
||||
media-understanding provider, OpenClaw uses Groq's `whisper-large-v3-turbo`
|
||||
model to transcribe voice messages through the shared `tools.media.audio`
|
||||
surface.
|
||||
|
||||
```json5
|
||||
{
|
||||
@@ -137,27 +100,25 @@ To make Groq the default audio backend:
|
||||
```
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Environment availability for the daemon">
|
||||
If the Gateway runs as a managed service (launchd, systemd, Docker), `GROQ_API_KEY` must be visible to that process — not just to your interactive shell.
|
||||
|
||||
<Warning>
|
||||
A key sitting only in `~/.profile` will not help a launchd or systemd daemon unless that environment is imported there too. Set the key in `~/.openclaw/.env` or via `env.shellEnv` to make it readable from the gateway process.
|
||||
</Warning>
|
||||
|
||||
<Accordion title="Audio transcription details">
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Shared config path | `tools.media.audio` |
|
||||
| Default base URL | `https://api.groq.com/openai/v1` |
|
||||
| Default model | `whisper-large-v3-turbo` |
|
||||
| API endpoint | OpenAI-compatible `/audio/transcriptions` |
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Custom Groq model ids">
|
||||
OpenClaw accepts any Groq model id at runtime. Use the exact id shown by Groq and prefix it with `groq/`. The bundled catalog covers the common cases; uncatalogued ids fall through to the default OpenAI-compatible template.
|
||||
<Accordion title="Environment note">
|
||||
If the Gateway runs as a daemon (launchd/systemd), make sure `GROQ_API_KEY` is
|
||||
available to that process (for example, in `~/.openclaw/.env` or via
|
||||
`env.shellEnv`).
|
||||
|
||||
```json5
|
||||
{
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "groq/<your-model-id>" },
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
<Warning>
|
||||
Keys set only in your interactive shell are not visible to daemon-managed
|
||||
gateway processes. Use `~/.openclaw/.env` or `env.shellEnv` config for
|
||||
persistent availability.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
@@ -165,16 +126,16 @@ To make Groq the default audio backend:
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Model providers" href="/concepts/model-providers" icon="layers">
|
||||
<Card title="Model selection" href="/concepts/model-providers" icon="layers">
|
||||
Choosing providers, model refs, and failover behavior.
|
||||
</Card>
|
||||
<Card title="Thinking modes" href="/tools/thinking" icon="brain">
|
||||
Reasoning effort levels and provider-policy interaction.
|
||||
</Card>
|
||||
<Card title="Configuration reference" href="/gateway/configuration-reference" icon="gear">
|
||||
Full config schema including provider and audio settings.
|
||||
</Card>
|
||||
<Card title="Groq Console" href="https://console.groq.com" icon="arrow-up-right-from-square">
|
||||
Groq dashboard, API docs, and pricing.
|
||||
</Card>
|
||||
<Card title="Groq model list" href="https://console.groq.com/docs/models" icon="list">
|
||||
Official Groq model catalog.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
|
||||
@@ -7,19 +7,12 @@ read_when:
|
||||
title: "Inferrs"
|
||||
---
|
||||
|
||||
[inferrs](https://github.com/ericcurtin/inferrs) can serve local models behind an OpenAI-compatible `/v1` API. OpenClaw works with `inferrs` through the generic `openai-completions` path.
|
||||
[inferrs](https://github.com/ericcurtin/inferrs) can serve local models behind an
|
||||
OpenAI-compatible `/v1` API. OpenClaw works with `inferrs` through the generic
|
||||
`openai-completions` path.
|
||||
|
||||
| Property | Value |
|
||||
| ------------------ | ------------------------------------------------------------------ |
|
||||
| Provider id | `inferrs` (custom; configure under `models.providers.inferrs`) |
|
||||
| Plugin | none — `inferrs` is not a bundled OpenClaw provider plugin |
|
||||
| Auth env var | Optional. Any value works if your inferrs server has no auth |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Suggested base URL | `http://127.0.0.1:8080/v1` (or wherever your inferrs server lives) |
|
||||
|
||||
<Note>
|
||||
`inferrs` is currently best treated as a custom self-hosted OpenAI-compatible backend, not a dedicated OpenClaw provider plugin. You configure it through `models.providers.inferrs` rather than an onboarding choice flag. If you need a true bundled plugin with auto-discovery, see [SGLang](/providers/sglang) or [vLLM](/providers/vllm).
|
||||
</Note>
|
||||
`inferrs` is currently best treated as a custom self-hosted OpenAI-compatible
|
||||
backend, not a dedicated OpenClaw provider plugin.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -14,18 +14,13 @@ OpenClaw posts to Inworld's streaming TTS endpoint, concatenates the
|
||||
returned base64 audio chunks into a single buffer, and hands the result to
|
||||
the standard reply-audio pipeline.
|
||||
|
||||
| Property | Value |
|
||||
| ------------- | --------------------------------------------------------------- |
|
||||
| Provider id | `inworld` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Contract | `speechProviders` (TTS only) |
|
||||
| Auth env var | `INWORLD_API_KEY` (HTTP Basic, Base64 dashboard credential) |
|
||||
| Base URL | `https://api.inworld.ai` |
|
||||
| Default voice | `Sarah` |
|
||||
| Default model | `inworld-tts-1.5-max` |
|
||||
| Output | MP3 (default), OGG_OPUS (voice notes), PCM 22050 Hz (telephony) |
|
||||
| Website | [inworld.ai](https://inworld.ai) |
|
||||
| Docs | [docs.inworld.ai/tts/tts](https://docs.inworld.ai/tts/tts) |
|
||||
| Detail | Value |
|
||||
| ------------- | ----------------------------------------------------------- |
|
||||
| Website | [inworld.ai](https://inworld.ai) |
|
||||
| Docs | [docs.inworld.ai/tts/tts](https://docs.inworld.ai/tts/tts) |
|
||||
| Auth | `INWORLD_API_KEY` (HTTP Basic, Base64 dashboard credential) |
|
||||
| Default voice | `Sarah` |
|
||||
| Default model | `inworld-tts-1.5-max` |
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -7,21 +7,13 @@ read_when:
|
||||
title: "Mistral"
|
||||
---
|
||||
|
||||
OpenClaw includes a bundled Mistral plugin that registers four contracts: chat completions, media understanding (Voxtral batch transcription), realtime STT for Voice Call (Voxtral Realtime), and memory embeddings (`mistral-embed`).
|
||||
OpenClaw supports Mistral for both text/image model routing (`mistral/...`) and
|
||||
audio transcription via Voxtral in media understanding.
|
||||
Mistral can also be used for memory embeddings (`memorySearch.provider = "mistral"`).
|
||||
|
||||
| Property | Value |
|
||||
| ---------------- | ------------------------------------------- |
|
||||
| Provider id | `mistral` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `MISTRAL_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice mistral-api-key` |
|
||||
| Direct CLI flag | `--mistral-api-key <key>` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Base URL | `https://api.mistral.ai/v1` |
|
||||
| Default model | `mistral/mistral-large-latest` |
|
||||
| Embedding model | `mistral-embed` |
|
||||
| Voxtral batch | `voxtral-mini-latest` (audio transcription) |
|
||||
| Voxtral realtime | `voxtral-mini-transcribe-realtime-2602` |
|
||||
- Provider: `mistral`
|
||||
- Auth: `MISTRAL_API_KEY`
|
||||
- API: Mistral Chat Completions (`https://api.mistral.ai/v1`)
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -165,10 +157,10 @@ matching `sampleRate` only if your upstream stream is already raw PCM.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Auth and base URL">
|
||||
- Mistral auth uses `MISTRAL_API_KEY` (Bearer header).
|
||||
- Provider base URL defaults to `https://api.mistral.ai/v1` and accepts the standard OpenAI-compatible chat-completions request shape.
|
||||
- Mistral auth uses `MISTRAL_API_KEY`.
|
||||
- Provider base URL defaults to `https://api.mistral.ai/v1`.
|
||||
- Onboarding default model is `mistral/mistral-large-latest`.
|
||||
- Override the base URL under `models.providers.mistral.baseUrl` only when Mistral explicitly publishes a regional endpoint you need.
|
||||
- Z.AI uses Bearer auth with your API key.
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
@@ -7,17 +7,13 @@ read_when:
|
||||
- You want to make Runway the default video provider
|
||||
---
|
||||
|
||||
OpenClaw ships a bundled `runway` provider for hosted video generation. The plugin is enabled by default and registers the `runway` provider against the `videoGenerationProviders` contract.
|
||||
OpenClaw ships a bundled `runway` provider for hosted video generation.
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | ----------------------------------------------------------------- |
|
||||
| Provider id | `runway` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env vars | `RUNWAYML_API_SECRET` (canonical) or `RUNWAY_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice runway-api-key` |
|
||||
| Direct CLI flag | `--runway-api-key <key>` |
|
||||
| API | Runway task-based video generation (`GET /v1/tasks/{id}` polling) |
|
||||
| Default model | `runway/gen4.5` |
|
||||
| Property | Value |
|
||||
| ----------- | ----------------------------------------------------------------- |
|
||||
| Provider id | `runway` |
|
||||
| Auth | `RUNWAYML_API_SECRET` (canonical) or `RUNWAY_API_KEY` |
|
||||
| API | Runway task-based video generation (`GET /v1/tasks/{id}` polling) |
|
||||
|
||||
## Getting started
|
||||
|
||||
@@ -37,31 +33,23 @@ OpenClaw ships a bundled `runway` provider for hosted video generation. The plug
|
||||
</Step>
|
||||
</Steps>
|
||||
|
||||
## Supported modes and models
|
||||
## Supported modes
|
||||
|
||||
The provider exposes seven Runway models split across three modes. The same model id can serve more than one mode (for example `gen4.5` works for both text-to-video and image-to-video).
|
||||
|
||||
| Mode | Models | Reference input |
|
||||
| -------------- | ---------------------------------------------------------------------- | ----------------------- |
|
||||
| Text-to-video | `gen4.5` (default), `veo3.1`, `veo3.1_fast`, `veo3` | None |
|
||||
| Image-to-video | `gen4.5`, `gen4_turbo`, `gen3a_turbo`, `veo3.1`, `veo3.1_fast`, `veo3` | 1 local or remote image |
|
||||
| Video-to-video | `gen4_aleph` | 1 local or remote video |
|
||||
|
||||
Local image and video references are supported via data URIs.
|
||||
|
||||
| Aspect ratios | Allowed values |
|
||||
| --------------------- | ------------------------------------------- |
|
||||
| Text-to-video | `16:9`, `9:16` |
|
||||
| Image and video edits | `1:1`, `16:9`, `9:16`, `3:4`, `4:3`, `21:9` |
|
||||
|
||||
<Warning>
|
||||
Video-to-video currently requires `runway/gen4_aleph`. Other Runway model ids reject video reference inputs.
|
||||
</Warning>
|
||||
| Mode | Model | Reference input |
|
||||
| -------------- | ------------------ | ----------------------- |
|
||||
| Text-to-video | `gen4.5` (default) | None |
|
||||
| Image-to-video | `gen4.5` | 1 local or remote image |
|
||||
| Video-to-video | `gen4_aleph` | 1 local or remote video |
|
||||
|
||||
<Note>
|
||||
Picking a Runway model id from the wrong column produces an explicit error before the API request leaves OpenClaw. The provider validates `model` against the mode's allowlist (`TEXT_ONLY_MODELS`, `IMAGE_MODELS`, `VIDEO_MODELS`) in `extensions/runway/video-generation-provider.ts`.
|
||||
Local image and video references are supported via data URIs. Text-only runs
|
||||
currently expose `16:9` and `9:16` aspect ratios.
|
||||
</Note>
|
||||
|
||||
<Warning>
|
||||
Video-to-video currently requires `runway/gen4_aleph` specifically.
|
||||
</Warning>
|
||||
|
||||
## Configuration
|
||||
|
||||
```json5
|
||||
|
||||
@@ -6,20 +6,22 @@ read_when:
|
||||
title: "SenseAudio"
|
||||
---
|
||||
|
||||
SenseAudio can transcribe inbound audio and voice-note attachments through OpenClaw's shared `tools.media.audio` pipeline. OpenClaw posts multipart audio to the OpenAI-compatible transcription endpoint and injects the returned text as `{{Transcript}}` plus an `[Audio]` block.
|
||||
# SenseAudio
|
||||
|
||||
| Property | Value |
|
||||
SenseAudio can transcribe inbound audio/voice-note attachments through
|
||||
OpenClaw's shared `tools.media.audio` pipeline. OpenClaw posts multipart audio
|
||||
to the OpenAI-compatible transcription endpoint and injects the returned text
|
||||
as `{{Transcript}}` plus an `[Audio]` block.
|
||||
|
||||
| Detail | Value |
|
||||
| ------------- | ------------------------------------------------ |
|
||||
| Provider id | `senseaudio` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Contract | `mediaUnderstandingProviders` (audio) |
|
||||
| Auth env var | `SENSEAUDIO_API_KEY` |
|
||||
| Default model | `senseaudio-asr-pro-1.5-260319` |
|
||||
| Default URL | `https://api.senseaudio.cn/v1` |
|
||||
| Website | [senseaudio.cn](https://senseaudio.cn) |
|
||||
| Docs | [senseaudio.cn/docs](https://senseaudio.cn/docs) |
|
||||
| Auth | `SENSEAUDIO_API_KEY` |
|
||||
| Default model | `senseaudio-asr-pro-1.5-260319` |
|
||||
| Default URL | `https://api.senseaudio.cn/v1` |
|
||||
|
||||
## Getting started
|
||||
## Getting Started
|
||||
|
||||
<Steps>
|
||||
<Step title="Set your API key">
|
||||
|
||||
@@ -6,21 +6,16 @@ read_when:
|
||||
title: "SGLang"
|
||||
---
|
||||
|
||||
SGLang serves open-weight models via an OpenAI-compatible HTTP API. OpenClaw connects to SGLang using the `openai-completions` provider family with auto-discovery of available models.
|
||||
SGLang can serve open-source models via an **OpenAI-compatible** HTTP API.
|
||||
OpenClaw can connect to SGLang using the `openai-completions` API.
|
||||
|
||||
| Property | Value |
|
||||
| ------------------------- | ------------------------------------------------------------ |
|
||||
| Provider id | `sglang` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `SGLANG_API_KEY` (any non-empty value if server has no auth) |
|
||||
| Onboarding flag | `--auth-choice sglang` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Default base URL | `http://127.0.0.1:30000/v1` |
|
||||
| Default model placeholder | `sglang/Qwen/Qwen3-8B` |
|
||||
| Streaming usage | Yes (`supportsStreamingUsage: true`) |
|
||||
| Pricing | Marked external-free (`modelPricing.external: false`) |
|
||||
OpenClaw can also **auto-discover** available models from SGLang when you opt
|
||||
in with `SGLANG_API_KEY` (any value works if your server does not enforce auth)
|
||||
and you do not define an explicit `models.providers.sglang` entry.
|
||||
|
||||
OpenClaw also **auto-discovers** available models from SGLang when you opt in with `SGLANG_API_KEY` and you do not define an explicit `models.providers.sglang` entry — see [Model discovery (implicit provider)](#model-discovery-implicit-provider) below.
|
||||
OpenClaw treats `sglang` as a local OpenAI-compatible provider that supports
|
||||
streamed usage accounting, so status/context token counts can update from
|
||||
`stream_options.include_usage` responses.
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -6,19 +6,20 @@ read_when:
|
||||
- You need the TokenHub API key setup
|
||||
---
|
||||
|
||||
Tencent Cloud ships as a bundled provider plugin in OpenClaw. It gives access to Tencent Hy3 preview through the TokenHub endpoint (`tencent-tokenhub`) using an OpenAI-compatible API.
|
||||
# Tencent Cloud TokenHub
|
||||
|
||||
| Property | Value |
|
||||
| ---------------- | ----------------------------------------------------- |
|
||||
| Provider id | `tencent-tokenhub` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `TOKENHUB_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice tokenhub-api-key` |
|
||||
| Direct CLI flag | `--tokenhub-api-key <key>` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Default base URL | `https://tokenhub.tencentmaas.com/v1` |
|
||||
| Global base URL | `https://tokenhub-intl.tencentmaas.com/v1` (override) |
|
||||
| Default model | `tencent-tokenhub/hy3-preview` |
|
||||
Tencent Cloud ships as a **bundled provider plugin** in OpenClaw. It gives access to Tencent Hy3 preview through the TokenHub endpoint (`tencent-tokenhub`).
|
||||
|
||||
The provider uses an OpenAI-compatible API.
|
||||
|
||||
| Property | Value |
|
||||
| ------------- | ------------------------------------------ |
|
||||
| Provider | `tencent-tokenhub` |
|
||||
| Default model | `tencent-tokenhub/hy3-preview` |
|
||||
| Auth | `TOKENHUB_API_KEY` |
|
||||
| API | OpenAI-compatible chat completions |
|
||||
| Base URL | `https://tokenhub.tencentmaas.com/v1` |
|
||||
| Global URL | `https://tokenhub-intl.tencentmaas.com/v1` |
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -27,24 +28,9 @@ Tencent Cloud ships as a bundled provider plugin in OpenClaw. It gives access to
|
||||
Create an API key in Tencent Cloud TokenHub. If you choose a limited access scope for the key, include **Hy3 preview** in the allowed models.
|
||||
</Step>
|
||||
<Step title="Run onboarding">
|
||||
<CodeGroup>
|
||||
|
||||
```bash Onboarding
|
||||
openclaw onboard --auth-choice tokenhub-api-key
|
||||
```
|
||||
|
||||
```bash Direct flag
|
||||
openclaw onboard --non-interactive \
|
||||
--auth-choice tokenhub-api-key \
|
||||
--tokenhub-api-key "$TOKENHUB_API_KEY"
|
||||
```
|
||||
|
||||
```bash Env only
|
||||
export TOKENHUB_API_KEY=...
|
||||
```
|
||||
|
||||
</CodeGroup>
|
||||
|
||||
```bash
|
||||
openclaw onboard --auth-choice tokenhub-api-key
|
||||
```
|
||||
</Step>
|
||||
<Step title="Verify the model">
|
||||
```bash
|
||||
@@ -73,58 +59,38 @@ openclaw onboard --non-interactive \
|
||||
Hy3 preview is Tencent Hunyuan's large MoE language model for reasoning, long-context instruction following, code, and agent workflows. Tencent's OpenAI-compatible examples use `hy3-preview` as the model id and support standard chat-completions tool calling plus `reasoning_effort`.
|
||||
|
||||
<Tip>
|
||||
The model id is `hy3-preview`. Do not confuse it with Tencent's `HY-3D-*` models, which are 3D generation APIs and are not the OpenClaw chat model configured by this provider.
|
||||
The model id is `hy3-preview`. Do not confuse it with Tencent's `HY-3D-*` models, which are 3D generation APIs and are not the OpenClaw chat model configured by this provider.
|
||||
</Tip>
|
||||
|
||||
## Tiered pricing
|
||||
## Endpoint override
|
||||
|
||||
The bundled catalog ships tiered cost metadata that scales with input window length, so cost estimates are populated without manual overrides.
|
||||
OpenClaw defaults to Tencent Cloud's `https://tokenhub.tencentmaas.com/v1` endpoint. Tencent also documents an international TokenHub endpoint:
|
||||
|
||||
| Input tokens range | Input rate | Output rate | Cache read |
|
||||
| ------------------ | ---------- | ----------- | ---------- |
|
||||
| 0 - 16,000 | 0.176 | 0.587 | 0.059 |
|
||||
| 16,000 - 32,000 | 0.235 | 0.939 | 0.088 |
|
||||
| 32,000+ | 0.293 | 1.173 | 0.117 |
|
||||
```bash
|
||||
openclaw config set models.providers.tencent-tokenhub.baseUrl "https://tokenhub-intl.tencentmaas.com/v1"
|
||||
```
|
||||
|
||||
Rates are per million tokens in USD as advertised by Tencent. Override pricing under `models.providers.tencent-tokenhub` only when you need a different surface.
|
||||
Only override the endpoint when your TokenHub account or region requires it.
|
||||
|
||||
## Advanced configuration
|
||||
## Notes
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Endpoint override">
|
||||
OpenClaw defaults to Tencent Cloud's `https://tokenhub.tencentmaas.com/v1` endpoint. Tencent also documents an international TokenHub endpoint:
|
||||
- TokenHub model refs use `tencent-tokenhub/<modelId>`.
|
||||
- The bundled catalog currently includes `hy3-preview`.
|
||||
- The plugin marks Hy3 preview as reasoning-capable and streaming-usage capable.
|
||||
- The plugin ships with tiered Hy3 pricing metadata, so cost estimates are populated without manual pricing overrides.
|
||||
- Override pricing, context, or endpoint metadata in `models.providers` only when needed.
|
||||
|
||||
```bash
|
||||
openclaw config set models.providers.tencent-tokenhub.baseUrl "https://tokenhub-intl.tencentmaas.com/v1"
|
||||
```
|
||||
## Environment note
|
||||
|
||||
Only override the endpoint when your TokenHub account or region requires it.
|
||||
If the Gateway runs as a daemon (launchd/systemd), make sure `TOKENHUB_API_KEY`
|
||||
is available to that process (for example, in `~/.openclaw/.env` or via
|
||||
`env.shellEnv`).
|
||||
|
||||
</Accordion>
|
||||
## Related documentation
|
||||
|
||||
<Accordion title="Environment availability for the daemon">
|
||||
If the Gateway runs as a managed service (launchd, systemd, Docker), `TOKENHUB_API_KEY` must be visible to that process. Set it in `~/.openclaw/.env` or via `env.shellEnv` so launchd, systemd, or Docker exec environments can read it.
|
||||
|
||||
<Warning>
|
||||
Keys set only in `~/.profile` are not visible to managed gateway processes. Use the env file or config seam for persistent availability.
|
||||
</Warning>
|
||||
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Model providers" href="/concepts/model-providers" icon="layers">
|
||||
Choosing providers, model refs, and failover behavior.
|
||||
</Card>
|
||||
<Card title="Configuration reference" href="/gateway/configuration" icon="gear">
|
||||
Full config schema including provider settings.
|
||||
</Card>
|
||||
<Card title="Tencent TokenHub" href="https://cloud.tencent.com/product/tokenhub" icon="arrow-up-right-from-square">
|
||||
Tencent Cloud's TokenHub product page.
|
||||
</Card>
|
||||
<Card title="Hy3 preview model card" href="https://huggingface.co/tencent/Hy3-preview" icon="square-poll-horizontal">
|
||||
Tencent Hunyuan Hy3 preview details and benchmarks.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
- [OpenClaw Configuration](/gateway/configuration)
|
||||
- [Model Providers](/concepts/model-providers)
|
||||
- [Tencent TokenHub product page](https://cloud.tencent.com/product/tokenhub)
|
||||
- [Tencent TokenHub text generation](https://cloud.tencent.com/document/product/1823/130079)
|
||||
- [Tencent TokenHub Cline setup for Hy3 preview](https://cloud.tencent.com/document/product/1823/130932)
|
||||
- [Tencent Hy3 preview model card](https://huggingface.co/tencent/Hy3-preview)
|
||||
|
||||
@@ -14,18 +14,10 @@ The bundled Vydra plugin adds:
|
||||
|
||||
OpenClaw uses the same `VYDRA_API_KEY` for all three capabilities.
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | ------------------------------------------------------------------------- |
|
||||
| Provider id | `vydra` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `VYDRA_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice vydra-api-key` |
|
||||
| Direct CLI flag | `--vydra-api-key <key>` |
|
||||
| Contracts | `imageGenerationProviders`, `videoGenerationProviders`, `speechProviders` |
|
||||
| Base URL | `https://www.vydra.ai/api/v1` (use the `www` host) |
|
||||
|
||||
<Warning>
|
||||
Use `https://www.vydra.ai/api/v1` as the base URL. Vydra's apex host (`https://vydra.ai/api/v1`) currently redirects to `www`. Some HTTP clients drop `Authorization` on that cross-host redirect, which turns a valid API key into a misleading auth failure. The bundled plugin uses the `www` base URL directly to avoid that.
|
||||
Use `https://www.vydra.ai/api/v1` as the base URL.
|
||||
|
||||
Vydra's apex host (`https://vydra.ai/api/v1`) currently redirects to `www`. Some HTTP clients drop `Authorization` on that cross-host redirect, which turns a valid API key into a misleading auth failure. The bundled plugin uses the `www` base URL directly to avoid that.
|
||||
</Warning>
|
||||
|
||||
## Setup
|
||||
|
||||
@@ -6,20 +6,15 @@ read_when:
|
||||
title: "Xiaomi MiMo"
|
||||
---
|
||||
|
||||
Xiaomi MiMo is the API platform for **MiMo** models. OpenClaw includes a bundled `xiaomi` plugin that registers both an OpenAI-compatible chat provider and a speech (TTS) provider against the same `XIAOMI_API_KEY`.
|
||||
Xiaomi MiMo is the API platform for **MiMo** models. OpenClaw uses the Xiaomi
|
||||
OpenAI-compatible endpoint with API-key authentication.
|
||||
|
||||
| Property | Value |
|
||||
| --------------- | ---------------------------------------- |
|
||||
| Provider id | `xiaomi` |
|
||||
| Plugin | bundled, `enabledByDefault: true` |
|
||||
| Auth env var | `XIAOMI_API_KEY` |
|
||||
| Onboarding flag | `--auth-choice xiaomi-api-key` |
|
||||
| Direct CLI flag | `--xiaomi-api-key <key>` |
|
||||
| Contracts | chat completions + `speechProviders` |
|
||||
| API | OpenAI-compatible (`openai-completions`) |
|
||||
| Base URL | `https://api.xiaomimimo.com/v1` |
|
||||
| Default model | `xiaomi/mimo-v2-flash` |
|
||||
| TTS default | `mimo-v2.5-tts`, voice `mimo_default` |
|
||||
| Property | Value |
|
||||
| -------- | ------------------------------- |
|
||||
| Provider | `xiaomi` |
|
||||
| Auth | `XIAOMI_API_KEY` |
|
||||
| API | OpenAI-compatible |
|
||||
| Base URL | `https://api.xiaomimimo.com/v1` |
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md
|
||||
- Capture: decisions, preferences, constraints, open loops.
|
||||
- Avoid secrets unless explicitly requested.
|
||||
|
||||
## Tools and skills
|
||||
## Tools & skills
|
||||
|
||||
- Tools live in skills; follow each skill’s `SKILL.md` when you need it.
|
||||
- Keep environment-specific notes in `TOOLS.md` (Notes for Skills).
|
||||
|
||||
@@ -85,7 +85,7 @@ Session persistence has automatic maintenance controls (`session.maintenance`) f
|
||||
- `maxDiskBytes`: optional sessions-directory budget
|
||||
- `highWaterBytes`: optional target after cleanup (default `80%` of `maxDiskBytes`)
|
||||
|
||||
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations and takes a `sessions.json.lock` file lock while reading and writing the store. The file lock keeps Node worker threads and other runtime isolates from losing updates when they mutate the same session store. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately and prunes old unreferenced transcript, checkpoint, and trajectory artifacts even when no disk budget is configured.
|
||||
Normal Gateway writes flow through a per-store session writer that serializes in-process mutations without taking a runtime file lock. Hot-path patch helpers borrow the validated mutable cache while they hold that writer slot, so large `sessions.json` files are not cloned or reread for every metadata update. Runtime code should prefer `updateSessionStore(...)` or `updateSessionStoreEntry(...)`; direct whole-store saves are compatibility and offline-maintenance tools. When a Gateway is reachable, non-dry-run `openclaw sessions cleanup` and `openclaw agents delete` delegate store mutations to the Gateway so cleanup joins the same writer queue; `--store <path>` is the explicit offline repair path for direct file maintenance. `maxEntries` cleanup is still batched for production-sized caps, so a store may briefly exceed the configured cap before the next high-water cleanup rewrites it back down. Session store reads do not prune or cap entries during Gateway startup; use writes or `openclaw sessions cleanup --enforce` for cleanup. `openclaw sessions cleanup --enforce` still applies the configured cap immediately and prunes old unreferenced transcript, checkpoint, and trajectory artifacts even when no disk budget is configured.
|
||||
|
||||
Maintenance keeps durable external conversation pointers such as group sessions
|
||||
and thread-scoped chat sessions, but synthetic runtime entries for cron, hooks,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
summary: "code_execution: run sandboxed remote Python analysis with xAI"
|
||||
summary: "code_execution -- run sandboxed remote Python analysis with xAI"
|
||||
read_when:
|
||||
- You want to enable or configure code_execution
|
||||
- You want remote analysis without local shell access
|
||||
@@ -7,95 +7,53 @@ read_when:
|
||||
title: "Code execution"
|
||||
---
|
||||
|
||||
`code_execution` runs sandboxed remote Python analysis on xAI's Responses API. It is registered by the bundled `xai` plugin (under the `tools` contract) and dispatches to the same `https://api.x.ai/v1/responses` endpoint used by `x_search`.
|
||||
|
||||
| Property | Value |
|
||||
| ------------------ | -------------------------------------------------------------- |
|
||||
| Tool name | `code_execution` |
|
||||
| Provider plugin | `xai` (bundled, `enabledByDefault: true`) |
|
||||
| Auth | `XAI_API_KEY` or `plugins.entries.xai.config.webSearch.apiKey` |
|
||||
| Default model | `grok-4-1-fast` |
|
||||
| Default timeout | 30 seconds |
|
||||
| Default `maxTurns` | unset (xAI applies its own internal limit) |
|
||||
|
||||
`code_execution` runs sandboxed remote Python analysis on xAI's Responses API.
|
||||
This is different from local [`exec`](/tools/exec):
|
||||
|
||||
- `exec` runs shell commands on your machine or paired node.
|
||||
- `code_execution` runs Python in xAI's remote sandbox.
|
||||
- `exec` runs shell commands on your machine or node
|
||||
- `code_execution` runs Python in xAI's remote sandbox
|
||||
|
||||
Use `code_execution` for:
|
||||
|
||||
- Calculations.
|
||||
- Tabulation.
|
||||
- Quick statistics.
|
||||
- Chart-style analysis.
|
||||
- Analyzing data returned by `x_search` or `web_search`.
|
||||
- calculations
|
||||
- tabulation
|
||||
- quick statistics
|
||||
- chart-style analysis
|
||||
- analyzing data returned by `x_search` or `web_search`
|
||||
|
||||
Do **not** use it when you need local files, your shell, your repo, or paired devices. Use [`exec`](/tools/exec) for that.
|
||||
Do **not** use it when you need local files, your shell, your repo, or paired
|
||||
devices. Use [`exec`](/tools/exec) for that.
|
||||
|
||||
## Setup
|
||||
|
||||
<Steps>
|
||||
<Step title="Provide an xAI API key">
|
||||
Set `XAI_API_KEY` in the gateway environment, or configure the key under the xAI plugin so the same credential covers `code_execution`, `x_search`, web search, and other xAI tools:
|
||||
You need an xAI API key. Any of these work:
|
||||
|
||||
```bash
|
||||
export XAI_API_KEY=xai-...
|
||||
```
|
||||
- `XAI_API_KEY`
|
||||
- `plugins.entries.xai.config.webSearch.apiKey`
|
||||
|
||||
Or via config:
|
||||
Example:
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-...",
|
||||
},
|
||||
},
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
webSearch: {
|
||||
apiKey: "xai-...",
|
||||
},
|
||||
codeExecution: {
|
||||
enabled: true,
|
||||
model: "grok-4-1-fast",
|
||||
maxTurns: 2,
|
||||
timeoutSeconds: 30,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Enable and tune code_execution">
|
||||
The tool is gated on `plugins.entries.xai.config.codeExecution.enabled`. Default is off.
|
||||
|
||||
```json5
|
||||
{
|
||||
plugins: {
|
||||
entries: {
|
||||
xai: {
|
||||
config: {
|
||||
codeExecution: {
|
||||
enabled: true,
|
||||
model: "grok-4-1-fast", // override the default xAI code-execution model
|
||||
maxTurns: 2, // optional cap on internal tool turns
|
||||
timeoutSeconds: 30, // request timeout (default: 30)
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
<Step title="Restart the Gateway">
|
||||
```bash
|
||||
openclaw gateway restart
|
||||
```
|
||||
|
||||
`code_execution` shows up in the agent's tool list once the xAI plugin re-registers with `enabled: true`.
|
||||
|
||||
</Step>
|
||||
</Steps>
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## How to use it
|
||||
|
||||
@@ -113,40 +71,20 @@ Use x_search to find posts mentioning OpenClaw this week, then use code_executio
|
||||
Use web_search to gather the latest AI benchmark numbers, then use code_execution to compare percent changes.
|
||||
```
|
||||
|
||||
The tool takes a single `task` parameter internally, so the agent should send the full analysis request and any inline data in one prompt.
|
||||
|
||||
## Errors
|
||||
|
||||
When the tool runs without auth, it returns a structured `missing_xai_api_key` error pointing at the env var and config path. The error is JSON, not a thrown exception, so the agent can self-correct:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "missing_xai_api_key",
|
||||
"message": "code_execution needs an xAI API key. Set XAI_API_KEY in the Gateway environment, or configure plugins.entries.xai.config.webSearch.apiKey.",
|
||||
"docs": "https://docs.openclaw.ai/tools/code-execution"
|
||||
}
|
||||
```
|
||||
The tool takes a single `task` parameter internally, so the agent should send
|
||||
the full analysis request and any inline data in one prompt.
|
||||
|
||||
## Limits
|
||||
|
||||
- This is remote xAI execution, not local process execution.
|
||||
- Treat results as ephemeral analysis, not a persistent notebook session.
|
||||
- It should be treated as ephemeral analysis, not a persistent notebook.
|
||||
- Do not assume access to local files or your workspace.
|
||||
- For fresh X data, use [`x_search`](/tools/web#x_search) first and pipe the result into `code_execution`.
|
||||
- For fresh X data, use [`x_search`](/tools/web#x_search) first.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Exec tool" href="/tools/exec" icon="terminal">
|
||||
Local shell execution on your machine or paired node.
|
||||
</Card>
|
||||
<Card title="Exec approvals" href="/tools/exec-approvals" icon="shield">
|
||||
Allow/deny policy for shell execution.
|
||||
</Card>
|
||||
<Card title="Web tools" href="/tools/web" icon="globe">
|
||||
`web_search`, `x_search`, and `web_fetch`.
|
||||
</Card>
|
||||
<Card title="xAI provider" href="/providers/xai" icon="microchip">
|
||||
Grok models, web/x search, and code execution config.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
- [Exec tool](/tools/exec)
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
- [apply_patch tool](/tools/apply-patch)
|
||||
- [Web tools](/tools/web)
|
||||
- [xAI](/providers/xai)
|
||||
|
||||
@@ -102,27 +102,13 @@ Allowlist entry formats:
|
||||
|
||||
## What elevated does not control
|
||||
|
||||
- **Tool policy**: if `exec` is denied by tool policy, elevated cannot override it.
|
||||
- **Tool policy**: if `exec` is denied by tool policy, elevated cannot override it
|
||||
- **Host selection policy**: elevated does not turn `auto` into a free cross-host override. It uses the configured/session exec target rules, choosing `node` only when the target is already `node`.
|
||||
- **Separate from `/exec`**: the `/exec` directive adjusts per-session exec defaults for authorized senders and does not require elevated mode.
|
||||
|
||||
<Note>
|
||||
The bash chat command (`!` prefix; `/bash` alias) is a separate gate that requires `tools.elevated` to be enabled in addition to its own `tools.bash.enabled` flag. Disabling elevated locks `!` shell commands out as well.
|
||||
</Note>
|
||||
- **Separate from `/exec`**: the `/exec` directive adjusts per-session exec defaults for authorized senders and does not require elevated mode
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Exec tool" href="/tools/exec" icon="terminal">
|
||||
Shell command execution from the agent.
|
||||
</Card>
|
||||
<Card title="Exec approvals" href="/tools/exec-approvals" icon="shield">
|
||||
Approval and allowlist system for `exec`.
|
||||
</Card>
|
||||
<Card title="Sandboxing" href="/gateway/sandboxing" icon="box">
|
||||
Gateway-level sandbox configuration.
|
||||
</Card>
|
||||
<Card title="Sandbox vs Tool Policy vs Elevated" href="/gateway/sandbox-vs-tool-policy-vs-elevated" icon="scale-balanced">
|
||||
How the three gates compose during a tool call.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
- [Exec tool](/tools/exec) — shell command execution
|
||||
- [Exec approvals](/tools/exec-approvals) — approval and allowlist system
|
||||
- [Sandboxing](/gateway/sandboxing) — sandbox configuration
|
||||
- [Sandbox vs Tool Policy vs Elevated](/gateway/sandbox-vs-tool-policy-vs-elevated)
|
||||
|
||||
@@ -106,7 +106,7 @@ automatically.
|
||||
| ---------------- | ------------------------------------------------------ | ---------------------------------------------------------------------------------- |
|
||||
| Goal | Auto-allow narrow stdin filters | Explicitly trust specific executables |
|
||||
| Match type | Executable name + safe-bin argv policy | Resolved executable path glob, or bare command-name glob for PATH-invoked commands |
|
||||
| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match by default; optional `argPattern` can restrict parsed argv |
|
||||
| Argument scope | Restricted by safe-bin profile and literal-token rules | Path match only; arguments are otherwise your responsibility |
|
||||
| Typical examples | `head`, `tail`, `tr`, `wc` | `jq`, `python3`, `node`, `ffmpeg`, custom CLIs |
|
||||
| Best use | Low-risk text transforms in pipelines | Any tool with broader behavior or side effects |
|
||||
|
||||
|
||||
@@ -299,52 +299,14 @@ Examples:
|
||||
- `~/.local/bin/*`
|
||||
- `/opt/homebrew/bin/rg`
|
||||
|
||||
### Restricting arguments with argPattern
|
||||
Each allowlist entry tracks:
|
||||
|
||||
Add `argPattern` when an allowlist entry should match a binary and a
|
||||
specific argument shape. OpenClaw evaluates the regular expression
|
||||
against the parsed command arguments, excluding the executable token
|
||||
(`argv[0]`). For hand-authored entries, arguments are joined with a
|
||||
single space, so anchor the pattern when you need an exact match.
|
||||
|
||||
```json
|
||||
{
|
||||
"version": 1,
|
||||
"agents": {
|
||||
"main": {
|
||||
"allowlist": [
|
||||
{
|
||||
"pattern": "python3",
|
||||
"argPattern": "^safe\\.py$"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That entry allows `python3 safe.py`; `python3 other.py` is an allowlist
|
||||
miss. If a path-only entry for the same binary is also present, unmatched
|
||||
arguments can still fall back to that path-only entry. Omit the path-only
|
||||
entry when the goal is to restrict the binary to the declared arguments.
|
||||
|
||||
Entries saved by approval flows can use an internal separator format for
|
||||
exact argv matching. Prefer the UI or approval flow to regenerate those
|
||||
entries instead of hand-editing the encoded value. If OpenClaw cannot
|
||||
parse argv for a command segment, entries with `argPattern` do not match.
|
||||
|
||||
Each allowlist entry supports:
|
||||
|
||||
| Field | Meaning |
|
||||
| ------------------ | ------------------------------------------------------------- |
|
||||
| `pattern` | Resolved binary path glob or bare command-name glob |
|
||||
| `argPattern` | Optional argv regex; omitted entries are path-only |
|
||||
| `id` | Stable UUID used for UI identity |
|
||||
| `source` | Entry source, such as `allow-always` |
|
||||
| `commandText` | Command text captured when an approval flow created the entry |
|
||||
| `lastUsedAt` | Last-used timestamp |
|
||||
| `lastUsedCommand` | Last command that matched |
|
||||
| `lastResolvedPath` | Last resolved binary path |
|
||||
| Field | Meaning |
|
||||
| ------------------ | -------------------------------- |
|
||||
| `id` | Stable UUID used for UI identity |
|
||||
| `lastUsedAt` | Last-used timestamp |
|
||||
| `lastUsedCommand` | Last command that matched |
|
||||
| `lastResolvedPath` | Last resolved binary path |
|
||||
|
||||
## Auto-allow skill CLIs
|
||||
|
||||
|
||||
@@ -5,45 +5,37 @@ read_when:
|
||||
- A user reports agents getting stuck repeating tool calls
|
||||
- You need to tune repetitive-call protection
|
||||
- You are editing agent tool/runtime policies
|
||||
- You hit `compaction_loop_persisted` aborts after a context-overflow retry
|
||||
---
|
||||
|
||||
OpenClaw has two cooperating guardrails for repetitive tool-call patterns:
|
||||
OpenClaw can keep agents from getting stuck in repeated tool-call patterns.
|
||||
The guard is **disabled by default**.
|
||||
|
||||
1. **Loop detection** (`tools.loopDetection.enabled`) — disabled by default. Watches the rolling tool-call history for repeated patterns and unknown-tool retries.
|
||||
2. **Post-compaction guard** (`tools.loopDetection.postCompactionGuard`) — enabled by default unless `tools.loopDetection.enabled` is explicitly `false`. Arms after every compaction-retry and aborts the run when the agent emits the same `(tool, args, result)` triple within the window.
|
||||
|
||||
Both are configured under the same `tools.loopDetection` block, but the post-compaction guard runs whenever the master switch is not explicitly off. Set `tools.loopDetection.enabled: false` to silence both surfaces.
|
||||
Enable it only where needed, because it can block legitimate repeated calls with strict settings.
|
||||
|
||||
## Why this exists
|
||||
|
||||
- Detect repetitive sequences that do not make progress.
|
||||
- Detect high-frequency no-result loops (same tool, same inputs, repeated errors).
|
||||
- Detect specific repeated-call patterns for known polling tools.
|
||||
- Prevent context-overflow then compaction then same-loop cycles from running indefinitely.
|
||||
|
||||
## Configuration block
|
||||
|
||||
Global defaults, with every documented field shown:
|
||||
Global defaults:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
loopDetection: {
|
||||
enabled: false, // master switch for the rolling-history detectors
|
||||
enabled: false,
|
||||
historySize: 30,
|
||||
warningThreshold: 10,
|
||||
criticalThreshold: 20,
|
||||
unknownToolThreshold: 10,
|
||||
globalCircuitBreakerThreshold: 30,
|
||||
detectors: {
|
||||
genericRepeat: true,
|
||||
knownPollNoProgress: true,
|
||||
pingPong: true,
|
||||
},
|
||||
postCompactionGuard: {
|
||||
windowSize: 3, // armed after compaction-retry; runs unless enabled is explicitly false
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -72,83 +64,67 @@ Per-agent override (optional):
|
||||
|
||||
### Field behavior
|
||||
|
||||
| Field | Default | Effect |
|
||||
| -------------------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `enabled` | `false` | Master switch for the rolling-history detectors. Setting `false` also disables the post-compaction guard. |
|
||||
| `historySize` | `30` | Number of recent tool calls kept for analysis. |
|
||||
| `warningThreshold` | `10` | Threshold before a pattern is classified as warning-only. |
|
||||
| `criticalThreshold` | `20` | Threshold for blocking repetitive loop patterns. |
|
||||
| `unknownToolThreshold` | `10` | Block repeated calls to the same unavailable tool after this many misses. |
|
||||
| `globalCircuitBreakerThreshold` | `30` | Global no-progress breaker threshold across all detectors. |
|
||||
| `detectors.genericRepeat` | `true` | Detects repeated same-tool + same-params patterns. |
|
||||
| `detectors.knownPollNoProgress` | `true` | Detects known polling-like patterns with no state change. |
|
||||
| `detectors.pingPong` | `true` | Detects alternating ping-pong patterns. |
|
||||
| `postCompactionGuard.windowSize` | `3` | Number of post-compaction tool calls during which the guard stays armed and the count of identical triples that aborts the run. |
|
||||
- `enabled`: Master switch. `false` means no loop detection is performed.
|
||||
- `historySize`: number of recent tool calls kept for analysis.
|
||||
- `warningThreshold`: threshold before classifying a pattern as warning-only.
|
||||
- `criticalThreshold`: threshold for blocking repetitive loop patterns.
|
||||
- `globalCircuitBreakerThreshold`: global no-progress breaker threshold.
|
||||
- `detectors.genericRepeat`: detects repeated same-tool + same-params patterns.
|
||||
- `detectors.knownPollNoProgress`: detects known polling-like patterns with no state change.
|
||||
- `detectors.pingPong`: detects alternating ping-pong patterns.
|
||||
|
||||
For `exec`, no-progress checks compare stable command outcomes and ignore volatile runtime metadata such as duration, PID, session ID, and working directory. When a run id is available, recent tool-call history is evaluated only within that run so scheduled heartbeat cycles and fresh runs do not inherit stale loop counts from earlier runs.
|
||||
For `exec`, no-progress checks compare stable command outcomes and ignore volatile runtime metadata such as duration, PID, session ID, and working directory.
|
||||
When a run id is available, recent tool-call history is evaluated only within that run so scheduled heartbeat cycles and fresh runs do not inherit stale loop counts from earlier runs.
|
||||
|
||||
## Recommended setup
|
||||
|
||||
- For smaller models, set `enabled: true` and leave the thresholds at their defaults. Flagship models rarely need rolling-history detection and can leave the master switch at `false` while still benefiting from the post-compaction guard.
|
||||
- For smaller models, start with `enabled: true`, defaults unchanged. Flagship models rarely need loop detection and can leave it disabled.
|
||||
- Keep thresholds ordered as `warningThreshold < criticalThreshold < globalCircuitBreakerThreshold`.
|
||||
- If false positives occur:
|
||||
- Raise `warningThreshold` and/or `criticalThreshold`.
|
||||
- Optionally raise `globalCircuitBreakerThreshold`.
|
||||
- Disable only the specific detector causing issues (`detectors.<name>: false`).
|
||||
- Reduce `historySize` for less strict historical context.
|
||||
- To disable everything (including the post-compaction guard), set `tools.loopDetection.enabled: false` explicitly.
|
||||
- raise `warningThreshold` and/or `criticalThreshold`
|
||||
- (optionally) raise `globalCircuitBreakerThreshold`
|
||||
- disable only the detector causing issues
|
||||
- reduce `historySize` for less strict historical context
|
||||
|
||||
## Post-compaction guard
|
||||
|
||||
When the runner completes a compaction-retry after a context-overflow, it arms a short-window guard that watches the next few tool calls. If the agent emits the same `(toolName, argsHash, resultHash)` triple multiple times within the window, the guard concludes that compaction did not break the loop and aborts the run with a `compaction_loop_persisted` error.
|
||||
When the runner completes an auto-compaction-retry (after a context-overflow), it arms a short-window guard that watches the next few tool calls. If the agent emits the _same_ `(toolName, args, result)` triple multiple times within that window, the guard concludes that compaction did not break the loop and aborts the run with a `compaction_loop_persisted` error.
|
||||
|
||||
The guard is gated by the master `tools.loopDetection.enabled` flag with one twist: it stays **enabled when the flag is unset or `true`** and only deactivates when the flag is explicitly `false`. This is intentional. The guard exists to escape compaction loops that would otherwise burn unbounded tokens, so a no-config user still gets the protection.
|
||||
This is a separate code path from the global `tools.loopDetection` detectors. It is independently configurable:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
loopDetection: {
|
||||
// master switch; set false to disable the guard along with the rolling detectors
|
||||
enabled: true,
|
||||
enabled: true, // existing master switch; set false to disable loop guards
|
||||
postCompactionGuard: {
|
||||
windowSize: 3, // default
|
||||
windowSize: 3, // default: 3
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- Lower `windowSize` is stricter (fewer attempts before abort).
|
||||
- Higher `windowSize` gives the agent more recovery attempts.
|
||||
- The guard never aborts when results are changing, only when results are byte-identical across the window.
|
||||
- It is intentionally narrow: it fires only in the immediate aftermath of a compaction-retry.
|
||||
- `windowSize`: number of post-compaction tool calls during which the guard stays armed _and_ the count of identical (tool, args, result) triples that triggers an abort.
|
||||
|
||||
<Note>
|
||||
The post-compaction guard runs whenever the master flag is not explicitly `false`, even if you never wrote a `tools.loopDetection` block. To verify, look for `post-compaction guard armed for N attempts` in the gateway log immediately after a compaction event.
|
||||
</Note>
|
||||
The guard never aborts when results are changing, only when results are byte-identical across the window. It is intentionally narrow: it fires only in the immediate aftermath of a compaction-retry.
|
||||
|
||||
## Logs and expected behavior
|
||||
|
||||
When a loop is detected, OpenClaw reports a loop event and either dampens or blocks the next tool-cycle depending on severity. This protects users from runaway token spend and lockups while preserving normal tool access.
|
||||
When a loop is detected, OpenClaw reports a loop event and blocks or dampens the next tool-cycle depending on severity.
|
||||
This protects users from runaway token spend and lockups while preserving normal tool access.
|
||||
|
||||
- Warnings come first.
|
||||
- Suppression follows when patterns persist past the warning threshold.
|
||||
- Critical thresholds block the next tool-cycle and surface a clear loop-detection reason in the run record.
|
||||
- The post-compaction guard emits `compaction_loop_persisted` errors with the offending tool name and identical-call count.
|
||||
- Prefer warning and temporary suppression first.
|
||||
- Escalate only when repeated evidence accumulates.
|
||||
|
||||
## Notes
|
||||
|
||||
- `tools.loopDetection` is merged with agent-level overrides.
|
||||
- Per-agent config fully overrides or extends global values.
|
||||
- If no config exists, guardrails stay off.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Exec approvals" href="/tools/exec-approvals" icon="shield">
|
||||
Allow/deny policy for shell execution.
|
||||
</Card>
|
||||
<Card title="Thinking levels" href="/tools/thinking" icon="brain">
|
||||
Reasoning effort levels and provider-policy interaction.
|
||||
</Card>
|
||||
<Card title="Sub-agents" href="/tools/subagents" icon="users">
|
||||
Spawning isolated agents to bound runaway behavior.
|
||||
</Card>
|
||||
<Card title="Configuration reference" href="/gateway/configuration-reference" icon="gear">
|
||||
Full `tools.loopDetection` schema and merging semantics.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
- [Exec approvals](/tools/exec-approvals)
|
||||
- [Thinking levels](/tools/thinking)
|
||||
- [Sub-agents](/tools/subagents)
|
||||
|
||||
@@ -164,12 +164,6 @@ Packaged installs must ship that JavaScript runtime output. The TypeScript
|
||||
source fallback is for source checkouts and local development paths, not for
|
||||
npm packages installed into OpenClaw's managed plugin root.
|
||||
|
||||
If a managed package warning says it `requires compiled runtime output for
|
||||
TypeScript entry ...`, the package was published without the JavaScript files
|
||||
OpenClaw needs at runtime. That is a plugin packaging issue, not a local config
|
||||
problem. Update or reinstall the plugin after the publisher republishes compiled
|
||||
JavaScript, or disable/uninstall that plugin until a fixed package is available.
|
||||
|
||||
Use `openclaw.runtimeExtensions` when published runtime files do not live at the
|
||||
same paths as the source entries. When present, `runtimeExtensions` must contain
|
||||
exactly one entry for every `extensions` entry. Mismatched lists fail install and
|
||||
|
||||
@@ -118,32 +118,20 @@ Per-skill fields:
|
||||
`skills.load.extraDirs`.
|
||||
- Changes to skills are picked up on the next agent turn when the watcher is enabled.
|
||||
|
||||
### Sandboxed skills and env vars
|
||||
### Sandboxed skills + env vars
|
||||
|
||||
When a session is **sandboxed**, skill processes run inside the configured sandbox backend. The sandbox does **not** inherit the host `process.env`.
|
||||
|
||||
<Warning>
|
||||
Global `env` and `skills.entries.<skill>.env`/`apiKey` apply to **host** runs only. Inside a sandbox they have no effect, so a skill that depends on `GEMINI_API_KEY` will fail with `apiKey not configured` unless the sandbox is given the variable separately.
|
||||
</Warning>
|
||||
When a session is **sandboxed**, skill processes run inside the configured
|
||||
sandbox backend. The sandbox does **not** inherit the host `process.env`.
|
||||
|
||||
Use one of:
|
||||
|
||||
- `agents.defaults.sandbox.docker.env` for the Docker backend (or per-agent `agents.list[].sandbox.docker.env`).
|
||||
- Bake the env into your custom sandbox image or remote sandbox environment.
|
||||
- `agents.defaults.sandbox.docker.env` for the Docker backend (or per-agent `agents.list[].sandbox.docker.env`)
|
||||
- bake the env into your custom sandbox image or remote sandbox environment
|
||||
|
||||
Global `env` and `skills.entries.<skill>.env/apiKey` apply to **host** runs only.
|
||||
|
||||
## Related
|
||||
|
||||
<CardGroup cols={2}>
|
||||
<Card title="Skills" href="/tools/skills" icon="puzzle-piece">
|
||||
What skills are and how they load.
|
||||
</Card>
|
||||
<Card title="Creating skills" href="/tools/creating-skills" icon="hammer">
|
||||
Authoring custom skill packs.
|
||||
</Card>
|
||||
<Card title="Slash commands" href="/tools/slash-commands" icon="terminal">
|
||||
Native command catalog and chat directives.
|
||||
</Card>
|
||||
<Card title="Configuration reference" href="/gateway/configuration-reference" icon="gear">
|
||||
Full `skills` and `agents.skills` schema.
|
||||
</Card>
|
||||
</CardGroup>
|
||||
- [Skills](/tools/skills)
|
||||
- [Creating skills](/tools/creating-skills)
|
||||
- [Slash commands](/tools/slash-commands)
|
||||
|
||||
@@ -188,7 +188,6 @@ describe("active-memory plugin", () => {
|
||||
payloads: [{ text: "- lemon pepper wings\n- blue cheese" }],
|
||||
});
|
||||
__testing.resetActiveRecallCacheForTests();
|
||||
__testing.setTimeoutPartialDataGraceMsForTests(5);
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
});
|
||||
|
||||
|
||||
@@ -248,7 +248,6 @@ const toggleStoreLocks = new Map<string, AsyncLock>();
|
||||
let lastActiveRecallCacheSweepAt = 0;
|
||||
let minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
|
||||
let setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
|
||||
let timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS;
|
||||
|
||||
function createAsyncLock(): AsyncLock {
|
||||
let lock: Promise<void> = Promise.resolve();
|
||||
@@ -1907,7 +1906,7 @@ async function waitForSubagentPartialTimeoutData(
|
||||
}
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
const timeoutPromise = new Promise<undefined>((resolve) => {
|
||||
timeoutId = setTimeout(() => resolve(undefined), timeoutPartialDataGraceMs);
|
||||
timeoutId = setTimeout(() => resolve(undefined), TIMEOUT_PARTIAL_DATA_GRACE_MS);
|
||||
timeoutId.unref?.();
|
||||
});
|
||||
try {
|
||||
@@ -3010,7 +3009,6 @@ const testing = {
|
||||
lastActiveRecallCacheSweepAt = 0;
|
||||
minimumTimeoutMs = DEFAULT_MIN_TIMEOUT_MS;
|
||||
setupGraceTimeoutMs = DEFAULT_SETUP_GRACE_TIMEOUT_MS;
|
||||
timeoutPartialDataGraceMs = TIMEOUT_PARTIAL_DATA_GRACE_MS;
|
||||
},
|
||||
setMinimumTimeoutMsForTests(value: number) {
|
||||
minimumTimeoutMs = value;
|
||||
@@ -3018,9 +3016,6 @@ const testing = {
|
||||
setSetupGraceTimeoutMsForTests(value: number) {
|
||||
setupGraceTimeoutMs = Math.max(0, Math.floor(value));
|
||||
},
|
||||
setTimeoutPartialDataGraceMsForTests(value: number) {
|
||||
timeoutPartialDataGraceMs = Math.max(0, Math.floor(value));
|
||||
},
|
||||
setCachedResult,
|
||||
getCircuitBreakerEntry(key: string) {
|
||||
return timeoutCircuitBreaker.get(key);
|
||||
|
||||
@@ -1,178 +0,0 @@
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { bluebubblesPlugin } from "./channel.js";
|
||||
|
||||
const sendMessageBlueBubblesMock = vi.hoisted(() => vi.fn());
|
||||
const sendBlueBubblesMediaMock = vi.hoisted(() => vi.fn());
|
||||
const resolveBlueBubblesMessageIdMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./channel.runtime.js", () => ({
|
||||
blueBubblesChannelRuntime: {
|
||||
sendMessageBlueBubbles: sendMessageBlueBubblesMock,
|
||||
sendBlueBubblesMedia: sendBlueBubblesMediaMock,
|
||||
resolveBlueBubblesMessageId: resolveBlueBubblesMessageIdMock,
|
||||
},
|
||||
}));
|
||||
|
||||
describe("bluebubbles message adapter", () => {
|
||||
it("declares durable text, media, and reply target capabilities with receipt proofs", async () => {
|
||||
sendMessageBlueBubblesMock.mockImplementation(
|
||||
async (_to: string, _text: string, opts: { replyToMessageGuid?: string } = {}) => ({
|
||||
messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
{
|
||||
channel: "bluebubbles",
|
||||
messageId: opts.replyToMessageGuid ? "bb-reply-1" : "bb-text-1",
|
||||
},
|
||||
],
|
||||
kind: "text",
|
||||
...(opts.replyToMessageGuid ? { replyToId: opts.replyToMessageGuid } : {}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
sendBlueBubblesMediaMock.mockResolvedValue({
|
||||
messageId: "bb-media-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "bluebubbles", messageId: "bb-media-1" }],
|
||||
kind: "media",
|
||||
}),
|
||||
});
|
||||
resolveBlueBubblesMessageIdMock.mockReturnValue("guid-reply-1");
|
||||
|
||||
await expect(
|
||||
verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "bluebubbles",
|
||||
adapter: bluebubblesPlugin.message!,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await bluebubblesPlugin.message?.send?.text?.({
|
||||
cfg: {},
|
||||
to: "+15551234567",
|
||||
text: "hello",
|
||||
});
|
||||
expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("+15551234567", "hello", {
|
||||
cfg: {},
|
||||
accountId: undefined,
|
||||
replyToMessageGuid: undefined,
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["bb-text-1"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await bluebubblesPlugin.message?.send?.media?.({
|
||||
cfg: {},
|
||||
to: "+15551234567",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
});
|
||||
expect(sendBlueBubblesMediaMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
to: "+15551234567",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
caption: "image",
|
||||
}),
|
||||
);
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["bb-media-1"]);
|
||||
},
|
||||
replyTo: async () => {
|
||||
const result = await bluebubblesPlugin.message?.send?.text?.({
|
||||
cfg: {},
|
||||
to: "chat_guid:chat-1",
|
||||
text: "reply",
|
||||
replyToId: "short-1",
|
||||
});
|
||||
expect(resolveBlueBubblesMessageIdMock).toHaveBeenCalledWith(
|
||||
"short-1",
|
||||
expect.objectContaining({ requireKnownShortId: true }),
|
||||
);
|
||||
expect(sendMessageBlueBubblesMock).toHaveBeenCalledWith("chat_guid:chat-1", "reply", {
|
||||
cfg: {},
|
||||
accountId: undefined,
|
||||
replyToMessageGuid: "guid-reply-1",
|
||||
});
|
||||
expect(result?.receipt.replyToId).toBe("guid-reply-1");
|
||||
},
|
||||
messageSendingHooks: async () => {
|
||||
const beforeSendAttempt = vi.fn(() => "pending-1");
|
||||
const afterSendFailure = vi.fn();
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
kind: "text" as const,
|
||||
to: "+15551234567",
|
||||
text: "hello",
|
||||
deps: {
|
||||
bluebubblesMessageLifecycle: {
|
||||
beforeSendAttempt,
|
||||
afterSendFailure,
|
||||
},
|
||||
},
|
||||
};
|
||||
const attemptToken =
|
||||
await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx);
|
||||
await bluebubblesPlugin.message?.send?.lifecycle?.afterSendFailure?.({
|
||||
...ctx,
|
||||
error: new Error("send failed"),
|
||||
attemptToken,
|
||||
});
|
||||
expect(beforeSendAttempt).toHaveBeenCalledWith(ctx);
|
||||
expect(afterSendFailure).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "text",
|
||||
attemptToken: "pending-1",
|
||||
error: expect.any(Error),
|
||||
}),
|
||||
);
|
||||
},
|
||||
afterSendSuccess: async () => {
|
||||
const beforeSendAttempt = vi.fn(() => "pending-1");
|
||||
const afterSendSuccess = vi.fn();
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
kind: "text" as const,
|
||||
to: "+15551234567",
|
||||
text: "hello",
|
||||
deps: {
|
||||
bluebubblesMessageLifecycle: {
|
||||
beforeSendAttempt,
|
||||
afterSendSuccess,
|
||||
},
|
||||
},
|
||||
};
|
||||
const attemptToken =
|
||||
await bluebubblesPlugin.message?.send?.lifecycle?.beforeSendAttempt?.(ctx);
|
||||
await bluebubblesPlugin.message?.send?.lifecycle?.afterSendSuccess?.({
|
||||
...ctx,
|
||||
result: {
|
||||
messageId: "bb-text-1",
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [{ channel: "bluebubbles", messageId: "bb-text-1" }],
|
||||
kind: "text",
|
||||
}),
|
||||
},
|
||||
attemptToken,
|
||||
});
|
||||
expect(beforeSendAttempt).toHaveBeenCalledWith(ctx);
|
||||
expect(afterSendSuccess).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
kind: "text",
|
||||
attemptToken: "pending-1",
|
||||
result: expect.objectContaining({ messageId: "bb-text-1" }),
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual(
|
||||
expect.arrayContaining([
|
||||
{ capability: "text", status: "verified" },
|
||||
{ capability: "media", status: "verified" },
|
||||
{ capability: "replyTo", status: "verified" },
|
||||
{ capability: "messageSendingHooks", status: "verified" },
|
||||
{ capability: "afterSendSuccess", status: "verified" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,21 +2,11 @@ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
import { createScopedDmSecurityResolver } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
defineChannelMessageAdapter,
|
||||
type ChannelMessageSendAttemptContext,
|
||||
type ChannelMessageSendFailureContext,
|
||||
type ChannelMessageSendSuccessContext,
|
||||
type ChannelMessageSendResult,
|
||||
type MessageReceiptPartKind,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import {
|
||||
createOpenGroupPolicyRestrictSendersWarningCollector,
|
||||
projectAccountWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
@@ -71,169 +61,6 @@ const loadBlueBubblesChannelRuntime = createLazyRuntimeNamedExport(
|
||||
"blueBubblesChannelRuntime",
|
||||
);
|
||||
|
||||
type BlueBubblesRuntime = Awaited<ReturnType<typeof loadBlueBubblesChannelRuntime>>;
|
||||
type BlueBubblesMediaExtras = {
|
||||
mediaPath?: string;
|
||||
mediaBuffer?: Uint8Array;
|
||||
contentType?: string;
|
||||
filename?: string;
|
||||
caption?: string;
|
||||
};
|
||||
type BlueBubblesMessageLifecycleDeps = {
|
||||
beforeSendAttempt?: (ctx: ChannelMessageSendAttemptContext) => unknown;
|
||||
afterSendSuccess?: (ctx: ChannelMessageSendSuccessContext) => Promise<void> | void;
|
||||
afterSendFailure?: (ctx: ChannelMessageSendFailureContext) => Promise<void> | void;
|
||||
};
|
||||
|
||||
function resolveBlueBubblesMessageLifecycleDeps(
|
||||
ctx:
|
||||
| ChannelMessageSendAttemptContext
|
||||
| ChannelMessageSendSuccessContext
|
||||
| ChannelMessageSendFailureContext,
|
||||
): BlueBubblesMessageLifecycleDeps | undefined {
|
||||
const candidate = ctx.deps?.bluebubblesMessageLifecycle;
|
||||
if (!candidate || typeof candidate !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
return candidate as BlueBubblesMessageLifecycleDeps;
|
||||
}
|
||||
|
||||
function resolveBlueBubblesReplyToMessageGuid(params: {
|
||||
runtime: BlueBubblesRuntime;
|
||||
to: string;
|
||||
replyToId?: string | null;
|
||||
}): string | undefined {
|
||||
const rawReplyToId = normalizeOptionalString(params.replyToId) ?? "";
|
||||
if (!rawReplyToId) {
|
||||
return undefined;
|
||||
}
|
||||
return (
|
||||
params.runtime.resolveBlueBubblesMessageId(rawReplyToId, {
|
||||
requireKnownShortId: true,
|
||||
chatContext: buildBlueBubblesChatContextFromTarget(params.to),
|
||||
}) || undefined
|
||||
);
|
||||
}
|
||||
|
||||
async function sendBlueBubblesTextWithRuntime(params: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text: string;
|
||||
accountId?: string;
|
||||
replyToId?: string | null;
|
||||
}) {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
return await runtime.sendMessageBlueBubbles(params.to, params.text, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
replyToMessageGuid: resolveBlueBubblesReplyToMessageGuid({
|
||||
runtime,
|
||||
to: params.to,
|
||||
replyToId: params.replyToId,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async function sendBlueBubblesMediaWithRuntime(params: {
|
||||
cfg: OpenClawConfig;
|
||||
to: string;
|
||||
text?: string;
|
||||
mediaUrl: string;
|
||||
accountId?: string;
|
||||
replyToId?: string | null;
|
||||
audioAsVoice?: boolean;
|
||||
extras?: BlueBubblesMediaExtras;
|
||||
}) {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
return await runtime.sendBlueBubblesMedia({
|
||||
cfg: params.cfg,
|
||||
to: params.to,
|
||||
mediaUrl: params.mediaUrl,
|
||||
mediaPath: params.extras?.mediaPath,
|
||||
mediaBuffer: params.extras?.mediaBuffer,
|
||||
contentType: params.extras?.contentType,
|
||||
filename: params.extras?.filename,
|
||||
caption: params.extras?.caption ?? params.text ?? undefined,
|
||||
replyToId:
|
||||
resolveBlueBubblesReplyToMessageGuid({
|
||||
runtime,
|
||||
to: params.to,
|
||||
replyToId: params.replyToId,
|
||||
}) ?? null,
|
||||
accountId: params.accountId,
|
||||
asVoice: params.audioAsVoice === true,
|
||||
});
|
||||
}
|
||||
|
||||
function toBlueBubblesMessageSendResult(
|
||||
result: { messageId?: string; receipt?: ChannelMessageSendResult["receipt"] },
|
||||
kind: MessageReceiptPartKind,
|
||||
replyToId?: string | null,
|
||||
): ChannelMessageSendResult {
|
||||
const receipt =
|
||||
result.receipt ??
|
||||
createMessageReceiptFromOutboundResults({
|
||||
results: result.messageId ? [{ channel: "bluebubbles", messageId: result.messageId }] : [],
|
||||
kind,
|
||||
...(replyToId ? { replyToId } : {}),
|
||||
});
|
||||
return {
|
||||
messageId: result.messageId || receipt.primaryPlatformMessageId,
|
||||
receipt,
|
||||
};
|
||||
}
|
||||
|
||||
const bluebubblesMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "bluebubbles",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
replyTo: true,
|
||||
messageSendingHooks: true,
|
||||
afterSendSuccess: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
lifecycle: {
|
||||
beforeSendAttempt: async (ctx) =>
|
||||
await resolveBlueBubblesMessageLifecycleDeps(ctx)?.beforeSendAttempt?.(ctx),
|
||||
afterSendSuccess: async (ctx) => {
|
||||
await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendSuccess?.(ctx);
|
||||
},
|
||||
afterSendFailure: async (ctx) => {
|
||||
await resolveBlueBubblesMessageLifecycleDeps(ctx)?.afterSendFailure?.(ctx);
|
||||
},
|
||||
},
|
||||
text: async (ctx) =>
|
||||
toBlueBubblesMessageSendResult(
|
||||
await sendBlueBubblesTextWithRuntime({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: ctx.text,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToId: ctx.replyToId,
|
||||
}),
|
||||
"text",
|
||||
ctx.replyToId,
|
||||
),
|
||||
media: async (ctx) =>
|
||||
toBlueBubblesMessageSendResult(
|
||||
await sendBlueBubblesMediaWithRuntime({
|
||||
cfg: ctx.cfg,
|
||||
to: ctx.to,
|
||||
text: ctx.text,
|
||||
mediaUrl: ctx.mediaUrl,
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
replyToId: ctx.replyToId,
|
||||
audioAsVoice: ctx.audioAsVoice,
|
||||
}),
|
||||
"media",
|
||||
ctx.replyToId,
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
const resolveBlueBubblesDmPolicy = createScopedDmSecurityResolver<ResolvedBlueBubblesAccount>({
|
||||
channelKey: "bluebubbles",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
@@ -454,7 +281,6 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
|
||||
}
|
||||
},
|
||||
},
|
||||
message: bluebubblesMessageAdapter,
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveBlueBubblesDmPolicy,
|
||||
@@ -492,19 +318,24 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
|
||||
},
|
||||
attachedResults: {
|
||||
channel: "bluebubbles",
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) =>
|
||||
await sendBlueBubblesTextWithRuntime({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const rawReplyToId = normalizeOptionalString(replyToId) ?? "";
|
||||
const replyToMessageGuid = rawReplyToId
|
||||
? runtime.resolveBlueBubblesMessageId(rawReplyToId, {
|
||||
requireKnownShortId: true,
|
||||
chatContext: buildBlueBubblesChatContextFromTarget(to),
|
||||
})
|
||||
: "";
|
||||
return await runtime.sendMessageBlueBubbles(to, text, {
|
||||
cfg: cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId,
|
||||
}),
|
||||
replyToMessageGuid: replyToMessageGuid || undefined,
|
||||
});
|
||||
},
|
||||
sendMedia: async (ctx) => {
|
||||
const runtime = await loadBlueBubblesChannelRuntime();
|
||||
const { cfg, to, text, mediaUrl, accountId, replyToId, audioAsVoice } = ctx;
|
||||
if (!mediaUrl) {
|
||||
throw new Error("BlueBubbles media send requires mediaUrl");
|
||||
}
|
||||
const { mediaPath, mediaBuffer, contentType, filename, caption } = ctx as {
|
||||
mediaPath?: string;
|
||||
mediaBuffer?: Uint8Array;
|
||||
@@ -512,15 +343,18 @@ export const bluebubblesPlugin: ChannelPlugin<ResolvedBlueBubblesAccount, BlueBu
|
||||
filename?: string;
|
||||
caption?: string;
|
||||
};
|
||||
return await sendBlueBubblesMediaWithRuntime({
|
||||
cfg,
|
||||
return await runtime.sendBlueBubblesMedia({
|
||||
cfg: cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaPath,
|
||||
mediaBuffer,
|
||||
contentType,
|
||||
filename,
|
||||
caption: caption ?? text ?? undefined,
|
||||
replyToId: replyToId ?? null,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId,
|
||||
audioAsVoice,
|
||||
extras: { mediaPath, mediaBuffer, contentType, filename, caption },
|
||||
asVoice: audioAsVoice === true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -3,7 +3,7 @@ export { logAckFailure, logTypingFailure } from "openclaw/plugin-sdk/channel-fee
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { deriveDurableFinalDeliveryRequirements } from "openclaw/plugin-sdk/channel-message";
|
||||
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
resolveOutboundMediaUrls,
|
||||
resolveTextChunksWithFallback,
|
||||
sendMediaWithLeadingCaption,
|
||||
type ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
@@ -39,7 +38,7 @@ import {
|
||||
import {
|
||||
DM_GROUP_ACCESS_REASON,
|
||||
createChannelPairingController,
|
||||
deriveDurableFinalDeliveryRequirements,
|
||||
createChannelReplyPipeline,
|
||||
evictOldHistoryKeys,
|
||||
evaluateSupplementalContextVisibility,
|
||||
logAckFailure,
|
||||
@@ -1542,45 +1541,6 @@ async function processMessageAfterDedupe(
|
||||
.replace(/[ \t]+/g, " ")
|
||||
.trim();
|
||||
};
|
||||
const resolveReplyToMessageGuidForPayload = (payload: { replyToId?: string | null }): string => {
|
||||
const rawReplyToId =
|
||||
privateApiEnabled && typeof payload.replyToId === "string" ? payload.replyToId.trim() : "";
|
||||
if (!rawReplyToId) {
|
||||
return "";
|
||||
}
|
||||
return (
|
||||
resolveBlueBubblesMessageId(rawReplyToId, {
|
||||
requireKnownShortId: true,
|
||||
chatContext: {
|
||||
chatGuid: chatGuidForActions ?? chatGuid,
|
||||
chatIdentifier,
|
||||
chatId,
|
||||
},
|
||||
}) || ""
|
||||
);
|
||||
};
|
||||
const prepareBlueBubblesReplyPayload = (payload: ReplyPayload): ReplyPayload => {
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg: config,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const text = sanitizeReplyDirectiveText(
|
||||
core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode),
|
||||
);
|
||||
return {
|
||||
...payload,
|
||||
text,
|
||||
...(typeof payload.replyToId === "string" && !privateApiEnabled ? { replyToId: "" } : {}),
|
||||
};
|
||||
};
|
||||
const canUseDurableBlueBubblesFinalDelivery = (payload: { text?: string }): boolean => {
|
||||
const textLimit =
|
||||
account.config.textChunkLimit && account.config.textChunkLimit > 0
|
||||
? account.config.textChunkLimit
|
||||
: DEFAULT_TEXT_LIMIT;
|
||||
return (payload.text ?? "").length <= textLimit;
|
||||
};
|
||||
|
||||
// History: in-memory rolling map with bounded API backfill retries
|
||||
const historyLimit = isGroup
|
||||
@@ -1768,36 +1728,42 @@ async function processMessageAfterDedupe(
|
||||
}, typingRestartDelayMs);
|
||||
};
|
||||
try {
|
||||
const typingCallbacks = {
|
||||
onReplyStart: async () => {
|
||||
if (!chatGuidForActions) {
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
streamingActive = true;
|
||||
clearTypingRestartTimer();
|
||||
try {
|
||||
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`[bluebubbles] typing start failed: ${sanitizeForLog(err)}`);
|
||||
}
|
||||
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: config,
|
||||
agentId: route.agentId,
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
typingCallbacks: {
|
||||
onReplyStart: async () => {
|
||||
if (!chatGuidForActions) {
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
streamingActive = true;
|
||||
clearTypingRestartTimer();
|
||||
try {
|
||||
await sendBlueBubblesTyping(chatGuidForActions, true, {
|
||||
cfg: config,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`[bluebubbles] typing start failed: ${sanitizeForLog(err)}`);
|
||||
}
|
||||
},
|
||||
onIdle: () => {
|
||||
if (!chatGuidForActions) {
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
// Intentionally no-op for block streaming. We stop typing in finally
|
||||
// after the run completes to avoid flicker between paragraph blocks.
|
||||
},
|
||||
},
|
||||
onIdle: () => {
|
||||
if (!chatGuidForActions) {
|
||||
return;
|
||||
}
|
||||
if (!baseUrl || !password) {
|
||||
return;
|
||||
}
|
||||
// Intentionally no-op for block streaming. We stop typing in finally
|
||||
// after the run completes to avoid flicker between paragraph blocks.
|
||||
},
|
||||
};
|
||||
});
|
||||
await core.channel.turn.run({
|
||||
channel: "bluebubbles",
|
||||
accountId: account.accountId,
|
||||
@@ -1823,76 +1789,6 @@ async function processMessageAfterDedupe(
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
preparePayload: (payload) => prepareBlueBubblesReplyPayload(payload),
|
||||
durable: (payload, info) => {
|
||||
if (info.kind !== "final" || !canUseDurableBlueBubblesFinalDelivery(payload)) {
|
||||
return false;
|
||||
}
|
||||
const replyToMessageGuid = resolveReplyToMessageGuidForPayload(payload);
|
||||
return {
|
||||
to: outboundTarget,
|
||||
replyToId:
|
||||
typeof payload.replyToId === "string" ? payload.replyToId.trim() || null : null,
|
||||
deps: {
|
||||
bluebubblesMessageLifecycle: {
|
||||
beforeSendAttempt: (ctx: { kind: string; text?: string }) => {
|
||||
const snippet =
|
||||
ctx.kind === "media"
|
||||
? (ctx.text ?? "").trim() || "<media:attachment>"
|
||||
: (ctx.text ?? "").trim();
|
||||
return rememberPendingOutboundMessageId({
|
||||
accountId: account.accountId,
|
||||
sessionKey: route.sessionKey,
|
||||
outboundTarget,
|
||||
chatGuid: chatGuidForActions ?? chatGuid,
|
||||
chatIdentifier,
|
||||
chatId,
|
||||
snippet,
|
||||
});
|
||||
},
|
||||
afterSendSuccess: (ctx: {
|
||||
kind: string;
|
||||
text?: string;
|
||||
result?: { messageId?: string };
|
||||
attemptToken?: unknown;
|
||||
}) => {
|
||||
const snippet =
|
||||
ctx.kind === "media"
|
||||
? (ctx.text ?? "").trim() || "<media:attachment>"
|
||||
: (ctx.text ?? "").trim();
|
||||
if (
|
||||
maybeEnqueueOutboundMessageId(ctx.result?.messageId, snippet) &&
|
||||
typeof ctx.attemptToken === "number"
|
||||
) {
|
||||
forgetPendingOutboundMessageId(ctx.attemptToken);
|
||||
}
|
||||
},
|
||||
afterSendFailure: (ctx: { attemptToken?: unknown }) => {
|
||||
if (typeof ctx.attemptToken === "number") {
|
||||
forgetPendingOutboundMessageId(ctx.attemptToken);
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
requiredCapabilities: deriveDurableFinalDeliveryRequirements({
|
||||
payload,
|
||||
replyToId: replyToMessageGuid || null,
|
||||
afterSendSuccess: true,
|
||||
}),
|
||||
};
|
||||
},
|
||||
onDelivered: (_payload, info, result) => {
|
||||
if (!result?.deliveryIntent) {
|
||||
return;
|
||||
}
|
||||
if (result.visibleReplySent === true) {
|
||||
sentMessage = true;
|
||||
statusSink?.({ lastOutboundAt: Date.now() });
|
||||
if (info.kind === "block") {
|
||||
restartTypingSoon();
|
||||
}
|
||||
}
|
||||
},
|
||||
deliver: async (payload, info) => {
|
||||
const rawReplyToId =
|
||||
privateApiEnabled && typeof payload.replyToId === "string"
|
||||
@@ -2036,14 +1932,13 @@ async function processMessageAfterDedupe(
|
||||
runtime.error?.(`BlueBubbles ${info.kind} reply failed: ${sanitizeForLog(err)}`);
|
||||
},
|
||||
},
|
||||
replyPipeline: {
|
||||
typingCallbacks,
|
||||
},
|
||||
dispatcherOptions: {
|
||||
onReplyStart: typingCallbacks.onReplyStart,
|
||||
onIdle: typingCallbacks.onIdle,
|
||||
...replyPipeline,
|
||||
onReplyStart: typingCallbacks?.onReplyStart,
|
||||
onIdle: typingCallbacks?.onIdle,
|
||||
},
|
||||
replyOptions: {
|
||||
onModelSelected,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
|
||||
@@ -34,16 +34,7 @@ import { _setFetchGuardForTesting } from "./types.js";
|
||||
// Mock dependencies
|
||||
vi.mock("./send.js", () => ({
|
||||
resolveChatGuidForTarget: vi.fn().mockResolvedValue("iMessage;-;+15551234567"),
|
||||
sendMessageBlueBubbles: vi.fn().mockResolvedValue({
|
||||
messageId: "msg-123",
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "msg-123",
|
||||
platformMessageIds: ["msg-123"],
|
||||
parts: [],
|
||||
sentAt: 0,
|
||||
raw: [],
|
||||
},
|
||||
}),
|
||||
sendMessageBlueBubbles: vi.fn().mockResolvedValue({ messageId: "msg-123" }),
|
||||
}));
|
||||
|
||||
vi.mock("./chat.js", () => ({
|
||||
@@ -87,20 +78,6 @@ const DEFAULT_RESOLVED_AGENT_ROUTE: ReturnType<
|
||||
matchedBy: "default",
|
||||
};
|
||||
const mockResolveAgentRoute = vi.fn(() => DEFAULT_RESOLVED_AGENT_ROUTE);
|
||||
|
||||
function blueBubblesTestSendResult(messageId: string) {
|
||||
const hasPlatformId = messageId && messageId !== "ok" && messageId !== "unknown";
|
||||
return {
|
||||
messageId,
|
||||
receipt: {
|
||||
...(hasPlatformId ? { primaryPlatformMessageId: messageId } : {}),
|
||||
platformMessageIds: hasPlatformId ? [messageId] : [],
|
||||
parts: [],
|
||||
sentAt: 0,
|
||||
raw: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
const mockBuildMentionRegexes = vi.fn(() => [/\bbert\b/i]);
|
||||
const mockMatchesMentionPatterns = vi.fn((text: string, regexes: RegExp[]) =>
|
||||
regexes.some((r) => r.test(text)),
|
||||
@@ -2066,7 +2043,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
mockEnqueueSystemEvent.mockClear();
|
||||
|
||||
const { sendMessageBlueBubbles } = await import("./send.js");
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
@@ -2106,7 +2083,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
mockEnqueueSystemEvent.mockClear();
|
||||
|
||||
const { sendMessageBlueBubbles } = await import("./send.js");
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
@@ -2566,9 +2543,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
setupWebhookTarget();
|
||||
|
||||
const { sendMessageBlueBubbles } = await import("./send.js");
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(
|
||||
blueBubblesTestSendResult("msg-self-1"),
|
||||
);
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "msg-self-1" });
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "replying now" }, { kind: "final" });
|
||||
@@ -2718,7 +2693,7 @@ describe("BlueBubbles webhook monitor", () => {
|
||||
setupWebhookTarget();
|
||||
|
||||
const { sendMessageBlueBubbles } = await import("./send.js");
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce(blueBubblesTestSendResult("ok"));
|
||||
vi.mocked(sendMessageBlueBubbles).mockResolvedValueOnce({ messageId: "ok" });
|
||||
|
||||
mockDispatchReplyWithBufferedBlockDispatcher.mockImplementationOnce(async (params) => {
|
||||
await params.dispatcherOptions.deliver({ text: "same text" }, { kind: "final" });
|
||||
|
||||
@@ -39,7 +39,7 @@ export {
|
||||
export { readBooleanParam } from "openclaw/plugin-sdk/boolean-param";
|
||||
export { mapAllowFromEntries } from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export { resolveRequestUrl } from "openclaw/plugin-sdk/request-url";
|
||||
export { buildProbeChannelStatusSummary } from "openclaw/plugin-sdk/channel-status";
|
||||
export { stripMarkdown } from "openclaw/plugin-sdk/text-runtime";
|
||||
|
||||
@@ -717,21 +717,6 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("msg-uuid-123");
|
||||
expect(result.receipt).toMatchObject({
|
||||
primaryPlatformMessageId: "msg-uuid-123",
|
||||
platformMessageIds: ["msg-uuid-123"],
|
||||
parts: [
|
||||
{
|
||||
platformMessageId: "msg-uuid-123",
|
||||
kind: "text",
|
||||
raw: {
|
||||
channel: "bluebubbles",
|
||||
conversationId: "iMessage;-;+15551234567",
|
||||
messageId: "msg-uuid-123",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const sendCall = mockFetch.mock.calls[1];
|
||||
@@ -827,16 +812,6 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("new-msg-guid");
|
||||
expect(result.receipt).toMatchObject({
|
||||
primaryPlatformMessageId: "new-msg-guid",
|
||||
platformMessageIds: ["new-msg-guid"],
|
||||
parts: [
|
||||
{
|
||||
platformMessageId: "new-msg-guid",
|
||||
kind: "text",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const createCall = mockFetch.mock.calls[1];
|
||||
@@ -882,18 +857,6 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("msg-uuid-124");
|
||||
expect(result.receipt).toMatchObject({
|
||||
primaryPlatformMessageId: "msg-uuid-124",
|
||||
platformMessageIds: ["msg-uuid-124"],
|
||||
replyToId: "reply-guid-123",
|
||||
parts: [
|
||||
{
|
||||
platformMessageId: "msg-uuid-124",
|
||||
kind: "text",
|
||||
replyToId: "reply-guid-123",
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
|
||||
const sendCall = mockFetch.mock.calls[1];
|
||||
@@ -1090,8 +1053,6 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("ok");
|
||||
expect(result.receipt.platformMessageIds).toEqual([]);
|
||||
expect(result.receipt.parts).toEqual([]);
|
||||
});
|
||||
|
||||
it("handles invalid JSON response body", async () => {
|
||||
@@ -1107,8 +1068,6 @@ describe("send", () => {
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("ok");
|
||||
expect(result.receipt.platformMessageIds).toEqual([]);
|
||||
expect(result.receipt.parts).toEqual([]);
|
||||
});
|
||||
|
||||
it("extracts messageId from various response formats", async () => {
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
type MessageReceiptSourceResult,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
@@ -40,7 +35,6 @@ export type BlueBubblesSendOpts = {
|
||||
|
||||
export type BlueBubblesSendResult = {
|
||||
messageId: string;
|
||||
receipt: MessageReceipt;
|
||||
};
|
||||
|
||||
/** Maps short effect names to full Apple effect IDs */
|
||||
@@ -124,61 +118,17 @@ function resolvePrivateApiDecision(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function createBlueBubblesSendReceipt(params: {
|
||||
messageId: string;
|
||||
chatGuid?: string | null;
|
||||
replyToMessageGuid?: string;
|
||||
}): MessageReceipt {
|
||||
const messageId = params.messageId.trim();
|
||||
const results: MessageReceiptSourceResult[] =
|
||||
messageId && messageId !== "unknown" && messageId !== "ok"
|
||||
? [
|
||||
{
|
||||
channel: "bluebubbles",
|
||||
messageId,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
if (results[0] && params.chatGuid) {
|
||||
results[0].conversationId = params.chatGuid;
|
||||
}
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results,
|
||||
kind: "text",
|
||||
...(params.replyToMessageGuid ? { replyToId: params.replyToMessageGuid } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
async function parseBlueBubblesMessageResponse(
|
||||
res: Response,
|
||||
params: { chatGuid?: string | null; replyToMessageGuid?: string } = {},
|
||||
): Promise<BlueBubblesSendResult> {
|
||||
async function parseBlueBubblesMessageResponse(res: Response): Promise<BlueBubblesSendResult> {
|
||||
const body = await res.text();
|
||||
let messageId = "ok";
|
||||
if (!body) {
|
||||
return {
|
||||
messageId,
|
||||
receipt: createBlueBubblesSendReceipt({
|
||||
messageId,
|
||||
...(params.chatGuid ? { chatGuid: params.chatGuid } : {}),
|
||||
...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}),
|
||||
}),
|
||||
};
|
||||
return { messageId: "ok" };
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(body) as unknown;
|
||||
messageId = extractBlueBubblesMessageId(parsed);
|
||||
return { messageId: extractBlueBubblesMessageId(parsed) };
|
||||
} catch {
|
||||
messageId = "ok";
|
||||
return { messageId: "ok" };
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
receipt: createBlueBubblesSendReceipt({
|
||||
messageId,
|
||||
...(params.chatGuid ? { chatGuid: params.chatGuid } : {}),
|
||||
...(params.replyToMessageGuid ? { replyToMessageGuid: params.replyToMessageGuid } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
type BlueBubblesChatRecord = Record<string, unknown>;
|
||||
@@ -529,13 +479,7 @@ async function createNewChatWithMessage(params: {
|
||||
timeoutMs: params.timeoutMs,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
return {
|
||||
messageId: result.messageId,
|
||||
receipt: createBlueBubblesSendReceipt({
|
||||
messageId: result.messageId,
|
||||
chatGuid: result.chatGuid,
|
||||
}),
|
||||
};
|
||||
return { messageId: result.messageId };
|
||||
}
|
||||
|
||||
export async function sendMessageBlueBubbles(
|
||||
@@ -670,10 +614,5 @@ export async function sendMessageBlueBubbles(
|
||||
const errorText = await res.text();
|
||||
throw new Error(`BlueBubbles send failed (${res.status}): ${errorText || "unknown"}`);
|
||||
}
|
||||
return parseBlueBubblesMessageResponse(res, {
|
||||
chatGuid,
|
||||
...(wantsReplyThread && opts.replyToMessageGuid
|
||||
? { replyToMessageGuid: opts.replyToMessageGuid }
|
||||
: {}),
|
||||
});
|
||||
return parseBlueBubblesMessageResponse(res);
|
||||
}
|
||||
|
||||
@@ -369,7 +369,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
__testing.resetCodexAppServerClientFactoryForTests();
|
||||
__testing.resetOpenClawCodingToolsFactoryForTests();
|
||||
resetCodexRateLimitCacheForTests();
|
||||
nativeHookRelayTesting.clearNativeHookRelaysForTests();
|
||||
resetAgentEventsForTest();
|
||||
@@ -476,18 +475,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
params.config = { tools: { profile: "coding" } };
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
params.messageProvider = "whatsapp";
|
||||
let seenForceMessageTool: boolean | undefined;
|
||||
__testing.setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
seenForceMessageTool = options?.forceMessageTool;
|
||||
return [
|
||||
{
|
||||
name: "message",
|
||||
description: "message test tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(),
|
||||
},
|
||||
] as never;
|
||||
});
|
||||
|
||||
const dynamicTools = await __testing.buildDynamicTools({
|
||||
params,
|
||||
@@ -502,7 +489,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
const dynamicToolNames = dynamicTools.map((tool) => tool.name);
|
||||
|
||||
expect(seenForceMessageTool).toBe(true);
|
||||
expect(dynamicToolNames).toContain("message");
|
||||
});
|
||||
|
||||
@@ -531,18 +517,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
},
|
||||
}),
|
||||
);
|
||||
let seenRunSessionKey: string | undefined;
|
||||
__testing.setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
seenRunSessionKey = options?.runSessionKey;
|
||||
return [
|
||||
{
|
||||
name: "session_status",
|
||||
description: "session status test tool",
|
||||
parameters: { type: "object", properties: {} },
|
||||
execute: vi.fn(async () => ({ details: { sessionKey: options?.runSessionKey } })),
|
||||
},
|
||||
] as never;
|
||||
});
|
||||
|
||||
const dynamicTools = await __testing.buildDynamicTools({
|
||||
params,
|
||||
@@ -559,7 +533,6 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
expect(sessionStatus).toBeDefined();
|
||||
const result = await sessionStatus?.execute("call-current", { sessionKey: "current" });
|
||||
expect(seenRunSessionKey).toBe("agent:main:main");
|
||||
expect((result?.details as { sessionKey?: string } | undefined)?.sessionKey).toBe(
|
||||
"agent:main:main",
|
||||
);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AsyncLocalStorage } from "node:async_hooks";
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
@@ -47,8 +46,8 @@ import {
|
||||
resolveCodexAppServerAuthProfileIdForAgent,
|
||||
} from "./auth-bridge.js";
|
||||
import {
|
||||
createCodexAppServerClientFactoryTestHooks,
|
||||
defaultCodexAppServerClientFactory,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./client-factory.js";
|
||||
import {
|
||||
isCodexAppServerApprovalRequest,
|
||||
@@ -127,16 +126,8 @@ const CODEX_BOOTSTRAP_CONTEXT_ORDER = new Map<string, number>([
|
||||
type OpenClawCodingToolsOptions = NonNullable<
|
||||
Parameters<(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"]>[0]
|
||||
>;
|
||||
type OpenClawCodingToolsFactory =
|
||||
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
|
||||
|
||||
const testClientFactoryStorage = new AsyncLocalStorage<CodexAppServerClientFactory | undefined>();
|
||||
const clientFactory = defaultCodexAppServerClientFactory;
|
||||
let openClawCodingToolsFactoryForTests: OpenClawCodingToolsFactory | undefined;
|
||||
|
||||
function resolveCodexAppServerClientFactory(): CodexAppServerClientFactory {
|
||||
return testClientFactoryStorage.getStore() ?? clientFactory;
|
||||
}
|
||||
let clientFactory = defaultCodexAppServerClientFactory;
|
||||
|
||||
function emitCodexAppServerEvent(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
@@ -360,7 +351,7 @@ export async function runCodexAppServerAttempt(
|
||||
} = {},
|
||||
): Promise<EmbeddedRunAttemptResult> {
|
||||
const attemptStartedAt = Date.now();
|
||||
const attemptClientFactory = resolveCodexAppServerClientFactory();
|
||||
const attemptClientFactory = clientFactory;
|
||||
const pluginConfig = readCodexPluginConfig(options.pluginConfig);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
|
||||
@@ -1487,9 +1478,7 @@ async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
}
|
||||
const modelHasVision = params.model.input?.includes("image") ?? false;
|
||||
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
|
||||
const createOpenClawCodingTools =
|
||||
openClawCodingToolsFactoryForTests ??
|
||||
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
|
||||
const { createOpenClawCodingTools } = await import("openclaw/plugin-sdk/agent-harness");
|
||||
const allTools = createOpenClawCodingTools({
|
||||
agentId: input.sessionAgentId,
|
||||
...buildEmbeddedAttemptToolRunContext(params),
|
||||
@@ -1974,16 +1963,7 @@ export const __testing = {
|
||||
buildDynamicTools,
|
||||
filterToolsForVisionInputs,
|
||||
handleDynamicToolCallWithTimeout,
|
||||
setOpenClawCodingToolsFactoryForTests(factory: OpenClawCodingToolsFactory): void {
|
||||
openClawCodingToolsFactoryForTests = factory;
|
||||
},
|
||||
resetOpenClawCodingToolsFactoryForTests(): void {
|
||||
openClawCodingToolsFactoryForTests = undefined;
|
||||
},
|
||||
setCodexAppServerClientFactoryForTests(factory: CodexAppServerClientFactory): void {
|
||||
testClientFactoryStorage.enterWith(factory);
|
||||
},
|
||||
resetCodexAppServerClientFactoryForTests(): void {
|
||||
testClientFactoryStorage.enterWith(undefined);
|
||||
},
|
||||
...createCodexAppServerClientFactoryTestHooks((factory) => {
|
||||
clientFactory = factory;
|
||||
}),
|
||||
} as const;
|
||||
|
||||
@@ -91,10 +91,6 @@ type TelemetryExporterDiagnosticEvent = Extract<
|
||||
DiagnosticEventPayload,
|
||||
{ type: "telemetry.exporter" }
|
||||
>;
|
||||
type SessionRecoveryDiagnosticEvent = Extract<
|
||||
DiagnosticEventPayload,
|
||||
{ type: "session.recovery.requested" | "session.recovery.completed" }
|
||||
>;
|
||||
|
||||
const NO_CONTENT_CAPTURE: OtelContentCapturePolicy = {
|
||||
inputMessages: false,
|
||||
@@ -823,27 +819,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
unit: "ms",
|
||||
description: "Age of stuck sessions",
|
||||
});
|
||||
const sessionRecoveryRequestedCounter = meter.createCounter(
|
||||
"openclaw.session.recovery.requested",
|
||||
{
|
||||
unit: "1",
|
||||
description: "Session recovery attempts requested",
|
||||
},
|
||||
);
|
||||
const sessionRecoveryCompletedCounter = meter.createCounter(
|
||||
"openclaw.session.recovery.completed",
|
||||
{
|
||||
unit: "1",
|
||||
description: "Session recovery attempts completed",
|
||||
},
|
||||
);
|
||||
const sessionRecoveryAgeHistogram = meter.createHistogram(
|
||||
"openclaw.session.recovery.age_ms",
|
||||
{
|
||||
unit: "ms",
|
||||
description: "Age of sessions selected for recovery",
|
||||
},
|
||||
);
|
||||
const runAttemptCounter = meter.createCounter("openclaw.run.attempt", {
|
||||
unit: "1",
|
||||
description: "Run attempts",
|
||||
@@ -1493,39 +1468,6 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
span.end();
|
||||
};
|
||||
|
||||
const sessionRecoveryAttrs = (evt: SessionRecoveryDiagnosticEvent) => {
|
||||
const attrs: Record<string, string> = { "openclaw.state": evt.state };
|
||||
if (evt.reason) {
|
||||
attrs["openclaw.reason"] = redactSensitiveText(evt.reason);
|
||||
}
|
||||
if (evt.activeWorkKind) {
|
||||
attrs["openclaw.active_work_kind"] = evt.activeWorkKind;
|
||||
}
|
||||
return attrs;
|
||||
};
|
||||
|
||||
const recordSessionRecoveryRequested = (
|
||||
evt: Extract<DiagnosticEventPayload, { type: "session.recovery.requested" }>,
|
||||
) => {
|
||||
const attrs = sessionRecoveryAttrs(evt);
|
||||
attrs["openclaw.action"] = evt.allowActiveAbort ? "abort" : "recover";
|
||||
sessionRecoveryRequestedCounter.add(1, attrs);
|
||||
sessionRecoveryAgeHistogram.record(evt.ageMs, attrs);
|
||||
};
|
||||
|
||||
const recordSessionRecoveryCompleted = (
|
||||
evt: Extract<DiagnosticEventPayload, { type: "session.recovery.completed" }>,
|
||||
) => {
|
||||
const attrs = sessionRecoveryAttrs(evt);
|
||||
attrs["openclaw.status"] = evt.status;
|
||||
attrs["openclaw.action"] = lowCardinalityAttr(evt.action, "unknown");
|
||||
if (evt.outcomeReason) {
|
||||
attrs["openclaw.reason"] = redactSensitiveText(evt.outcomeReason);
|
||||
}
|
||||
sessionRecoveryCompletedCounter.add(1, attrs);
|
||||
sessionRecoveryAgeHistogram.record(evt.ageMs, attrs);
|
||||
};
|
||||
|
||||
const recordRunAttempt = (evt: Extract<DiagnosticEventPayload, { type: "run.attempt" }>) => {
|
||||
runAttemptCounter.add(1, { "openclaw.attempt": evt.attempt });
|
||||
};
|
||||
@@ -2294,16 +2236,12 @@ export function createDiagnosticsOtelService(): OpenClawPluginService {
|
||||
return;
|
||||
case "session.long_running":
|
||||
case "session.stalled":
|
||||
case "session.recovery.completed":
|
||||
case "session.recovery.requested":
|
||||
return;
|
||||
case "session.stuck":
|
||||
recordSessionStuck(evt);
|
||||
return;
|
||||
case "session.recovery.requested":
|
||||
recordSessionRecoveryRequested(evt);
|
||||
return;
|
||||
case "session.recovery.completed":
|
||||
recordSessionRecoveryCompleted(evt);
|
||||
return;
|
||||
case "run.attempt":
|
||||
recordRunAttempt(evt);
|
||||
return;
|
||||
|
||||
@@ -19,13 +19,7 @@ import {
|
||||
|
||||
type Ctx = Pick<
|
||||
ChannelMessageActionContext,
|
||||
| "action"
|
||||
| "params"
|
||||
| "cfg"
|
||||
| "accountId"
|
||||
| "requesterSenderId"
|
||||
| "mediaLocalRoots"
|
||||
| "mediaReadFile"
|
||||
"action" | "params" | "cfg" | "accountId" | "requesterSenderId" | "mediaLocalRoots"
|
||||
>;
|
||||
|
||||
export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
@@ -371,10 +365,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
const content = readStringParam(actionParams, "message", {
|
||||
required: true,
|
||||
});
|
||||
const mediaUrl =
|
||||
readStringParam(actionParams, "media", { trim: false }) ??
|
||||
readStringParam(actionParams, "path", { trim: false }) ??
|
||||
readStringParam(actionParams, "filePath", { trim: false });
|
||||
const mediaUrl = readStringParam(actionParams, "media", { trim: false });
|
||||
const replyTo = readStringParam(actionParams, "replyTo");
|
||||
|
||||
// `message.thread-reply` (tool) uses `threadId`, while the CLI historically used `to`/`channelId`.
|
||||
@@ -392,7 +383,6 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
replyTo: replyTo ?? undefined,
|
||||
},
|
||||
cfg,
|
||||
{ mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -216,38 +216,6 @@ describe("handleDiscordMessageAction", () => {
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("maps thread-reply filePath to Discord threadReply with media read context", async () => {
|
||||
const mediaReadFile = vi.fn(async () => Buffer.from("report"));
|
||||
|
||||
await handleDiscordMessageAction({
|
||||
action: "thread-reply",
|
||||
params: {
|
||||
threadId: "thread-123",
|
||||
message: "thread update",
|
||||
filePath: "/tmp/agent-root/report.md",
|
||||
},
|
||||
cfg: {
|
||||
channels: { discord: { token: "tok", actions: { threads: true } } },
|
||||
} as OpenClawConfig,
|
||||
mediaLocalRoots: ["/tmp/agent-root"],
|
||||
mediaReadFile,
|
||||
});
|
||||
|
||||
expect(handleDiscordActionMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: "threadReply",
|
||||
channelId: "thread-123",
|
||||
content: "thread update",
|
||||
mediaUrl: "/tmp/agent-root/report.md",
|
||||
}),
|
||||
expect.any(Object),
|
||||
{
|
||||
mediaLocalRoots: ["/tmp/agent-root"],
|
||||
mediaReadFile,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("forwards top-level components on sends", async () => {
|
||||
const components = { blocks: [{ type: "text", text: "Pick one" }] };
|
||||
|
||||
|
||||
@@ -229,59 +229,6 @@ describe("discordMessageActions", () => {
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("prepares Discord send payload channel data for durable core delivery", async () => {
|
||||
const prepared = await discordMessageActions.prepareSendPayload?.({
|
||||
ctx: {
|
||||
channel: "discord",
|
||||
action: "send",
|
||||
cfg: {} as OpenClawConfig,
|
||||
params: {
|
||||
components: {
|
||||
text: "Choose",
|
||||
blocks: [
|
||||
{
|
||||
type: "actions",
|
||||
buttons: [{ label: "Yes", callbackData: "yes" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
embeds: undefined,
|
||||
filename: "photo.png",
|
||||
},
|
||||
},
|
||||
to: "channel:123",
|
||||
payload: { text: "hello", mediaUrl: "/tmp/photo.png" },
|
||||
});
|
||||
|
||||
expect(prepared).toMatchObject({
|
||||
text: "hello",
|
||||
mediaUrl: "/tmp/photo.png",
|
||||
channelData: {
|
||||
discord: {
|
||||
components: expect.objectContaining({ text: "Choose" }),
|
||||
filename: "photo.png",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps non-serializable Discord component sends on the legacy action path", async () => {
|
||||
const prepared = await discordMessageActions.prepareSendPayload?.({
|
||||
ctx: {
|
||||
channel: "discord",
|
||||
action: "send",
|
||||
cfg: {} as OpenClawConfig,
|
||||
params: {
|
||||
components: () => [],
|
||||
},
|
||||
},
|
||||
to: "channel:123",
|
||||
payload: { text: "hello" },
|
||||
});
|
||||
|
||||
expect(prepared).toBeNull();
|
||||
});
|
||||
|
||||
it("delegates action handling to the Discord action handler", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
|
||||
@@ -9,7 +9,6 @@ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { extractToolSend } from "openclaw/plugin-sdk/tool-send";
|
||||
import { inspectDiscordAccount } from "./account-inspect.js";
|
||||
import { createDiscordActionGate, listDiscordAccountIds } from "./accounts.js";
|
||||
import { readDiscordComponentSpec } from "./components.js";
|
||||
|
||||
let discordChannelActionsRuntimePromise:
|
||||
| Promise<typeof import("./channel-actions.runtime.js")>
|
||||
@@ -176,47 +175,6 @@ export const discordMessageActions: ChannelMessageActionAdapter = {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
prepareSendPayload: ({ ctx, payload }) => {
|
||||
if (ctx.action !== "send") {
|
||||
return null;
|
||||
}
|
||||
const rawComponents = ctx.params.components;
|
||||
if (typeof rawComponents === "function") {
|
||||
return null;
|
||||
}
|
||||
const componentSpec =
|
||||
rawComponents && typeof rawComponents === "object" && !Array.isArray(rawComponents)
|
||||
? readDiscordComponentSpec(rawComponents)
|
||||
: undefined;
|
||||
const nativeComponents = Array.isArray(rawComponents) ? rawComponents : undefined;
|
||||
const embeds = Array.isArray(ctx.params.embeds) ? ctx.params.embeds : undefined;
|
||||
if ((componentSpec || nativeComponents) && embeds?.length) {
|
||||
return null;
|
||||
}
|
||||
const filename = normalizeOptionalString(ctx.params.filename);
|
||||
if (!componentSpec && !nativeComponents && !embeds?.length && !filename) {
|
||||
return payload;
|
||||
}
|
||||
const discordData =
|
||||
payload.channelData?.discord &&
|
||||
typeof payload.channelData.discord === "object" &&
|
||||
!Array.isArray(payload.channelData.discord)
|
||||
? (payload.channelData.discord as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
...payload,
|
||||
channelData: {
|
||||
...payload.channelData,
|
||||
discord: {
|
||||
...discordData,
|
||||
...(componentSpec ? { components: componentSpec } : {}),
|
||||
...(nativeComponents ? { components: nativeComponents } : {}),
|
||||
...(embeds?.length ? { embeds } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
handleAction: async ({
|
||||
action,
|
||||
params,
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
import {
|
||||
verifyChannelMessageAdapterCapabilityProofs,
|
||||
verifyChannelMessageLiveCapabilityAdapterProofs,
|
||||
verifyChannelMessageLiveFinalizerProofs,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createDiscordOutboundHoisted,
|
||||
installDiscordOutboundModuleSpies,
|
||||
resetDiscordOutboundMocks,
|
||||
} from "./outbound-adapter.test-harness.js";
|
||||
|
||||
const hoisted = createDiscordOutboundHoisted();
|
||||
await installDiscordOutboundModuleSpies(hoisted);
|
||||
|
||||
let discordPlugin: typeof import("./channel.js").discordPlugin;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ discordPlugin } = await import("./channel.js"));
|
||||
});
|
||||
|
||||
describe("discord channel message adapter", () => {
|
||||
beforeEach(() => {
|
||||
resetDiscordOutboundMocks(hoisted);
|
||||
});
|
||||
|
||||
it("backs declared durable-final capabilities with outbound send proofs", async () => {
|
||||
const adapter = discordPlugin.message;
|
||||
expect(adapter).toBeDefined();
|
||||
|
||||
const proveText = async () => {
|
||||
resetDiscordOutboundMocks(hoisted);
|
||||
const result = await adapter!.send!.text!({
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
|
||||
"channel:123456",
|
||||
"hello",
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("text");
|
||||
};
|
||||
|
||||
const proveMedia = async () => {
|
||||
resetDiscordOutboundMocks(hoisted);
|
||||
const result = await adapter!.send!.media!({
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
accountId: "default",
|
||||
});
|
||||
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
|
||||
"channel:123456",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
}),
|
||||
);
|
||||
expect(result.receipt.parts[0]?.kind).toBe("media");
|
||||
};
|
||||
|
||||
const provePayload = async () => {
|
||||
resetDiscordOutboundMocks(hoisted);
|
||||
const result = await adapter!.send!.payload!({
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "payload",
|
||||
payload: { text: "payload" },
|
||||
accountId: "default",
|
||||
});
|
||||
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
|
||||
"channel:123456",
|
||||
"payload",
|
||||
expect.objectContaining({ accountId: "default" }),
|
||||
);
|
||||
expect(result.receipt.platformMessageIds).toEqual(["msg-1"]);
|
||||
};
|
||||
|
||||
const proveReplyThreadSilent = async () => {
|
||||
resetDiscordOutboundMocks(hoisted);
|
||||
const result = await adapter!.send!.text!({
|
||||
cfg: {},
|
||||
to: "channel:parent-1",
|
||||
text: "threaded",
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
threadId: "thread-1",
|
||||
silent: true,
|
||||
});
|
||||
expect(hoisted.sendMessageDiscordMock).toHaveBeenLastCalledWith(
|
||||
"channel:thread-1",
|
||||
"threaded",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyTo: "reply-1",
|
||||
silent: true,
|
||||
}),
|
||||
);
|
||||
expect(result.receipt.threadId).toBe("thread-1");
|
||||
expect(result.receipt.replyToId).toBe("reply-1");
|
||||
};
|
||||
|
||||
await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "discordMessageAdapter",
|
||||
adapter: adapter!,
|
||||
proofs: {
|
||||
text: proveText,
|
||||
media: proveMedia,
|
||||
payload: provePayload,
|
||||
silent: proveReplyThreadSilent,
|
||||
replyTo: proveReplyThreadSilent,
|
||||
thread: proveReplyThreadSilent,
|
||||
messageSendingHooks: () => {
|
||||
expect(adapter!.send!.text).toBeTypeOf("function");
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
|
||||
const adapter = discordPlugin.message;
|
||||
|
||||
await verifyChannelMessageLiveCapabilityAdapterProofs({
|
||||
adapterName: "discordMessageAdapter",
|
||||
adapter: adapter!,
|
||||
proofs: {
|
||||
draftPreview: () => {
|
||||
expect(adapter!.live?.finalizer?.capabilities?.discardPending).toBe(true);
|
||||
},
|
||||
previewFinalization: () => {
|
||||
expect(adapter!.live?.finalizer?.capabilities?.finalEdit).toBe(true);
|
||||
},
|
||||
progressUpdates: () => {
|
||||
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await verifyChannelMessageLiveFinalizerProofs({
|
||||
adapterName: "discordMessageAdapter",
|
||||
adapter: adapter!,
|
||||
proofs: {
|
||||
finalEdit: () => {
|
||||
expect(adapter!.live?.capabilities?.previewFinalization).toBe(true);
|
||||
},
|
||||
normalFallback: () => {
|
||||
expect(adapter!.send!.text).toBeTypeOf("function");
|
||||
},
|
||||
discardPending: () => {
|
||||
expect(adapter!.live?.capabilities?.draftPreview).toBe(true);
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,6 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vite
|
||||
import type { ResolvedDiscordAccount } from "./accounts.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import * as sendModule from "./send.js";
|
||||
import { createDiscordSendReceipt } from "./send.receipt.js";
|
||||
import { EMPTY_DISCORD_TEST_CONFIG } from "./test-support/config.js";
|
||||
let discordPlugin: typeof import("./channel.js").discordPlugin;
|
||||
let setDiscordRuntime: typeof import("./runtime.js").setDiscordRuntime;
|
||||
@@ -19,14 +18,6 @@ const collectDiscordAuditChannelIdsMock = vi.hoisted(() =>
|
||||
);
|
||||
const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
|
||||
function discordTestSendResult(messageId: string, channelId = "channel:thread-123") {
|
||||
return {
|
||||
messageId,
|
||||
channelId,
|
||||
receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "text" }),
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/runtime-env")>(
|
||||
"openclaw/plugin-sdk/runtime-env",
|
||||
@@ -259,8 +250,8 @@ describe("discordPlugin outbound", () => {
|
||||
it("splits text and video into separate sends for attached outbound delivery", async () => {
|
||||
const sendMessageDiscord = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(discordTestSendResult("text-1"))
|
||||
.mockResolvedValueOnce(discordTestSendResult("video-1"));
|
||||
.mockResolvedValueOnce({ messageId: "text-1" })
|
||||
.mockResolvedValueOnce({ messageId: "video-1" });
|
||||
|
||||
const result = await discordPlugin.outbound!.sendMedia!({
|
||||
cfg: EMPTY_DISCORD_TEST_CONFIG,
|
||||
@@ -296,7 +287,10 @@ describe("discordPlugin outbound", () => {
|
||||
});
|
||||
|
||||
it("threads poll sends through the thread target", async () => {
|
||||
const sendPollDiscord = vi.fn(async () => discordTestSendResult("poll-1"));
|
||||
const sendPollDiscord = vi.fn(async () => ({
|
||||
channelId: "channel:thread-123",
|
||||
messageId: "poll-1",
|
||||
}));
|
||||
const sendPollSpy = vi.spyOn(sendModule, "sendPollDiscord").mockImplementation(sendPollDiscord);
|
||||
try {
|
||||
const result = await discordPlugin.outbound!.sendPoll!({
|
||||
|
||||
@@ -8,7 +8,6 @@ import type {
|
||||
ChannelMessageToolDiscovery,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-message";
|
||||
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
@@ -82,24 +81,6 @@ import { parseDiscordTarget } from "./target-parsing.js";
|
||||
|
||||
const REQUIRED_DISCORD_PERMISSIONS = ["ViewChannel", "SendMessages"] as const;
|
||||
const DISCORD_ACCOUNT_STARTUP_STAGGER_MS = 10_000;
|
||||
const discordMessageAdapter = createChannelMessageAdapterFromOutbound({
|
||||
id: "discord",
|
||||
outbound: discordOutbound,
|
||||
live: {
|
||||
capabilities: {
|
||||
draftPreview: true,
|
||||
previewFinalization: true,
|
||||
progressUpdates: true,
|
||||
},
|
||||
finalizer: {
|
||||
capabilities: {
|
||||
finalEdit: true,
|
||||
normalFallback: true,
|
||||
discardPending: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function startDiscordStartupProbe(params: {
|
||||
accountId: string;
|
||||
@@ -199,12 +180,6 @@ const discordMessageActions = {
|
||||
resolveRuntimeDiscordMessageActions()?.extractToolSend?.(ctx) ??
|
||||
discordMessageActionsImpl.extractToolSend?.(ctx) ??
|
||||
null,
|
||||
prepareSendPayload: (
|
||||
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["prepareSendPayload"]>>[0],
|
||||
) =>
|
||||
resolveRuntimeDiscordMessageActions()?.prepareSendPayload?.(ctx) ??
|
||||
discordMessageActionsImpl.prepareSendPayload?.(ctx) ??
|
||||
null,
|
||||
handleAction: async (
|
||||
ctx: Parameters<NonNullable<ChannelMessageActionAdapter["handleAction"]>>[0],
|
||||
) => {
|
||||
@@ -340,7 +315,6 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
listGroupsLive: (runtime) => runtime.listDiscordDirectoryGroupsLive,
|
||||
}),
|
||||
}),
|
||||
message: discordMessageAdapter,
|
||||
resolver: {
|
||||
resolveTargets: async ({ cfg, accountId, inputs, kind }) => {
|
||||
const account = resolveDiscordAccount({ cfg, accountId });
|
||||
|
||||
@@ -37,7 +37,7 @@ import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
|
||||
let conversationRuntimePromise: Promise<typeof import("./agent-components.runtime.js")> | undefined;
|
||||
let replyPipelineRuntimePromise:
|
||||
| Promise<typeof import("openclaw/plugin-sdk/channel-message")>
|
||||
| Promise<typeof import("openclaw/plugin-sdk/channel-reply-pipeline")>
|
||||
| undefined;
|
||||
let typingRuntimePromise: Promise<typeof import("./typing.js")> | undefined;
|
||||
|
||||
@@ -47,7 +47,7 @@ async function loadConversationRuntime() {
|
||||
}
|
||||
|
||||
async function loadReplyPipelineRuntime() {
|
||||
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-message");
|
||||
replyPipelineRuntimePromise ??= import("openclaw/plugin-sdk/channel-reply-pipeline");
|
||||
return await replyPipelineRuntimePromise;
|
||||
}
|
||||
|
||||
@@ -241,8 +241,8 @@ export async function dispatchDiscordComponentEvent(params: {
|
||||
|
||||
const deliverTarget = `channel:${interactionCtx.channelId}`;
|
||||
const typingChannelId = interactionCtx.channelId;
|
||||
const { createChannelMessageReplyPipeline } = await loadReplyPipelineRuntime();
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
const { createChannelReplyPipeline } = await loadReplyPipelineRuntime();
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: ctx.cfg,
|
||||
agentId,
|
||||
channel: "discord",
|
||||
|
||||
@@ -6,12 +6,11 @@ import {
|
||||
logTypingFailure,
|
||||
shouldAckReaction as shouldAckReactionGate,
|
||||
} from "openclaw/plugin-sdk/channel-feedback";
|
||||
import { deliverFinalizableDraftPreview } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import {
|
||||
createChannelMessageReplyPipeline,
|
||||
defineFinalizableLivePreviewAdapter,
|
||||
deliverWithFinalizableLivePreviewAdapter,
|
||||
resolveChannelMessageSourceReplyDeliveryMode,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
createChannelReplyPipeline,
|
||||
resolveChannelSourceReplyDeliveryMode,
|
||||
} from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import {
|
||||
formatChannelProgressDraftLine,
|
||||
formatChannelProgressDraftLineForEntry,
|
||||
@@ -174,7 +173,7 @@ export async function processDiscordMessage(
|
||||
}
|
||||
const { createReplyDispatcherWithTyping, dispatchInboundMessage, settleReplyDispatcher } =
|
||||
await loadReplyRuntime();
|
||||
const sourceReplyDeliveryMode = resolveChannelMessageSourceReplyDeliveryMode({
|
||||
const sourceReplyDeliveryMode = resolveChannelSourceReplyDeliveryMode({
|
||||
cfg,
|
||||
ctx: { ChatType: isGuildMessage ? "channel" : undefined },
|
||||
});
|
||||
@@ -365,7 +364,7 @@ export async function processDiscordMessage(
|
||||
? deliverTarget.slice("channel:".length)
|
||||
: messageChannelId;
|
||||
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "discord",
|
||||
@@ -456,51 +455,39 @@ export async function processDiscordMessage(
|
||||
Boolean(payload.replyToTag || payload.replyToCurrent) ||
|
||||
(typeof finalText === "string" && /\[\[\s*reply_to(?:_current|\s*:)/i.test(finalText));
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
const result = await deliverFinalizableDraftPreview({
|
||||
kind: info.kind,
|
||||
payload,
|
||||
adapter: defineFinalizableLivePreviewAdapter({
|
||||
draft: {
|
||||
flush: () => draftPreview.flush(),
|
||||
clear: () => draftStream.clear(),
|
||||
discardPending: () => draftStream.discardPending(),
|
||||
seal: () => draftStream.seal(),
|
||||
id: draftStream.messageId,
|
||||
},
|
||||
buildFinalEdit: () => {
|
||||
if (
|
||||
draftPreview.finalizedViaPreviewMessage ||
|
||||
hasMedia ||
|
||||
typeof previewFinalText !== "string" ||
|
||||
hasExplicitReplyDirective ||
|
||||
payload.isError
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { content: previewFinalText };
|
||||
},
|
||||
editFinal: async (previewMessageId, edit) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
throw new Error("process aborted");
|
||||
}
|
||||
notifyFinalReplyStart();
|
||||
await editMessageDiscord(deliverChannelId, previewMessageId, edit, {
|
||||
cfg,
|
||||
accountId,
|
||||
rest: deliveryRest,
|
||||
});
|
||||
},
|
||||
onPreviewFinalized: () => {
|
||||
draftPreview.markPreviewFinalized();
|
||||
replyReference.markSent();
|
||||
observer?.onFinalReplyDelivered?.();
|
||||
},
|
||||
logPreviewEditFailure: (err) => {
|
||||
logVerbose(
|
||||
`discord: preview final edit failed; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
draft: {
|
||||
flush: () => draftPreview.flush(),
|
||||
clear: () => draftStream.clear(),
|
||||
discardPending: () => draftStream.discardPending(),
|
||||
seal: () => draftStream.seal(),
|
||||
id: draftStream.messageId,
|
||||
},
|
||||
buildFinalEdit: () => {
|
||||
if (
|
||||
draftPreview.finalizedViaPreviewMessage ||
|
||||
hasMedia ||
|
||||
typeof previewFinalText !== "string" ||
|
||||
hasExplicitReplyDirective ||
|
||||
payload.isError
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return { content: previewFinalText };
|
||||
},
|
||||
editFinal: async (previewMessageId, edit) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
throw new Error("process aborted");
|
||||
}
|
||||
notifyFinalReplyStart();
|
||||
await editMessageDiscord(deliverChannelId, previewMessageId, edit, {
|
||||
cfg,
|
||||
accountId,
|
||||
rest: deliveryRest,
|
||||
});
|
||||
},
|
||||
deliverNormally: async () => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
return false;
|
||||
@@ -529,8 +516,18 @@ export async function processDiscordMessage(
|
||||
observer?.onFinalReplyDelivered?.();
|
||||
return true;
|
||||
},
|
||||
onPreviewFinalized: () => {
|
||||
draftPreview.markPreviewFinalized();
|
||||
replyReference.markSent();
|
||||
observer?.onFinalReplyDelivered?.();
|
||||
},
|
||||
logPreviewEditFailure: (err) => {
|
||||
logVerbose(
|
||||
`discord: preview final edit failed; falling back to standard send (${String(err)})`,
|
||||
);
|
||||
},
|
||||
});
|
||||
if (result.kind !== "normal-skipped") {
|
||||
if (result !== "normal-skipped") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import type {
|
||||
ModalInteraction,
|
||||
StringSelectMenuInteraction,
|
||||
} from "../internal/discord.js";
|
||||
import { createDiscordSendReceipt } from "../send.receipt.js";
|
||||
import {
|
||||
dispatchPluginInteractiveHandlerMock,
|
||||
dispatchReplyMock,
|
||||
@@ -51,14 +50,6 @@ function getLastRecordedCtx(): Record<string, unknown> | undefined {
|
||||
return params?.ctx;
|
||||
}
|
||||
|
||||
function discordTestSendResult(messageId: string, channelId = "dm-channel") {
|
||||
return {
|
||||
messageId,
|
||||
channelId,
|
||||
receipt: createDiscordSendReceipt({ platformMessageIds: [messageId], channelId, kind: "card" }),
|
||||
};
|
||||
}
|
||||
|
||||
describe("discord component interactions", () => {
|
||||
let editDiscordComponentMessageMock: ReturnType<typeof vi.spyOn>;
|
||||
const createCfg = (): OpenClawConfig =>
|
||||
@@ -263,7 +254,10 @@ describe("discord component interactions", () => {
|
||||
beforeEach(() => {
|
||||
editDiscordComponentMessageMock = vi
|
||||
.spyOn(sendComponents, "editDiscordComponentMessage")
|
||||
.mockResolvedValue(discordTestSendResult("msg-1"));
|
||||
.mockResolvedValue({
|
||||
messageId: "msg-1",
|
||||
channelId: "dm-channel",
|
||||
});
|
||||
clearDiscordComponentEntries();
|
||||
resetDiscordComponentRuntimeMocks();
|
||||
lastDispatchCtx = undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { resolveHumanDelayConfig } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-message";
|
||||
import { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-streaming";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
@@ -42,7 +42,7 @@ export async function dispatchDiscordNativeAgentReply(params: {
|
||||
suppressReplies?: boolean;
|
||||
log: ReturnType<typeof createSubsystemLogger>;
|
||||
}): Promise<void> {
|
||||
const { onModelSelected, ...replyPipeline } = createChannelMessageReplyPipeline({
|
||||
const { onModelSelected, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg: params.cfg,
|
||||
agentId: params.effectiveRoute.agentId,
|
||||
channel: "discord",
|
||||
|
||||
@@ -3,18 +3,12 @@ import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as discordClientModule from "../client.js";
|
||||
import * as discordSendModule from "../send.js";
|
||||
import { createDiscordSendReceipt } from "../send.receipt.js";
|
||||
import { EMPTY_DISCORD_TEST_CONFIG } from "../test-support/config.js";
|
||||
import type { ThreadBindingRecord } from "./thread-bindings.types.js";
|
||||
|
||||
const DEFAULT_SEND_RESULT = {
|
||||
messageId: "msg-1",
|
||||
channelId: "thread-1",
|
||||
receipt: createDiscordSendReceipt({
|
||||
platformMessageIds: ["msg-1"],
|
||||
channelId: "thread-1",
|
||||
kind: "text",
|
||||
}),
|
||||
};
|
||||
|
||||
const restGet = vi.fn<(...args: unknown[]) => Promise<unknown>>();
|
||||
|
||||
@@ -541,38 +541,6 @@ describe("discordOutbound", () => {
|
||||
).toBe("reply-1");
|
||||
});
|
||||
|
||||
it("sends prepared native Discord payload data through outbound delivery", async () => {
|
||||
await discordOutbound.sendPayload?.({
|
||||
cfg: {},
|
||||
to: "channel:123456",
|
||||
text: "",
|
||||
payload: {
|
||||
text: "hello",
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
channelData: {
|
||||
discord: {
|
||||
components: [{ type: 1, components: [] }],
|
||||
filename: "photo.png",
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "default",
|
||||
replyToId: "reply-1",
|
||||
});
|
||||
|
||||
expect(hoisted.sendMessageDiscordMock).toHaveBeenCalledWith(
|
||||
"channel:123456",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "https://example.com/photo.png",
|
||||
components: [{ type: 1, components: [] }],
|
||||
filename: "photo.png",
|
||||
accountId: "default",
|
||||
replyTo: "reply-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit component payload replies when replyToMode is off", async () => {
|
||||
const payload = await discordOutbound.renderPresentation?.({
|
||||
payload: {
|
||||
|
||||
@@ -120,17 +120,6 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
context: true,
|
||||
divider: true,
|
||||
},
|
||||
deliveryCapabilities: {
|
||||
durableFinal: {
|
||||
text: true,
|
||||
media: true,
|
||||
payload: true,
|
||||
silent: true,
|
||||
replyTo: true,
|
||||
thread: true,
|
||||
messageSendingHooks: true,
|
||||
},
|
||||
},
|
||||
renderPresentation: async ({ payload, presentation }) => {
|
||||
return await buildDiscordPresentationPayload({
|
||||
payload,
|
||||
|
||||
@@ -67,12 +67,7 @@ export async function resolveDiscordComponentSpec(
|
||||
| { components?: unknown; presentationComponents?: DiscordComponentMessageSpec }
|
||||
| undefined;
|
||||
const rawComponentSpec =
|
||||
discordData?.presentationComponents ??
|
||||
(discordData?.components &&
|
||||
typeof discordData.components === "object" &&
|
||||
!Array.isArray(discordData.components)
|
||||
? readDiscordComponentSpec(discordData.components)
|
||||
: null);
|
||||
discordData?.presentationComponents ?? readDiscordComponentSpec(discordData?.components);
|
||||
if (rawComponentSpec) {
|
||||
return addPayloadTextFallback(rawComponentSpec, payload);
|
||||
}
|
||||
|
||||
@@ -7,15 +7,12 @@ import {
|
||||
sendPayloadMediaSequenceOrFallback,
|
||||
sendTextMediaPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeDiscordApprovalPayload } from "./outbound-approval.js";
|
||||
import {
|
||||
resolveDiscordComponentSpec,
|
||||
sendDiscordComponentMessageLazy,
|
||||
} from "./outbound-components.js";
|
||||
import { createDiscordPayloadSendContext } from "./outbound-send-context.js";
|
||||
import { createDiscordSendReceipt } from "./send.receipt.js";
|
||||
import type { DiscordSendComponents, DiscordSendEmbeds } from "./send.shared.js";
|
||||
|
||||
export async function sendDiscordOutboundPayload(params: {
|
||||
ctx: Parameters<NonNullable<ChannelOutboundAdapter["sendPayload"]>>[0];
|
||||
@@ -74,69 +71,6 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
|
||||
const componentSpec = await resolveDiscordComponentSpec(payload);
|
||||
if (!componentSpec) {
|
||||
const discordData =
|
||||
payload.channelData?.discord &&
|
||||
typeof payload.channelData.discord === "object" &&
|
||||
!Array.isArray(payload.channelData.discord)
|
||||
? (payload.channelData.discord as Record<string, unknown>)
|
||||
: {};
|
||||
const nativeComponents = Array.isArray(discordData.components)
|
||||
? (discordData.components as DiscordSendComponents)
|
||||
: undefined;
|
||||
const embeds = Array.isArray(discordData.embeds)
|
||||
? (discordData.embeds as DiscordSendEmbeds)
|
||||
: undefined;
|
||||
const filename = normalizeOptionalString(discordData.filename);
|
||||
if (nativeComponents || embeds?.length || filename) {
|
||||
const result = await sendPayloadMediaSequenceOrFallback({
|
||||
text: payload.text ?? "",
|
||||
mediaUrls,
|
||||
fallbackResult: {
|
||||
messageId: "",
|
||||
channelId: sendContext.target,
|
||||
receipt: createDiscordSendReceipt({
|
||||
platformMessageIds: [],
|
||||
channelId: sendContext.target,
|
||||
kind: "unknown",
|
||||
}),
|
||||
},
|
||||
sendNoMedia: async () =>
|
||||
await sendContext.withRetry(
|
||||
async () =>
|
||||
await sendContext.send(sendContext.target, payload.text ?? "", {
|
||||
verbose: false,
|
||||
components: nativeComponents,
|
||||
embeds,
|
||||
filename,
|
||||
replyTo: sendContext.resolveReplyTo(),
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
silent: ctx.silent ?? undefined,
|
||||
cfg: ctx.cfg,
|
||||
...sendContext.formatting,
|
||||
}),
|
||||
),
|
||||
send: async ({ text, mediaUrl, isFirst }) =>
|
||||
await sendContext.withRetry(
|
||||
async () =>
|
||||
await sendContext.send(sendContext.target, text, {
|
||||
verbose: false,
|
||||
mediaUrl,
|
||||
mediaAccess: ctx.mediaAccess,
|
||||
mediaLocalRoots: ctx.mediaLocalRoots,
|
||||
mediaReadFile: ctx.mediaReadFile,
|
||||
components: isFirst ? nativeComponents : undefined,
|
||||
embeds: isFirst ? embeds : undefined,
|
||||
filename: isFirst ? filename : undefined,
|
||||
replyTo: sendContext.resolveReplyTo(),
|
||||
accountId: ctx.accountId ?? undefined,
|
||||
silent: ctx.silent ?? undefined,
|
||||
cfg: ctx.cfg,
|
||||
...sendContext.formatting,
|
||||
}),
|
||||
),
|
||||
});
|
||||
return attachChannelToResult("discord", result);
|
||||
}
|
||||
return await sendTextMediaPayload({
|
||||
channel: "discord",
|
||||
ctx: {
|
||||
@@ -150,15 +84,7 @@ export async function sendDiscordOutboundPayload(params: {
|
||||
const result = await sendPayloadMediaSequenceOrFallback({
|
||||
text: payload.text ?? "",
|
||||
mediaUrls,
|
||||
fallbackResult: {
|
||||
messageId: "",
|
||||
channelId: sendContext.target,
|
||||
receipt: createDiscordSendReceipt({
|
||||
platformMessageIds: [],
|
||||
channelId: sendContext.target,
|
||||
kind: "unknown",
|
||||
}),
|
||||
},
|
||||
fallbackResult: { messageId: "", channelId: sendContext.target },
|
||||
sendNoMedia: async () =>
|
||||
await sendContext.withRetry(
|
||||
async () =>
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import { parseAndResolveRecipient } from "./recipient-resolution.js";
|
||||
import { loadOutboundMediaFromUrl } from "./runtime-api.js";
|
||||
import { sendMessageDiscord } from "./send.outbound.js";
|
||||
import { createDiscordSendResult } from "./send.receipt.js";
|
||||
import {
|
||||
buildDiscordSendError,
|
||||
createDiscordClient,
|
||||
@@ -322,12 +321,10 @@ export async function sendDiscordComponentMessage(
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return createDiscordSendResult({
|
||||
result,
|
||||
fallbackChannelId: channelId,
|
||||
kind: "card",
|
||||
...(opts.replyTo ? { replyToId: opts.replyTo } : {}),
|
||||
});
|
||||
return {
|
||||
messageId: result.id ?? "unknown",
|
||||
channelId: result.channel_id ?? channelId,
|
||||
};
|
||||
}
|
||||
|
||||
export async function editDiscordComponentMessage(
|
||||
@@ -377,13 +374,8 @@ export async function editDiscordComponentMessage(
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return createDiscordSendResult({
|
||||
result: {
|
||||
id: result.id ?? messageId,
|
||||
channel_id: result.channel_id,
|
||||
},
|
||||
fallbackChannelId: channelId,
|
||||
kind: "card",
|
||||
...(opts.replyTo ? { replyToId: opts.replyTo } : {}),
|
||||
});
|
||||
return {
|
||||
messageId: result.id ?? messageId,
|
||||
channelId: result.channel_id ?? channelId,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -434,10 +434,7 @@ describe("sendStickerDiscord", () => {
|
||||
token: "t",
|
||||
content: "hiya",
|
||||
});
|
||||
expect(res).toMatchObject({ messageId: "msg1", channelId: "789" });
|
||||
expect(res.receipt.parts[0]).toEqual(
|
||||
expect.objectContaining({ platformMessageId: "msg1", kind: "card" }),
|
||||
);
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
@@ -470,10 +467,7 @@ describe("sendPollDiscord", () => {
|
||||
token: "t",
|
||||
},
|
||||
);
|
||||
expect(res).toMatchObject({ messageId: "msg1", channelId: "789" });
|
||||
expect(res.receipt.parts[0]).toEqual(
|
||||
expect.objectContaining({ platformMessageId: "msg1", kind: "card" }),
|
||||
);
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({
|
||||
@@ -554,13 +548,9 @@ describe("retry rate limits", () => {
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 1000, jitter: 0 },
|
||||
});
|
||||
|
||||
await expect(promise).resolves.toMatchObject({
|
||||
await expect(promise).resolves.toEqual({
|
||||
messageId: "msg1",
|
||||
channelId: "789",
|
||||
receipt: expect.objectContaining({
|
||||
primaryPlatformMessageId: "msg1",
|
||||
platformMessageIds: ["msg1"],
|
||||
}),
|
||||
});
|
||||
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(1);
|
||||
} finally {
|
||||
@@ -608,8 +598,7 @@ describe("retry rate limits", () => {
|
||||
retry: { attempts: 2, minDelayMs: 0, maxDelayMs: 0, jitter: 0 },
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ messageId: "msg1", channelId: "789" });
|
||||
expect(result.receipt.platformMessageIds).toEqual(["msg1"]);
|
||||
expect(result).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { resolveDiscordAccount } from "./accounts.js";
|
||||
import { createChannelMessage, createThread, type RequestClient } from "./internal/discord.js";
|
||||
import { rewriteDiscordKnownMentions } from "./mentions.js";
|
||||
import { parseAndResolveRecipient } from "./recipient-resolution.js";
|
||||
import { createDiscordSendResult, type DiscordReceiptResultSource } from "./send.receipt.js";
|
||||
import {
|
||||
buildDiscordMessageRequest,
|
||||
buildDiscordSendError,
|
||||
@@ -56,7 +55,10 @@ type DiscordClientRequest = ReturnType<typeof createDiscordClient>["request"];
|
||||
|
||||
const DEFAULT_DISCORD_MEDIA_MAX_MB = 100;
|
||||
|
||||
type DiscordChannelMessageResult = DiscordReceiptResultSource;
|
||||
type DiscordChannelMessageResult = {
|
||||
id?: string | null;
|
||||
channel_id?: string | null;
|
||||
};
|
||||
|
||||
async function sendDiscordThreadTextChunks(params: {
|
||||
rest: RequestClient;
|
||||
@@ -103,24 +105,11 @@ function isForumLikeType(channelType?: number): boolean {
|
||||
function toDiscordSendResult(
|
||||
result: DiscordChannelMessageResult,
|
||||
fallbackChannelId: string,
|
||||
params: {
|
||||
kind?: Parameters<typeof createDiscordSendResult>[0]["kind"];
|
||||
threadId?: string | number;
|
||||
replyToId?: string;
|
||||
} = {},
|
||||
): DiscordSendResult {
|
||||
const resultParams: Parameters<typeof createDiscordSendResult>[0] = {
|
||||
result,
|
||||
fallbackChannelId,
|
||||
kind: params.kind ?? "text",
|
||||
return {
|
||||
messageId: result.id || "unknown",
|
||||
channelId: result.channel_id ?? fallbackChannelId,
|
||||
};
|
||||
if (params.threadId != null) {
|
||||
resultParams.threadId = params.threadId;
|
||||
}
|
||||
if (params.replyToId) {
|
||||
resultParams.replyToId = params.replyToId;
|
||||
}
|
||||
return createDiscordSendResult(resultParams);
|
||||
}
|
||||
|
||||
async function resolveDiscordSendTarget(
|
||||
@@ -289,11 +278,10 @@ export async function sendMessageDiscord(
|
||||
channel_id: resultChannelId,
|
||||
},
|
||||
channelId,
|
||||
{ kind: opts.mediaUrl ? "media" : "text", threadId },
|
||||
);
|
||||
}
|
||||
|
||||
let result: DiscordChannelMessageResult;
|
||||
let result: { id: string; channel_id: string } | { id: string | null; channel_id: string };
|
||||
try {
|
||||
if (opts.mediaUrl) {
|
||||
result = await sendDiscordMedia(
|
||||
@@ -345,10 +333,7 @@ export async function sendMessageDiscord(
|
||||
accountId: accountInfo.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
return toDiscordSendResult(result, channelId, {
|
||||
kind: opts.mediaUrl ? "media" : opts.components || opts.embeds ? "card" : "text",
|
||||
replyToId: opts.replyTo,
|
||||
});
|
||||
return toDiscordSendResult(result, channelId);
|
||||
}
|
||||
|
||||
export async function sendStickerDiscord(
|
||||
@@ -371,7 +356,7 @@ export async function sendStickerDiscord(
|
||||
}),
|
||||
"sticker",
|
||||
)) as { id: string; channel_id: string };
|
||||
return toDiscordSendResult(res, channelId, { kind: "card" });
|
||||
return toDiscordSendResult(res, channelId);
|
||||
}
|
||||
|
||||
export async function sendPollDiscord(
|
||||
@@ -399,7 +384,7 @@ export async function sendPollDiscord(
|
||||
}),
|
||||
"poll",
|
||||
)) as { id: string; channel_id: string };
|
||||
return toDiscordSendResult(res, channelId, { kind: "card" });
|
||||
return toDiscordSendResult(res, channelId);
|
||||
}
|
||||
|
||||
async function resolveDiscordStructuredSendContext(
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
type MessageReceiptPartKind,
|
||||
type MessageReceiptSourceResult,
|
||||
} from "openclaw/plugin-sdk/channel-message";
|
||||
import type { DiscordSendResult } from "./send.types.js";
|
||||
|
||||
export type DiscordReceiptResultSource = {
|
||||
id?: string | null;
|
||||
channel_id?: string | null;
|
||||
platformMessageIds?: readonly string[];
|
||||
};
|
||||
|
||||
export function createDiscordSendReceipt(params: {
|
||||
platformMessageIds: readonly string[];
|
||||
channelId?: string;
|
||||
kind: MessageReceiptPartKind;
|
||||
threadId?: string;
|
||||
replyToId?: string;
|
||||
}): MessageReceipt {
|
||||
const platformMessageIds = params.platformMessageIds
|
||||
.map((messageId) => messageId.trim())
|
||||
.filter((messageId) => messageId && messageId !== "unknown");
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results: platformMessageIds.map((messageId) => {
|
||||
const result: MessageReceiptSourceResult = {
|
||||
channel: "discord",
|
||||
messageId,
|
||||
};
|
||||
if (params.channelId) {
|
||||
result.channelId = params.channelId;
|
||||
}
|
||||
return result;
|
||||
}),
|
||||
kind: params.kind,
|
||||
threadId: params.threadId,
|
||||
replyToId: params.replyToId,
|
||||
});
|
||||
}
|
||||
|
||||
export function createDiscordSendResult(params: {
|
||||
result: DiscordReceiptResultSource;
|
||||
fallbackChannelId: string;
|
||||
kind: MessageReceiptPartKind;
|
||||
threadId?: string | number;
|
||||
replyToId?: string;
|
||||
}): DiscordSendResult {
|
||||
const messageId = params.result.id || "unknown";
|
||||
const channelId = params.result.channel_id ?? params.fallbackChannelId;
|
||||
const receiptParams: Parameters<typeof createDiscordSendReceipt>[0] = {
|
||||
platformMessageIds: params.result.platformMessageIds?.length
|
||||
? params.result.platformMessageIds
|
||||
: [messageId],
|
||||
channelId,
|
||||
kind: params.kind,
|
||||
};
|
||||
if (params.threadId != null) {
|
||||
receiptParams.threadId = String(params.threadId);
|
||||
}
|
||||
if (params.replyToId) {
|
||||
receiptParams.replyToId = params.replyToId;
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
channelId,
|
||||
receipt: createDiscordSendReceipt(receiptParams),
|
||||
};
|
||||
}
|
||||
@@ -139,12 +139,7 @@ describe("sendMessageDiscord", () => {
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res).toMatchObject({ messageId: "msg1", channelId: "789" });
|
||||
expect(res.receipt).toMatchObject({
|
||||
primaryPlatformMessageId: "msg1",
|
||||
platformMessageIds: ["msg1"],
|
||||
parts: [expect.objectContaining({ platformMessageId: "msg1", kind: "text" })],
|
||||
});
|
||||
expect(res).toEqual({ messageId: "msg1", channelId: "789" });
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.channelMessages("789"),
|
||||
expect.objectContaining({ body: { content: "hello world" } }),
|
||||
@@ -250,12 +245,7 @@ describe("sendMessageDiscord", () => {
|
||||
token: "t",
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
});
|
||||
expect(res).toMatchObject({ messageId: "starter1", channelId: "thread1" });
|
||||
expect(res.receipt).toMatchObject({
|
||||
threadId: "thread1",
|
||||
platformMessageIds: ["starter1"],
|
||||
parts: [expect.objectContaining({ platformMessageId: "starter1", kind: "text" })],
|
||||
});
|
||||
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
|
||||
// Should POST to threads route, not channelMessages.
|
||||
expect(postMock).toHaveBeenCalledWith(
|
||||
Routes.threads("forum1"),
|
||||
@@ -276,12 +266,7 @@ describe("sendMessageDiscord", () => {
|
||||
cfg: DISCORD_TEST_CFG,
|
||||
mediaUrl: "file:///tmp/photo.jpg",
|
||||
});
|
||||
expect(res).toMatchObject({ messageId: "starter1", channelId: "thread1" });
|
||||
expect(res.receipt).toMatchObject({
|
||||
threadId: "thread1",
|
||||
platformMessageIds: ["starter1"],
|
||||
parts: [expect.objectContaining({ platformMessageId: "starter1", kind: "media" })],
|
||||
});
|
||||
expect(res).toEqual({ messageId: "starter1", channelId: "thread1" });
|
||||
expect(postMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
Routes.threads("forum1"),
|
||||
|
||||
@@ -328,21 +328,16 @@ async function sendDiscordText(
|
||||
)) as { id: string; channel_id: string };
|
||||
};
|
||||
if (chunks.length === 1) {
|
||||
const result = await sendChunk(chunks[0], true);
|
||||
return { ...result, platformMessageIds: result.id ? [result.id] : [] };
|
||||
return await sendChunk(chunks[0], true);
|
||||
}
|
||||
const platformMessageIds: string[] = [];
|
||||
let last: { id: string; channel_id: string } | null = null;
|
||||
for (const [index, chunk] of chunks.entries()) {
|
||||
last = await sendChunk(chunk, index === 0);
|
||||
if (last.id) {
|
||||
platformMessageIds.push(last.id);
|
||||
}
|
||||
}
|
||||
if (!last) {
|
||||
throw new Error("Discord send failed (empty chunk result)");
|
||||
}
|
||||
return { ...last, platformMessageIds };
|
||||
return last;
|
||||
}
|
||||
|
||||
async function sendDiscordMedia(
|
||||
@@ -403,12 +398,11 @@ async function sendDiscordMedia(
|
||||
() => createChannelMessage<{ id: string; channel_id: string }>(rest, channelId, { body }),
|
||||
"media",
|
||||
)) as { id: string; channel_id: string };
|
||||
const platformMessageIds = res.id ? [res.id] : [];
|
||||
for (const chunk of chunks.slice(1)) {
|
||||
if (!chunk.trim()) {
|
||||
continue;
|
||||
}
|
||||
const followup = await sendDiscordText(
|
||||
await sendDiscordText(
|
||||
rest,
|
||||
channelId,
|
||||
chunk,
|
||||
@@ -421,13 +415,8 @@ async function sendDiscordMedia(
|
||||
silent,
|
||||
maxChars,
|
||||
);
|
||||
for (const id of followup.platformMessageIds) {
|
||||
if (id) {
|
||||
platformMessageIds.push(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return { ...res, platformMessageIds };
|
||||
return res;
|
||||
}
|
||||
|
||||
function buildReactionIdentifier(emoji: { id?: string | null; name?: string | null }) {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
|
||||
import type { RetryConfig } from "openclaw/plugin-sdk/retry-runtime";
|
||||
import type { RequestClient } from "./internal/discord.js";
|
||||
@@ -30,7 +29,6 @@ export const DISCORD_MAX_EVENT_COVER_BYTES = 8 * 1024 * 1024;
|
||||
export type DiscordSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
receipt: MessageReceipt;
|
||||
};
|
||||
|
||||
export type DiscordRuntimeAccountContext = {
|
||||
|
||||
@@ -15,7 +15,6 @@ import { loadWebMediaRaw } from "openclaw/plugin-sdk/web-media";
|
||||
import { resolveDiscordAccount } from "./accounts.js";
|
||||
import type { RequestClient } from "./internal/discord.js";
|
||||
import { parseAndResolveRecipient } from "./recipient-resolution.js";
|
||||
import { createDiscordSendResult } from "./send.receipt.js";
|
||||
import { buildDiscordSendError, createDiscordClient, resolveChannelId } from "./send.shared.js";
|
||||
import type { DiscordSendResult } from "./send.types.js";
|
||||
import {
|
||||
@@ -39,11 +38,10 @@ function toDiscordSendResult(
|
||||
result: { id?: string | null; channel_id?: string | null },
|
||||
fallbackChannelId: string,
|
||||
): DiscordSendResult {
|
||||
return createDiscordSendResult({
|
||||
result,
|
||||
fallbackChannelId,
|
||||
kind: "voice",
|
||||
});
|
||||
return {
|
||||
messageId: result.id || "unknown",
|
||||
channelId: result.channel_id ?? fallbackChannelId,
|
||||
};
|
||||
}
|
||||
|
||||
async function materializeVoiceMessageInput(mediaUrl: string): Promise<{ filePath: string }> {
|
||||
|
||||
@@ -64,13 +64,9 @@ describe("sendWebhookMessageDiscord activity", () => {
|
||||
threadId: "thread-1",
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
expect(result).toEqual({
|
||||
messageId: "msg-1",
|
||||
channelId: "thread-1",
|
||||
receipt: expect.objectContaining({
|
||||
threadId: "thread-1",
|
||||
platformMessageIds: ["msg-1"],
|
||||
}),
|
||||
});
|
||||
expect(recordChannelActivityMock).toHaveBeenCalledWith({
|
||||
channel: "discord",
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
readRetryAfter,
|
||||
} from "./internal/rest-errors.js";
|
||||
import { rewriteDiscordKnownMentions } from "./mentions.js";
|
||||
import { createDiscordSendResult } from "./send.receipt.js";
|
||||
import type { DiscordSendResult } from "./send.types.js";
|
||||
|
||||
type DiscordWebhookSendOpts = {
|
||||
@@ -127,11 +126,8 @@ export async function sendWebhookMessageDiscord(
|
||||
} catch {
|
||||
// Best-effort telemetry only.
|
||||
}
|
||||
return createDiscordSendResult({
|
||||
result: payload,
|
||||
fallbackChannelId: opts.threadId ? String(opts.threadId) : "",
|
||||
kind: "text",
|
||||
...(opts.threadId != null ? { threadId: opts.threadId } : {}),
|
||||
...(replyTo ? { replyToId: replyTo } : {}),
|
||||
});
|
||||
return {
|
||||
messageId: payload.id || "unknown",
|
||||
channelId: payload.channel_id ? payload.channel_id : opts.threadId ? String(opts.threadId) : "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -33,7 +33,7 @@ export {
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-message";
|
||||
export { createReplyPrefixContext } from "openclaw/plugin-sdk/channel-reply-pipeline";
|
||||
export {
|
||||
evaluateSupplementalContextVisibility,
|
||||
filterSupplementalContextItems,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user