Compare commits

..

4 Commits

Author SHA1 Message Date
Dallin Romney
606494a5cb test: fold plugin lifecycle probe into qa e2e 2026-06-14 21:17:54 -07:00
Dallin Romney
dacd29616b test: point qa code refs at migrated e2e 2026-06-14 21:01:14 -07:00
Dallin Romney
fce216acf0 test: migrate script checks into qa e2e 2026-06-14 20:55:22 -07:00
Dallin Romney
da5c13b24a test: fold script coverage into qa scenarios 2026-06-14 18:44:59 -07:00
437 changed files with 4735 additions and 36924 deletions

View File

@@ -1,51 +0,0 @@
---
name: discord-user-post
description: Post an approved message as the logged-in Discord user through the Discord desktop app. Use for release announcements or other direct user-authored Discord posts; not for OpenClaw channel sends, bots, webhooks, relays, agent sessions, or archive search.
---
# Discord User Post
Use `$computer-use` to operate `/Applications/Discord.app` in the user's
existing logged-in session. This workflow represents the user directly.
## Prepare
1. Draft the complete final message outside Discord.
2. Confirm the intended server and channel with the user when either is
ambiguous.
3. Open Discord and navigate to the exact destination without entering the
message.
4. Verify the visible server name, channel header, and logged-in account.
Do not infer the target from unrelated Discord content. Stop if Discord is not
logged in, the account is wrong, or the exact destination cannot be verified.
## Confirm and Post
Posting is representational communication. Follow the `$computer-use`
confirmation policy even when the user previously asked for an announcement:
1. Show the user the exact final body and verified destination.
2. Request action-time confirmation before typing into Discord.
3. After confirmation, enter the approved body unchanged.
4. Visually inspect the composed message and destination again.
5. Send once.
If the body or destination changes after confirmation, request confirmation
again before sending.
## Verify
- Confirm the message appears once, from the user's account, in the intended
channel.
- Report the server, channel, and visible send result.
- Do not edit, delete, react, or send a follow-up without the corresponding
user instruction and confirmation.
## Guardrails
- Never use `openclaw message`, an OpenClaw agent, a Discord bot, webhook, relay,
or token for this workflow.
- Never expose private Discord content or account details in public output.
- Never send a draft, partial message, duplicate, or unreviewed attachment.
- For Discord archive/history/search, use `$discrawl` instead.

View File

@@ -1,4 +0,0 @@
interface:
display_name: "Discord User Post"
short_description: "Post approved messages through the logged-in Discord app"
default_prompt: "Post this approved message as me through the logged-in Discord desktop app."

View File

@@ -6,8 +6,7 @@ description: "Draft or post OpenClaw beta/stable Discord release announcements f
# OpenClaw Release Announcement
Use with `release-openclaw-maintainer` after a beta or stable release is live.
Use with `$discord-user-post` when actually posting to Discord as the logged-in
user.
Use with `openclaw-discord` when actually posting to Discord.
## Evidence First
@@ -81,7 +80,6 @@ Fresh installs still point to `https://openclaw.ai`.
## Posting
When asked to post, use `$discord-user-post` to operate the logged-in Discord
desktop app as the user. Resolve and visibly verify the exact server/channel,
inspect the final body, and request action-time confirmation before entering or
sending it. Never use OpenClaw channel sends, bots, webhooks, relays, or tokens.
When asked to post, use the configured Discord workflow from
`openclaw-discord` or the approved OpenClaw relay. Never print tokens.
For public channels, inspect the final body before sending.

View File

@@ -321,7 +321,6 @@ Upgrade with the beta channel.
Before tagging or publishing, run:
```bash
pnpm release:fast-pretag-check
pnpm check:architecture
pnpm build
pnpm ui:build
@@ -330,21 +329,6 @@ pnpm release:check
pnpm test:install:smoke
```
- Treat `pnpm release:fast-pretag-check` as a hard packaging gate. Every
publishable plugin must have a non-empty package-root `README.md`, build its
package-local runtime, and pass the npm and ClawHub release metadata checks
before a tag or publish workflow can start. Do not defer README, entrypoint,
or packed-artifact failures to postpublish verification.
- Before tagging, require green CI for the exact release-candidate SHA, not an
earlier branch SHA. Heal every related red CI, release-check, packaging, or
root-Dockerfile lane on the release branch, forward-port the fix to `main`,
and rerun the affected exact-SHA gates. Never waive a red Docker lane because
npm preflight passed.
- Root Dockerfile proof is mandatory before every beta and stable tag. Run the
release `install-smoke` group or equivalent root Dockerfile build for the
exact candidate SHA and require it to pass. The tag-triggered Docker Release
workflow is post-tag publishing, not the first valid proof that the root
Dockerfile can build.
- Before tagging, diff publishable plugin package manifests against the last
reachable stable/beta release tag. For every newly publishable package
(`openclaw.release.publishToNpm: true` or `publishToClawHub: true`) whose
@@ -660,10 +644,9 @@ node --import tsx scripts/openclaw-npm-postpublish-verify.ts <published-version>
off, live OpenAI off, and regression failure off. Let it run in parallel
with preflight and validation work.
10. Run the fast local beta preflight from the release branch before any npm
preflight or publish. Require exact-SHA CI and root Dockerfile install-smoke
to be green before tagging. Keep the remaining expensive Docker, Parallels,
and published-package install/update lanes for after the beta is live unless
the operator asks to run them before beta publication.
preflight or publish. Keep expensive Docker, Parallels, and published-package
install/update lanes for after the beta is live unless the operator asks to
run them before beta publication.
11. For beta releases, skip mac app build/sign/notarize unless beta scope or a
release blocker specifically requires it. For stable releases, include the
mac app, signing, notarization, and appcast path.

View File

@@ -1288,7 +1288,6 @@ jobs:
env:
OPENCLAW_LOCAL_CHECK: "0"
TASK: ${{ matrix.task }}
PR_BASE_SHA: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || '' }}
shell: bash
run: |
set -euo pipefail
@@ -1298,10 +1297,6 @@ jobs:
pnpm tool-display:check
pnpm check:host-env-policy:swift
pnpm dup:check:coverage
if [ -n "$PR_BASE_SHA" ]; then
git fetch --no-tags --depth=1 origin "+${PR_BASE_SHA}:refs/remotes/origin/pr-base"
node scripts/report-test-temp-creations.mjs --base refs/remotes/origin/pr-base --head HEAD --no-merge-base
fi
pnpm deps:patches:check
pnpm lint:webhook:no-low-level-body-read
pnpm lint:auth:no-pairing-store-group

View File

@@ -1,447 +0,0 @@
name: iOS Periphery Dead Code Comment
on:
workflow_run: # zizmor: ignore[dangerous-triggers] trusted PR commenter; job gates repository, source event, workflow name, live open PR, and exact current head before reading artifacts or writing comments
workflows: ["iOS Periphery Dead Code"]
types: [completed]
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
actions: read
contents: read
issues: write
pull-requests: read
jobs:
comment:
name: Comment on PR
runs-on: ubuntu-24.04
if: >
github.repository == 'openclaw/openclaw' &&
github.event.workflow_run.event == 'pull_request' &&
github.event.workflow_run.name == 'iOS Periphery Dead Code'
steps:
- name: Upsert Periphery PR comment
uses: actions/github-script@v9
with:
script: |
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const childProcess = require("node:child_process");
const marker = "<!-- openclaw-ios-periphery-dead-code -->";
const run = context.payload.workflow_run;
const pr = run.pull_requests?.[0];
if (!pr) {
core.info("No pull request attached to workflow_run.");
return;
}
const { owner, repo } = context.repo;
const repository = `${owner}/${repo}`;
if (run.repository?.full_name !== repository) {
core.info(`Skipping workflow_run from ${run.repository?.full_name ?? "unknown repository"}.`);
return;
}
if (run.event !== "pull_request") {
core.info(`Skipping workflow_run for ${run.event ?? "unknown"} event.`);
return;
}
if (run.name !== "iOS Periphery Dead Code") {
core.info(`Skipping unexpected workflow ${run.name ?? "unknown"}.`);
return;
}
const livePull = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (livePull.data.state !== "open") {
core.info(`Skipping closed PR #${pr.number}.`);
return;
}
if (livePull.data.base?.repo?.full_name !== repository) {
core.info(`Skipping PR #${pr.number} targeting ${livePull.data.base?.repo?.full_name ?? "unknown repository"}.`);
return;
}
if (livePull.data.head?.sha !== run.head_sha) {
core.info(`Skipping stale run ${run.id}; PR #${pr.number} is now at ${livePull.data.head?.sha}.`);
return;
}
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
owner,
repo,
run_id: run.id,
filter: "latest",
per_page: 100,
});
const scopeJob = jobs.find((job) => job.name === "Detect iOS scan scope");
const scanJob = jobs.find((job) => job.name === "Scan iOS dead code");
const scanSkipped =
scopeJob?.conclusion === "success" && scanJob?.conclusion === "skipped";
if (scanSkipped) {
core.info(`Skipping intentionally omitted Periphery scan for PR #${pr.number}.`);
}
const artifacts = scanSkipped
? []
: await github.paginate(github.rest.actions.listWorkflowRunArtifacts, {
owner,
repo,
run_id: run.id,
per_page: 100,
});
const readReport = async () => {
if (scanSkipped) {
return;
}
const artifactName = `ios-periphery-dead-code-${run.id}-${run.run_attempt}`;
const artifact = artifacts.find((item) => item.name === artifactName);
if (!artifact) {
core.warning(`No ${artifactName} artifact found.`);
return;
}
if (artifact.expired) {
core.warning(`${artifactName} artifact expired.`);
return;
}
const maxArchiveBytes = 1024 * 1024;
const archiveSize = Number(artifact.size_in_bytes);
if (!Number.isSafeInteger(archiveSize) || archiveSize < 0 || archiveSize > maxArchiveBytes) {
core.warning(`Skipping ${artifactName}; compressed artifact size ${artifact.size_in_bytes ?? "unknown"} exceeds the ${maxArchiveBytes} byte limit.`);
return;
}
const archive = await github.rest.actions.downloadArtifact({
owner,
repo,
artifact_id: artifact.id,
archive_format: "zip",
});
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "ios-periphery-"));
const archivePath = path.join(dir, "artifact.zip");
const archiveBuffer = Buffer.from(archive.data);
fs.writeFileSync(archivePath, archiveBuffer);
const allowedArtifactFiles = new Set([
"periphery.json",
"periphery.status",
"periphery.stderr.log",
"periphery.stdout.json",
"should-fail.txt",
]);
const maxEntries = allowedArtifactFiles.size;
const maxEntryBytes = 2 * 1024 * 1024;
const maxTotalBytes = 4 * 1024 * 1024;
const readUInt16 = (offset) => archiveBuffer.readUInt16LE(offset);
const readUInt32 = (offset) => archiveBuffer.readUInt32LE(offset);
const findEndOfCentralDirectoryOffset = () => {
const minimumOffset = Math.max(0, archiveBuffer.length - 0xffff - 22);
for (let offset = archiveBuffer.length - 22; offset >= minimumOffset; offset -= 1) {
if (readUInt32(offset) === 0x06054b50) {
return offset;
}
}
return -1;
};
const endOfCentralDirectoryOffset = findEndOfCentralDirectoryOffset();
if (endOfCentralDirectoryOffset < 0) {
core.warning(`Skipping ${artifactName}; ZIP end-of-central-directory record was not found.`);
return;
}
const entryCount = readUInt16(endOfCentralDirectoryOffset + 10);
const centralDirectorySize = readUInt32(endOfCentralDirectoryOffset + 12);
const centralDirectoryOffset = readUInt32(endOfCentralDirectoryOffset + 16);
if (entryCount < 1 || entryCount > maxEntries) {
core.warning(`Skipping ${artifactName}; artifact has ${entryCount} entries.`);
return;
}
if (
centralDirectoryOffset + centralDirectorySize > archiveBuffer.length ||
readUInt32(centralDirectoryOffset) !== 0x02014b50
) {
core.warning(`Skipping ${artifactName}; invalid ZIP central directory.`);
return;
}
const entries = new Map();
let totalUncompressedSize = 0;
let offset = centralDirectoryOffset;
for (let index = 0; index < entryCount; index += 1) {
if (offset + 46 > archiveBuffer.length || readUInt32(offset) !== 0x02014b50) {
core.warning(`Skipping ${artifactName}; invalid central directory entry.`);
return;
}
const compressionMethod = readUInt16(offset + 10);
const generalPurposeBitFlag = readUInt16(offset + 8);
const compressedSize = readUInt32(offset + 20);
const uncompressedSize = readUInt32(offset + 24);
const fileNameLength = readUInt16(offset + 28);
const extraLength = readUInt16(offset + 30);
const commentLength = readUInt16(offset + 32);
const externalAttributes = readUInt32(offset + 38);
const nameStart = offset + 46;
const nameEnd = nameStart + fileNameLength;
const nextOffset = nameEnd + extraLength + commentLength;
if (nextOffset > archiveBuffer.length) {
core.warning(`Skipping ${artifactName}; central directory entry exceeds archive bounds.`);
return;
}
const name = archiveBuffer.toString("utf8", nameStart, nameEnd);
const mode = externalAttributes >>> 16;
const fileType = mode & 0o170000;
const isRegularFile = fileType === 0 || fileType === 0o100000;
const invalidName =
!allowedArtifactFiles.has(name) ||
name.includes("/") ||
name.includes("\\") ||
name.includes("..") ||
path.isAbsolute(name);
if (invalidName) {
core.warning(`Skipping ${artifactName}; unexpected artifact entry ${name}.`);
return;
}
if (!isRegularFile || name.endsWith("/")) {
core.warning(`Skipping ${artifactName}; ${name} is not a regular file.`);
return;
}
if (entries.has(name)) {
core.warning(`Skipping ${artifactName}; duplicate artifact entry ${name}.`);
return;
}
if (![0, 8].includes(compressionMethod)) {
core.warning(`Skipping ${artifactName}; ${name} uses unsupported ZIP compression method ${compressionMethod}.`);
return;
}
if ((generalPurposeBitFlag & 0x1) !== 0) {
core.warning(`Skipping ${artifactName}; ${name} is encrypted.`);
return;
}
if (compressedSize > maxEntryBytes || uncompressedSize > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeds the per-file size limit.`);
return;
}
totalUncompressedSize += uncompressedSize;
if (totalUncompressedSize > maxTotalBytes) {
core.warning(`Skipping ${artifactName}; artifact exceeds the aggregate size limit.`);
return;
}
entries.set(name, { uncompressedSize });
offset = nextOffset;
}
const files = new Map();
for (const [name, entry] of entries) {
const contents = childProcess.execFileSync("unzip", ["-p", archivePath, name], {
encoding: "utf8",
maxBuffer: Math.max(1, entry.uncompressedSize + 1024),
timeout: 5000,
});
if (Buffer.byteLength(contents, "utf8") > maxEntryBytes) {
core.warning(`Skipping ${artifactName}; ${name} exceeded the per-file size limit while reading.`);
return;
}
files.set(name, contents);
}
const read = (name) => {
return files.get(name) ?? "";
};
const status = Number(read("periphery.status").trim() || "1");
let findings = null;
for (const name of ["periphery.json", "periphery.stdout.json"]) {
try {
const parsed = JSON.parse(read(name));
const validFindings =
Array.isArray(parsed) &&
parsed.every(
(finding) =>
finding !== null &&
typeof finding === "object" &&
!Array.isArray(finding),
);
if (validFindings) {
findings = parsed;
break;
}
} catch {}
}
return { findings, status };
};
const report = await readReport();
const status = report?.status ?? 1;
const findings = report?.findings ?? null;
const sanitizeCell = (value) => {
const normalized = String(value ?? "")
.replace(/[\u0000-\u001f\u007f-\u009f]/gu, " ")
.replace(/[\u200b-\u200f\u202a-\u202e\u2060\u2066-\u2069\ufeff]/gu, "")
.replace(/\s+/gu, " ")
.trim();
const maxEncodedLength = 180;
let escaped = "";
for (const character of normalized) {
const encoded =
character === "`"
? "'"
: character === "|"
? "\\|"
: character;
if (escaped.length + encoded.length > maxEncodedLength) {
break;
}
escaped += encoded;
}
return `\`${escaped || "-"}\``;
};
const rows = (findings ?? []).map((finding) => {
const location = String(finding.location ?? "");
const [file, line] = location.split(":");
return {
file: file ? `apps/ios/${file}` : "",
line: line || "",
kind: String(finding.kind ?? ""),
name: String(finding.name ?? ""),
};
});
let mode = "failure";
let body = `${marker}\n`;
if (scanSkipped) {
mode = "skipped";
body += [
"### iOS Periphery",
"",
"Periphery scan skipped because the pull request is a draft or no longer touches iOS scan scope.",
].join("\n");
} else if (findings === null) {
body += [
"### iOS Periphery",
"",
"Periphery did not complete or its report could not be safely read. Check the workflow run for details.",
].join("\n");
} else if (rows.length === 0 && status === 0) {
mode = "success";
body += [
"### iOS Periphery",
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
const shown = rows.slice(0, 50);
body += [
"### iOS Periphery",
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. Remove the code or add a narrow Periphery exemption with a comment explaining why it must stay.`,
"",
"| File | Line | Kind | Name |",
"| --- | ---: | --- | --- |",
...shown.map((row) => `| ${sanitizeCell(row.file)} | ${sanitizeCell(row.line)} | ${sanitizeCell(row.kind)} | ${sanitizeCell(row.name)} |`),
rows.length > shown.length ? "" : null,
rows.length > shown.length ? `Showing first ${shown.length}; full JSON is in the workflow artifact.` : null,
].filter(Boolean).join("\n");
} else {
body += [
"### iOS Periphery",
"",
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
].join("\n");
}
body += "\n";
const maxCommentChars = 60_000;
if (body.length > maxCommentChars) {
body = [
marker,
"### iOS Periphery",
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. The rendered report exceeded the safe comment limit; use the workflow artifact for details.`,
"",
].join("\n");
}
const comments = await github.paginate(github.rest.issues.listComments, {
owner,
repo,
issue_number: livePull.data.number,
per_page: 100,
});
const existing = comments.find(
(comment) =>
comment.user?.login === "github-actions[bot]" &&
comment.body?.includes(marker),
);
if (!existing && ["skipped", "success"].includes(mode)) {
core.info(`No existing Periphery comment and scan ${mode}; skipping comment.`);
return;
}
const currentPull = await github.rest.pulls.get({
owner,
repo,
pull_number: pr.number,
});
if (
currentPull.data.state !== "open" ||
currentPull.data.base?.repo?.full_name !== repository ||
currentPull.data.head?.sha !== run.head_sha
) {
core.info(`Skipping stale run ${run.id}; PR #${pr.number} changed before comment update.`);
return;
}
const workflowRuns = await github.paginate(github.rest.actions.listWorkflowRuns, {
owner,
repo,
workflow_id: run.workflow_id,
event: "pull_request",
head_sha: run.head_sha,
per_page: 100,
});
const supersedingRun = workflowRuns.find(
(candidate) =>
(candidate.id === run.id ||
candidate.pull_requests?.some(
(candidatePull) => candidatePull.number === pr.number,
)) &&
(candidate.run_number > run.run_number ||
(candidate.run_number === run.run_number &&
candidate.run_attempt > run.run_attempt)),
);
if (supersedingRun) {
core.info(`Skipping superseded run ${run.id} attempt ${run.run_attempt}; run ${supersedingRun.id} attempt ${supersedingRun.run_attempt} is newer.`);
return;
}
if (existing) {
await github.rest.issues.updateComment({
owner,
repo,
comment_id: existing.id,
body,
});
return;
}
await github.rest.issues.createComment({
owner,
repo,
issue_number: livePull.data.number,
body,
});

View File

@@ -1,229 +0,0 @@
name: iOS Periphery Dead Code
on:
pull_request:
types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
workflow_dispatch:
concurrency:
group: ios-periphery-${{ github.workflow }}-${{ github.event.pull_request.number || github.sha }}
cancel-in-progress: true
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
permissions:
contents: read
pull-requests: read
jobs:
scope:
name: Detect iOS scan scope
runs-on: ubuntu-24.04
outputs:
should-scan: ${{ steps.scope.outputs.should-scan }}
steps:
- name: Detect changed paths
id: scope
uses: actions/github-script@v9
with:
script: |
if (context.eventName === "workflow_dispatch") {
core.setOutput("should-scan", "true");
return;
}
if (context.payload.pull_request?.draft) {
core.setOutput("should-scan", "false");
return;
}
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.payload.pull_request.number,
per_page: 100,
});
const isScanPath = (filename) =>
typeof filename === "string" && (
filename.startsWith("apps/ios/") ||
filename === ".github/workflows/ios-periphery.yml" ||
filename === ".github/workflows/ios-periphery-comment.yml" ||
filename === "config/swiftformat" ||
filename === "config/swiftlint.yml"
);
const shouldScan = files.some(
({ filename, previous_filename: previousFilename }) =>
isScanPath(filename) || isScanPath(previousFilename)
);
core.setOutput("should-scan", String(shouldScan));
scan:
name: Scan iOS dead code
needs: scope
if: ${{ needs.scope.outputs.should-scan == 'true' }}
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
timeout-minutes: 45
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 1
fetch-tags: false
persist-credentials: false
submodules: false
- name: Verify Xcode
run: |
set -euo pipefail
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
if [ -d "$xcode_app/Contents/Developer" ]; then
sudo xcode-select -s "$xcode_app/Contents/Developer"
break
fi
done
xcodebuild -version
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
if [[ "$xcode_version" != 26.* ]]; then
echo "error: expected Xcode 26.x, got $xcode_version" >&2
exit 1
fi
swift --version
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Install iOS Swift tooling
run: brew install xcodegen swiftformat swiftlint periphery
- name: Generate iOS project
run: |
set -euo pipefail
./scripts/ios-configure-signing.sh
./scripts/ios-write-version-xcconfig.sh
cd apps/ios
xcodegen generate
- name: Run Periphery
run: |
set -euo pipefail
output_dir="$RUNNER_TEMP/ios-periphery"
mkdir -p "$output_dir"
cd apps/ios
set +e
periphery scan \
--config .periphery.yml \
--strict \
--format json \
--write-results "$output_dir/periphery.json" \
>"$output_dir/periphery.stdout.json" \
2>"$output_dir/periphery.stderr.log"
periphery_status="$?"
set -e
printf '%s\n' "$periphery_status" >"$output_dir/periphery.status"
if [ ! -s "$output_dir/periphery.json" ]; then
cp "$output_dir/periphery.stdout.json" "$output_dir/periphery.json"
fi
- name: Build Periphery report
run: |
set -euo pipefail
node <<'NODE'
const fs = require("node:fs");
const path = require("node:path");
const outputDir = path.join(process.env.RUNNER_TEMP, "ios-periphery");
const read = (name) => {
const file = path.join(outputDir, name);
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
};
const status = Number(read("periphery.status").trim() || "1");
let findings = null;
for (const name of ["periphery.json", "periphery.stdout.json"]) {
try {
const parsed = JSON.parse(read(name));
if (Array.isArray(parsed)) {
findings = parsed;
break;
}
} catch {}
}
const escapeCommandData = (value) =>
String(value ?? "")
.replaceAll("%", "%25")
.replaceAll("\r", "%0D")
.replaceAll("\n", "%0A");
const escapeCommandProperty = (value) =>
escapeCommandData(value)
.replaceAll(":", "%3A")
.replaceAll(",", "%2C");
const rows = (findings ?? []).map((finding) => {
const location = String(finding.location ?? "");
const [file, line] = location.split(":");
const repoFile = file ? `apps/ios/${file}` : "";
return {
file: repoFile,
line: line || "",
kind: String(finding.kind ?? ""),
name: String(finding.name ?? ""),
};
});
for (const row of rows) {
if (!row.file) continue;
const line = row.line ? `,line=${escapeCommandProperty(row.line)}` : "";
const title = `${row.kind || "Unused code"} ${row.name}`.trim();
console.log(`::error file=${escapeCommandProperty(row.file)}${line},title=Dead Swift code::${escapeCommandData(title)}`);
}
let shouldFail = "1";
let summary = "";
if (findings === null) {
summary = [
"### iOS Periphery",
"",
"Periphery did not complete. Check the workflow artifact for stdout/stderr.",
].join("\n");
} else if (rows.length === 0 && status === 0) {
shouldFail = "0";
summary = [
"### iOS Periphery",
"",
"No dead Swift code found.",
].join("\n");
} else if (rows.length > 0) {
summary = [
"### iOS Periphery",
"",
`Found ${rows.length} dead Swift code ${rows.length === 1 ? "symbol" : "symbols"}. See the PR comment or workflow artifact for details.`,
].join("\n");
} else {
summary = [
"### iOS Periphery",
"",
"Periphery exited with a non-zero status before producing findings. Check the workflow artifact for stdout/stderr.",
].join("\n");
}
fs.writeFileSync(path.join(outputDir, "should-fail.txt"), `${shouldFail}\n`);
fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, `${summary.trim()}\n`);
NODE
- name: Upload Periphery report
if: always()
uses: actions/upload-artifact@v7
with:
name: ios-periphery-dead-code-${{ github.run_id }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/ios-periphery
if-no-files-found: warn
retention-days: 14
- name: Fail on dead code
run: |
set -euo pipefail
test "$(cat "$RUNNER_TEMP/ios-periphery/should-fail.txt")" = "0"

2
.gitignore vendored
View File

@@ -127,8 +127,6 @@ mantis/
!.agents/skills/clawdtributor/**
!.agents/skills/control-ui-e2e/
!.agents/skills/control-ui-e2e/**
!.agents/skills/discord-user-post/
!.agents/skills/discord-user-post/**
!.agents/skills/gitcrawl/
!.agents/skills/gitcrawl/**
!.agents/skills/technical-documentation/

View File

@@ -30,9 +30,6 @@ Docs: https://docs.openclaw.ai
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
- macOS Peekaboo bridge: update the embedded Peekaboo package to 3.5.2 and route bundled-skill CLI commands through the OpenClaw app bridge so they inherit its Screen Recording and Accessibility grants.
- Agent routing: route subagent RPC callbacks addressed to an agent-shaped `--to` target to the correct session key instead of falling back to the main session, so WeChat (and other channel) session-key callbacks reach the intended subagent session. (#90231) Thanks @zhangguiping-xydt.
- QQBot delivery: keep markdown table chunks self-contained across message boundaries by preserving table state across block deliveries, flushing unfinished table-row fragments as plain text, and detecting short pipe-terminated rows by column count so split rows are not sent as malformed markdown. (#92428) Thanks @sliverp.
## 2026.6.6

View File

@@ -138,7 +138,7 @@ ARG OPENCLAW_BUNDLED_PLUGIN_DIR
# BuildKit cache mounts are not part of cached layers; seed tarballs for the
# installed prod graph in the same step that runs offline prune.
RUN --mount=type=cache,id=openclaw-pnpm-store,target=/root/.local/share/pnpm/store,sharing=locked \
node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
pnpm list --prod --depth Infinity --json | node scripts/list-prod-store-packages.mjs | xargs -r pnpm store add && \
CI=true pnpm prune --prod \
--config.offline=true \
--config.supportedArchitectures.os=linux \

View File

@@ -1,18 +0,0 @@
project: OpenClaw.xcodeproj
schemes:
- OpenClaw
retain_codable_properties: true
retain_swift_ui_previews: true
retain_objc_accessible: true
retain_unused_protocol_func_params: true
retain_assign_only_properties: true
relative_results: true
disable_update_check: true
report_include:
- Sources/**
- ShareExtension/**
- ActivityWidget/**
- WatchExtension/Sources/**
build_arguments:
- -destination
- generic/platform=iOS Simulator

View File

@@ -202,4 +202,10 @@ final class ContactsService: ContactsServicing {
phoneNumbers: contact.phoneNumbers.map(\.value.stringValue),
emails: contact.emailAddresses.map { String($0.value) })
}
#if DEBUG
static func _test_matches(contact: CNContact, phoneNumbers: [String], emails: [String]) -> Bool {
self.matchContacts(contacts: [contact], phoneNumbers: phoneNumbers, emails: emails) != nil
}
#endif
}

View File

@@ -1,4 +1,5 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
extension AgentProTab {

View File

@@ -1,10 +1,12 @@
import OpenClawKit
import OpenClawProtocol
import SwiftUI
struct AgentProTab: View {
@Environment(NodeAppModel.self) var appModel
@Environment(\.colorScheme) var colorScheme
@Environment(\.scenePhase) var scenePhase
let initialRoute: AgentRoute?
let directRoute: AgentRoute?
let headerLeadingAction: OpenClawSidebarHeaderAction?
let headerTitle: String
@@ -125,11 +127,13 @@ struct AgentProTab: View {
}
init(
initialRoute: AgentRoute? = nil,
directRoute: AgentRoute? = nil,
headerLeadingAction: OpenClawSidebarHeaderAction? = nil,
headerTitle: String = "Agents",
openSettings: (() -> Void)? = nil)
{
self.initialRoute = initialRoute
self.directRoute = directRoute
self.headerLeadingAction = headerLeadingAction
self.headerTitle = headerTitle
@@ -180,6 +184,9 @@ struct AgentProTab: View {
self.destination(for: route)
}
}
.onAppear {
self.applyInitialRouteIfNeeded()
}
}
private func directDestination(for route: AgentRoute) -> some View {
@@ -188,4 +195,11 @@ struct AgentProTab: View {
self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden,
for: .navigationBar)
}
private func applyInitialRouteIfNeeded() {
guard self.directRoute == nil else { return }
guard let initialRoute else { return }
guard self.navigationPath != [initialRoute] else { return }
self.navigationPath = [initialRoute]
}
}

View File

@@ -185,3 +185,33 @@ struct CommandEmptyStateRow: View {
}
}
}
struct CommandTaskRow: View {
let item: CommandCenterTab.WorkItem
var body: some View {
HStack(alignment: .center, spacing: 6) {
Text(self.item.title)
.font(.footnote.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.80)
.frame(maxWidth: .infinity, minHeight: 20, alignment: .leading)
Text(self.item.detail)
.font(.caption.weight(.medium))
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.78)
.frame(width: 64, alignment: .leading)
if let progress = self.item.progress {
ProProgressBar(progress: progress, color: self.item.color)
.frame(width: 56)
}
Text(self.item.state)
.font(.footnote.weight(.medium))
.foregroundStyle(self.item.progress == nil ? self.item.color : .secondary)
.lineLimit(1)
.frame(width: self.item.progress == nil ? 58 : 34, alignment: .trailing)
}
.padding(.vertical, 8)
}
}

View File

@@ -213,6 +213,32 @@ struct IPadSkillWorkshopScreen: View {
}
}
private var statusMenu: some View {
HStack(spacing: 8) {
Text("Status")
.font(.caption.weight(.semibold))
.foregroundStyle(.secondary)
Menu {
ForEach(Self.proposalStatusFilters, id: \.self) { filter in
Button(Self.proposalStatusFilterLabel(filter)) {
self.statusFilter = filter
}
}
} label: {
HStack(spacing: 6) {
Text(self.statusFilterLabel)
.font(.subheadline.weight(.semibold))
Image(systemName: "chevron.up.chevron.down")
.font(.caption2.weight(.bold))
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
.buttonStyle(.bordered)
.controlSize(.small)
.tint(self.neutralControlTint)
}
}
private var agentScopeMenu: some View {
HStack(spacing: 8) {
Text("Agent")
@@ -1104,6 +1130,7 @@ struct IPadSkillProposalRecord: Decodable {
let description: String
let createdAt: String
let updatedAt: String
let proposedVersion: String
let target: IPadSkillProposalTarget
}

View File

@@ -47,6 +47,13 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
enum OpenClawBrand {
static let lightCanvasTop = Color(red: 246 / 255.0, green: 247 / 255.0, blue: 249 / 255.0)
static let lightCanvasMiddle = Color(red: 250 / 255.0, green: 251 / 255.0, blue: 252 / 255.0)
static let lightCanvasBottom = Color.white
static let darkCanvasTop = Color(red: 3 / 255.0, green: 7 / 255.0, blue: 7 / 255.0)
static let darkCanvasMiddle = Color(red: 13 / 255.0, green: 17 / 255.0, blue: 17 / 255.0)
static let darkCanvasBottom = Color(red: 17 / 255.0, green: 18 / 255.0, blue: 20 / 255.0)
static let accent = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
@@ -74,6 +81,11 @@ enum OpenClawBrand {
? UIColor(red: 34 / 255.0, green: 36 / 255.0, blue: 39 / 255.0, alpha: 1)
: UIColor.white
})
static let graphiteSoft = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 148 / 255.0, green: 163 / 255.0, blue: 184 / 255.0, alpha: 1)
: UIColor(red: 102 / 255.0, green: 112 / 255.0, blue: 133 / 255.0, alpha: 1)
})
static var sheetBackground: LinearGradient {
LinearGradient(
@@ -85,6 +97,40 @@ enum OpenClawBrand {
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static var toolbarChrome: LinearGradient {
LinearGradient(
colors: [
graphiteElevated.opacity(0.92),
graphite.opacity(0.78),
],
startPoint: .topLeading,
endPoint: .bottomTrailing)
}
static func glassFill(brighten: Bool) -> Color {
Color.black.opacity(brighten ? 0.10 : 0.22)
}
static func glassStroke(brighten: Bool, increasedContrast: Bool, active: Bool = false) -> Color {
if active {
return self.accent.opacity(increasedContrast ? 0.70 : 0.46)
}
return Color.white.opacity(increasedContrast ? 0.50 : (brighten ? 0.24 : 0.16))
}
static func formSectionHeader(_ title: String) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(self.accent)
.textCase(.uppercase)
}
static func canvasColors(for colorScheme: ColorScheme) -> [Color] {
colorScheme == .dark
? [self.darkCanvasTop, self.darkCanvasMiddle, self.darkCanvasBottom]
: [self.lightCanvasTop, self.lightCanvasMiddle, self.lightCanvasBottom]
}
}
extension View {

View File

@@ -5,6 +5,7 @@ enum OpenClawProMetric {
static let cardRadius: CGFloat = 10
static let controlRadius: CGFloat = 8
static let bottomScrollInset: CGFloat = 96
static let heroRadius: CGFloat = 12
}
struct OpenClawProBackground: View {
@@ -249,6 +250,13 @@ struct OpenClawSidebarRevealButton: View {
self.headerAction = action
}
init(action: @escaping () -> Void) {
self.headerAction = OpenClawSidebarHeaderAction(
systemName: "sidebar.left",
accessibilityLabel: "Show Sidebar",
action: action)
}
var body: some View {
let button = Button(action: self.headerAction.action) {
Image(systemName: self.headerAction.systemName)
@@ -422,6 +430,46 @@ struct ProProgressBar: View {
}
}
struct ProWorkRow: View {
let icon: String
let title: String
let detail: String
let state: String
let trailing: String
let color: Color
var progress: Double?
var body: some View {
HStack(alignment: .top, spacing: 12) {
ProIconBadge(systemName: self.icon, color: self.color)
VStack(alignment: .leading, spacing: 5) {
HStack(alignment: .firstTextBaseline) {
Text(self.title)
.font(.subheadline.weight(.semibold))
Spacer(minLength: 8)
Text(self.trailing)
.font(.caption2)
.foregroundStyle(.secondary)
}
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
HStack(spacing: 8) {
if let progress {
ProProgressBar(progress: progress, color: self.color)
.frame(maxWidth: 120)
}
Text(self.state)
.font(.caption2.weight(.semibold))
.foregroundStyle(self.color)
}
}
}
.padding(.vertical, 9)
}
}
struct ProCapsule: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
@@ -505,6 +553,94 @@ struct OpenClawGatewayCompactPill: View {
}
}
struct ProSegmentedControl: View {
@Environment(\.colorScheme) private var colorScheme
let labels: [String]
@Binding var selection: Int
var body: some View {
HStack(spacing: 4) {
ForEach(Array(self.labels.enumerated()), id: \.offset) { index, label in
Button {
self.selection = index
} label: {
Text(label)
.font(.subheadline.weight(self.selection == index ? .semibold : .regular))
.frame(maxWidth: .infinity)
.padding(.vertical, 9)
.background(self.segmentFill(isSelected: self.selection == index), in: Capsule())
}
.buttonStyle(.plain)
}
}
.padding(4)
.background {
Capsule()
.fill(self.trackFill)
.overlay {
Capsule().strokeBorder(self.trackStroke, lineWidth: 1)
}
}
}
private func segmentFill(isSelected: Bool) -> Color {
guard isSelected else { return .clear }
return self.colorScheme == .dark ? Color.white.opacity(0.12) : Color.primary.opacity(0.08)
}
private var trackFill: Color {
self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.72)
}
private var trackStroke: Color {
self.colorScheme == .dark ? Color.white.opacity(0.10) : Color.black.opacity(0.06)
}
}
struct ProHeroActionButton: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
let detail: String
let systemImage: String
let action: () -> Void
var body: some View {
Button(action: self.action) {
HStack(spacing: 12) {
Image(systemName: self.systemImage)
.font(.headline.weight(.semibold))
.foregroundStyle(.white)
.frame(width: 42, height: 42)
.background(OpenClawBrand.accentHot, in: RoundedRectangle(cornerRadius: 13, style: .continuous))
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer(minLength: 8)
Image(systemName: "arrow.right")
.font(.subheadline.weight(.bold))
.foregroundStyle(OpenClawBrand.accentHot)
}
.padding(12)
.proGlassSurface(
fill: self.colorScheme == .dark ? Color.white.opacity(0.045) : Color.white.opacity(0.68),
stroke: OpenClawBrand.accent.opacity(self.colorScheme == .dark ? 0.22 : 0.14),
radius: 18,
isProminent: true,
interactive: true)
}
.buttonStyle(.plain)
}
}
struct ProMetricTile: View {
@Environment(\.colorScheme) private var colorScheme
let title: String
@@ -659,3 +795,24 @@ struct ProStatusRow: View {
.padding(.vertical, 10)
}
}
struct ProTimelineRow: View {
let done: Bool
let title: String
let detail: String
var body: some View {
HStack(alignment: .top, spacing: 10) {
ProIconBadge(
systemName: self.done ? "checkmark.circle.fill" : "clock.fill",
color: self.done ? OpenClawBrand.ok : OpenClawBrand.warn)
VStack(alignment: .leading, spacing: 3) {
Text(self.title)
.font(.subheadline.weight(.medium))
Text(self.detail)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}

View File

@@ -0,0 +1,3 @@
import SwiftUI
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.

View File

@@ -332,6 +332,65 @@ struct SettingsChannelsDestination: View {
}
}
struct SettingsChannelsScreen: View {
let headerLeadingAction: OpenClawSidebarHeaderAction?
let gatewayAction: (() -> Void)?
init(headerLeadingAction: OpenClawSidebarHeaderAction? = nil, gatewayAction: (() -> Void)? = nil) {
self.headerLeadingAction = headerLeadingAction
self.gatewayAction = gatewayAction
}
var body: some View {
ZStack {
OpenClawProBackground()
ScrollView {
VStack(alignment: .leading, spacing: 14) {
self.header
SettingsChannelsDestination(showsSummaryCard: false)
}
.padding(.top, 18)
.padding(.bottom, OpenClawProMetric.bottomScrollInset)
}
}
.navigationTitle("Channels")
.navigationBarTitleDisplayMode(.inline)
}
private var header: some View {
HStack(alignment: .top, spacing: 12) {
if let headerLeadingAction {
OpenClawSidebarHeaderLeadingSlot(action: headerLeadingAction)
}
VStack(alignment: .leading, spacing: 5) {
Text("Channels / Integrations")
.font(.title3.weight(.semibold))
Text("Message routing and external channel clients.")
.font(.callout)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
Spacer(minLength: 8)
self.gatewayPill
}
.padding(.horizontal, OpenClawProMetric.pagePadding)
}
@ViewBuilder
private var gatewayPill: some View {
if let gatewayAction {
Button(action: gatewayAction) {
OpenClawGatewayCompactPill()
}
.buttonStyle(.plain)
.accessibilityHint("Opens Settings / Gateway")
} else {
OpenClawGatewayCompactPill()
}
}
}
private struct SettingsChannelRow: View {
let entry: SettingsChannelEntry
let canAdmin: Bool

View File

@@ -139,6 +139,15 @@ extension SettingsProTab {
await self.gatewayController.connectLastKnown()
}
func refreshGateway() async {
guard !self.isRefreshingGateway else { return }
self.isRefreshingGateway = true
defer { self.isRefreshingGateway = false }
self.gatewayController.refreshActiveGatewayRegistrationFromSettings()
self.gatewayController.restartDiscovery()
await self.appModel.refreshGatewayOverviewIfConnected()
}
@MainActor
func runDiagnostics() async {
guard !self.isRefreshingGateway else { return }
@@ -191,7 +200,7 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port) else { return }
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
self.setupStatusText = "Setup code applied. Connecting..."
await self.connectManual()
}
@@ -289,7 +298,7 @@ extension SettingsProTab {
self.setupStatusText = "Failed: invalid port"
return
}
guard await self.preflightGateway(host: host, port: port) else { return }
guard await self.preflightGateway(host: host, port: port, useTLS: self.manualGatewayTLS) else { return }
await self.connectManual()
}
@@ -318,7 +327,7 @@ extension SettingsProTab {
authOverride: authOverride)
}
func preflightGateway(host: String, port: Int) async -> Bool {
func preflightGateway(host: String, port: Int, useTLS: Bool) async -> Bool {
let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return false }
if Self.isTailnetHostOrIP(trimmed), !Self.hasTailnetIPv4() {

View File

@@ -3,6 +3,7 @@ import Contacts
import CoreLocation
import CoreMotion
import CryptoKit
import Darwin
import EventKit
import Foundation
import Network
@@ -168,6 +169,11 @@ final class GatewayConnectionController {
}
}
func allowAutoConnectAgain() {
self.didAutoConnect = false
self.maybeAutoConnect()
}
func restartDiscovery() {
self.discovery.stop()
self.didAutoConnect = false
@@ -516,7 +522,8 @@ final class GatewayConnectionController {
let stableID = self.manualStableID(host: manualHost, port: resolvedPort)
let tlsParams = self.resolveManualTLSParams(
stableID: stableID,
tlsEnabled: resolvedUseTLS)
tlsEnabled: resolvedUseTLS,
allowTOFUReset: self.shouldRequireTLS(host: manualHost))
guard let url = self.buildGatewayURL(
host: manualHost,
@@ -712,7 +719,8 @@ final class GatewayConnectionController {
}
private func resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
let stableID = gateway.stableID
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
@@ -739,7 +747,8 @@ final class GatewayConnectionController {
private func resolveManualTLSParams(
stableID: String,
tlsEnabled: Bool) -> GatewayTLSParams?
tlsEnabled: Bool,
allowTOFUReset: Bool = false) -> GatewayTLSParams?
{
let stored = GatewayTLSStore.loadFingerprint(stableID: stableID)
if tlsEnabled || stored != nil {
@@ -776,6 +785,126 @@ final class GatewayConnectionController {
resolver.start()
}
}
private func resolveHostPortFromBonjourEndpoint(_ endpoint: NWEndpoint) async -> (host: String, port: Int)? {
switch endpoint {
case let .hostPort(host, port):
(host: host.debugDescription, port: Int(port.rawValue))
case let .service(name, type, domain, _):
await Self.resolveBonjourServiceToHostPort(name: name, type: type, domain: domain)
default:
nil
}
}
private static func resolveBonjourServiceToHostPort(
name: String,
type: String,
domain: String,
timeoutSeconds: TimeInterval = 3.0) async -> (host: String, port: Int)?
{
// NetService callbacks are delivered via a run loop. If we resolve from a thread without one,
// we can end up never receiving callbacks, which in turn leaks the continuation and leaves
// the UI stuck "connecting". Keep the whole lifecycle on the main run loop and always
// resume the continuation exactly once (timeout/cancel safe).
@MainActor
final class Resolver: NSObject, @preconcurrency NetServiceDelegate {
private var cont: CheckedContinuation<(host: String, port: Int)?, Never>?
private let service: NetService
private var timeoutTask: Task<Void, Never>?
private var finished = false
init(cont: CheckedContinuation<(host: String, port: Int)?, Never>, service: NetService) {
self.cont = cont
self.service = service
super.init()
}
func start(timeoutSeconds: TimeInterval) {
self.service.delegate = self
self.service.schedule(in: .main, forMode: .default)
// NetService has its own timeout, but we keep a manual one as a backstop in case
// callbacks never arrive (e.g. local network permission issues).
self.timeoutTask = Task { @MainActor [weak self] in
guard let self else { return }
let ns = UInt64(max(0.1, timeoutSeconds) * 1_000_000_000)
try? await Task.sleep(nanoseconds: ns)
self.finish(nil)
}
self.service.resolve(withTimeout: timeoutSeconds)
}
func netServiceDidResolveAddress(_ sender: NetService) {
self.finish(Self.extractHostPort(sender))
}
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
_ = errorDict // currently best-effort; callers surface a generic failure
self.finish(nil)
}
private func finish(_ result: (host: String, port: Int)?) {
guard !self.finished else { return }
self.finished = true
self.timeoutTask?.cancel()
self.timeoutTask = nil
self.service.stop()
self.service.remove(from: .main, forMode: .default)
let c = self.cont
self.cont = nil
c?.resume(returning: result)
}
private static func extractHostPort(_ svc: NetService) -> (host: String, port: Int)? {
let port = svc.port
if let host = svc.hostName?.trimmingCharacters(in: .whitespacesAndNewlines), !host.isEmpty {
return (host: host, port: port)
}
guard let addrs = svc.addresses else { return nil }
for addrData in addrs {
let host = addrData.withUnsafeBytes { ptr -> String? in
guard let base = ptr.baseAddress, !ptr.isEmpty else { return nil }
var buffer = [CChar](repeating: 0, count: Int(NI_MAXHOST))
let rc = getnameinfo(
base.assumingMemoryBound(to: sockaddr.self),
socklen_t(ptr.count),
&buffer,
socklen_t(buffer.count),
nil,
0,
NI_NUMERICHOST)
guard rc == 0 else { return nil }
let bytes = buffer.prefix { $0 != 0 }.map { UInt8(bitPattern: $0) }
return String(bytes: bytes, encoding: .utf8)
}
if let host, !host.isEmpty {
return (host: host, port: port)
}
}
return nil
}
}
return await withCheckedContinuation { cont in
Task { @MainActor in
let service = NetService(domain: domain, type: type, name: name)
let resolver = Resolver(cont: cont, service: service)
// Keep the resolver alive for the lifetime of the NetService resolve.
objc_setAssociatedObject(service, "resolver", resolver, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
resolver.start(timeoutSeconds: timeoutSeconds)
}
}
}
}
extension GatewayConnectionController {
@@ -1033,10 +1162,30 @@ extension GatewayConnectionController {
self.currentCommands()
}
func _test_currentPermissions() async -> [String: Bool] {
await self.currentPermissions()
}
static func _test_isLocationAvailable(servicesEnabled: Bool, status: CLAuthorizationStatus) -> Bool {
self.isLocationAvailable(servicesEnabled: servicesEnabled, status: status)
}
func _test_platformString() -> String {
DeviceInfoHelper.platformString()
}
func _test_deviceFamily() -> String {
DeviceInfoHelper.deviceFamily()
}
func _test_modelIdentifier() -> String {
DeviceInfoHelper.modelIdentifier()
}
func _test_appVersion() -> String {
DeviceInfoHelper.appVersion()
}
func _test_setGateways(_ gateways: [GatewayDiscoveryModel.DiscoveredGateway]) {
self.gateways = gateways
}
@@ -1050,9 +1199,10 @@ extension GatewayConnectionController {
}
func _test_resolveDiscoveredTLSParams(
gateway: GatewayDiscoveryModel.DiscoveredGateway) -> GatewayTLSParams?
gateway: GatewayDiscoveryModel.DiscoveredGateway,
allowTOFU: Bool) -> GatewayTLSParams?
{
self.resolveDiscoveredTLSParams(gateway: gateway)
self.resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: allowTOFU)
}
func _test_resolveManualUseTLS(host: String, useTLS: Bool) -> Bool {

View File

@@ -9,6 +9,11 @@ enum GatewaySettingsStore {
private static let instanceIdDefaultsKey = "node.instanceId"
private static let preferredGatewayStableIDDefaultsKey = "gateway.preferredStableID"
private static let lastDiscoveredGatewayStableIDDefaultsKey = "gateway.lastDiscoveredStableID"
private static let manualEnabledDefaultsKey = "gateway.manual.enabled"
private static let manualHostDefaultsKey = "gateway.manual.host"
private static let manualPortDefaultsKey = "gateway.manual.port"
private static let manualTlsDefaultsKey = "gateway.manual.tls"
private static let discoveryDebugLogsDefaultsKey = "gateway.discovery.debugLogs"
private static let lastGatewayKindDefaultsKey = "gateway.last.kind"
private static let lastGatewayHostDefaultsKey = "gateway.last.host"
private static let lastGatewayPortDefaultsKey = "gateway.last.port"
@@ -179,6 +184,24 @@ enum GatewaySettingsStore {
enum LastGatewayConnection: Equatable {
case manual(host: String, port: Int, useTLS: Bool, stableID: String)
case discovered(stableID: String, useTLS: Bool)
var stableID: String {
switch self {
case let .manual(_, _, _, stableID):
stableID
case let .discovered(stableID, _):
stableID
}
}
var useTLS: Bool {
switch self {
case let .manual(_, _, useTLS, _):
useTLS
case let .discovered(_, useTLS):
useTLS
}
}
}
private enum LastGatewayKind: String, Codable {
@@ -206,6 +229,17 @@ enum GatewaySettingsStore {
return nil
}
static func saveTalkProviderApiKey(_ apiKey: String?, provider: String) {
guard let providerId = self.normalizedTalkProviderID(provider) else { return }
let account = self.talkProviderApiKeyAccount(providerId: providerId)
let trimmed = apiKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
if trimmed.isEmpty {
_ = KeychainStore.delete(service: self.talkService, account: account)
return
}
_ = KeychainStore.saveString(trimmed, service: self.talkService, account: account)
}
static func saveLastGatewayConnectionManual(host: String, port: Int, useTLS: Bool, stableID: String) {
let payload = LastGatewayConnectionData(
kind: .manual, stableID: stableID, useTLS: useTLS, host: host, port: port)
@@ -546,4 +580,11 @@ enum GatewayDiagnostics {
}
}
}
static func reset() {
guard let url = fileURL else { return }
self.queue.async {
try? FileManager.default.removeItem(at: url)
}
}
}

View File

@@ -17,6 +17,15 @@ final class LiveActivityManager {
self.hydrateCurrentAndPruneDuplicates()
}
var isActive: Bool {
guard let activity = self.currentActivity else { return false }
guard activity.activityState == .active else {
self.currentActivity = nil
return false
}
return true
}
func showConnecting(statusText: String = "Connecting...", agentName: String, sessionKey: String) {
self.hydrateCurrentAndPruneDuplicates()
@@ -87,6 +96,10 @@ final class LiveActivityManager {
self.endActivity(reason: "connected")
}
func handleDisconnect() {
self.endActivity(reason: "disconnected")
}
func endActivity(reason: String) {
guard let activity = self.currentActivity else { return }
self.currentActivity = nil
@@ -170,6 +183,15 @@ final class LiveActivityManager {
startedAt: self.activityStartDate)
}
private func idleState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Idle",
isIdle: true,
isDisconnected: false,
isConnecting: false,
startedAt: self.activityStartDate)
}
private func disconnectedState() -> OpenClawActivityAttributes.ContentState {
OpenClawActivityAttributes.ContentState(
statusText: "Disconnected",

View File

@@ -14,3 +14,39 @@ struct OpenClawActivityAttributes: ActivityAttributes {
var startedAt: Date
}
}
#if DEBUG
extension OpenClawActivityAttributes {
static let preview = OpenClawActivityAttributes(agentName: "main", sessionKey: "main")
}
extension OpenClawActivityAttributes.ContentState {
static let connecting = OpenClawActivityAttributes.ContentState(
statusText: "Connecting...",
isIdle: false,
isDisconnected: false,
isConnecting: true,
startedAt: .now)
static let idle = OpenClawActivityAttributes.ContentState(
statusText: "Idle",
isIdle: true,
isDisconnected: false,
isConnecting: false,
startedAt: .now)
static let disconnected = OpenClawActivityAttributes.ContentState(
statusText: "Disconnected",
isIdle: false,
isDisconnected: true,
isConnecting: false,
startedAt: .now)
static let attention = OpenClawActivityAttributes.ContentState(
statusText: "Approval needed",
isIdle: false,
isDisconnected: false,
isConnecting: false,
startedAt: .now)
}
#endif

View File

@@ -12,6 +12,8 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
private let manager = CLLocationManager()
private var authContinuation: CheckedContinuation<CLAuthorizationStatus, Never>?
private var locationContinuation: CheckedContinuation<CLLocation, Swift.Error>?
private var updatesContinuation: AsyncStream<CLLocation>.Continuation?
private var isStreaming = false
private var significantLocationCallback: (@Sendable (CLLocation) -> Void)?
private var isMonitoringSignificantChanges = false
@@ -82,6 +84,42 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
try await AsyncTimeout.withTimeoutMs(timeoutMs: timeoutMs, onTimeout: { Error.timeout }, operation: operation)
}
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
{
self.stopLocationUpdates()
self.manager.desiredAccuracy = LocationCurrentRequest.accuracyValue(desiredAccuracy)
self.manager.pausesLocationUpdatesAutomatically = true
self.manager.allowsBackgroundLocationUpdates = true
self.isStreaming = true
if significantChangesOnly {
self.manager.startMonitoringSignificantLocationChanges()
} else {
self.manager.startUpdatingLocation()
}
return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
self.updatesContinuation = continuation
continuation.onTermination = { @Sendable _ in
Task { @MainActor in
self.stopLocationUpdates()
}
}
}
}
func stopLocationUpdates() {
guard self.isStreaming else { return }
self.isStreaming = false
self.manager.stopUpdatingLocation()
self.manager.stopMonitoringSignificantLocationChanges()
self.updatesContinuation?.finish()
self.updatesContinuation = nil
}
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void) {
self.significantLocationCallback = onUpdate
guard !self.isMonitoringSignificantChanges else { return }
@@ -89,6 +127,13 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
self.manager.startMonitoringSignificantLocationChanges()
}
func stopMonitoringSignificantLocationChanges() {
guard self.isMonitoringSignificantChanges else { return }
self.isMonitoringSignificantChanges = false
self.significantLocationCallback = nil
self.manager.stopMonitoringSignificantLocationChanges()
}
nonisolated func locationManagerDidChangeAuthorization(_ manager: CLLocationManager) {
let status = manager.authorizationStatus
Task { @MainActor in
@@ -116,6 +161,9 @@ final class LocationService: NSObject, CLLocationManagerDelegate, LocationServic
if let callback = self.significantLocationCallback, let latest = locs.last {
callback(latest)
}
if let latest = locs.last, let updates = self.updatesContinuation {
updates.yield(latest)
}
}
}

View File

@@ -133,6 +133,7 @@ final class NodeAppModel {
self.lastGatewayProblem?.statusText ?? self.gatewayStatusText
}
var seamColorHex: String?
private var mainSessionBaseKey: String = "main"
private var focusedChatSessionKey: String?
var selectedAgentId: String?
@@ -201,6 +202,9 @@ final class NodeAppModel {
private var apnsDeviceTokenHex: String?
private var apnsLastRegisteredTokenHex: String?
@ObservationIgnored private let pushRegistrationManager = PushRegistrationManager()
var gatewaySession: GatewayNodeSession {
self.nodeGateway
}
var operatorSession: GatewayNodeSession {
self.operatorGateway
@@ -720,6 +724,11 @@ final class NodeAppModel {
}
}
var seamColor: Color {
Self.color(fromHex: self.seamColorHex) ?? Self.defaultSeamColor
}
private static let defaultSeamColor = Color(red: 79 / 255.0, green: 122 / 255.0, blue: 154 / 255.0)
private static let apnsDeviceTokenUserDefaultsKey = "push.apns.deviceTokenHex"
private static let deepLinkKeyUserDefaultsKey = "deeplink.agent.key"
private static let canvasUnattendedDeepLinkKey: String = NodeAppModel.generateDeepLinkKey()
@@ -729,9 +738,12 @@ final class NodeAppModel {
let res = try await self.operatorGateway.request(method: "config.get", paramsJSON: "{}", timeoutSeconds: 8)
guard let json = try JSONSerialization.jsonObject(with: res) as? [String: Any] else { return }
guard let config = json["config"] as? [String: Any] else { return }
let ui = config["ui"] as? [String: Any]
let raw = (ui?["seamColor"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let session = config["session"] as? [String: Any]
let mainKey = SessionKey.normalizeMainKey(session?["mainKey"] as? String)
await MainActor.run {
self.seamColorHex = raw.isEmpty ? nil : raw
self.mainSessionBaseKey = mainKey
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.homeCanvasRevision &+= 1
@@ -1936,7 +1948,7 @@ extension NodeAppModel {
}
self.activeGatewayConnectConfig = nextConfig
self.prepareForGatewayConnect(stableID: effectiveStableID)
self.prepareForGatewayConnect(url: url, stableID: effectiveStableID)
if operatorLoopRequired {
self.startOperatorGatewayLoop(
url: url,
@@ -2033,6 +2045,7 @@ extension NodeAppModel {
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
ShareGatewayRelaySettings.clearConfig()
@@ -2041,7 +2054,7 @@ extension NodeAppModel {
}
extension NodeAppModel {
private func prepareForGatewayConnect(stableID: String) {
private func prepareForGatewayConnect(url: URL, stableID: String) {
self.isAppleReviewDemoModeEnabled = false
self.gatewayAutoReconnectEnabled = true
self.gatewayPairingPaused = false
@@ -2645,6 +2658,7 @@ extension NodeAppModel {
self.gatewayConnected = false
self.setOperatorConnected(false)
self.talkMode.updateGatewayConnected(false)
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.talkMode.updateMainSessionKey(self.mainSessionKey)
self.showLocalCanvasOnDisconnect()
@@ -2817,6 +2831,7 @@ extension NodeAppModel {
self.talkMode.updateGatewayConnected(false)
self.talkMode.setEnabled(false)
self.talkMode.statusText = "Demo mode only"
self.seamColorHex = nil
self.mainSessionBaseKey = "main"
self.selectedAgentId = nil
self.gatewayDefaultAgentId = "main"
@@ -2931,6 +2946,14 @@ extension NodeAppModel {
self.refreshLastShareEventFromRelay()
}
func reloadTalkConfig() {
Task { [weak self] in
guard let self else { return }
await self.talkMode.reloadConfig()
await self.talkMode.prefetchRealtimeSessionIfReady(reason: "config_reload")
}
}
/// Back-compat hook retained for older gateway-connect flows.
func onNodeGatewayConnected() async {
await self.registerAPNsTokenIfNeeded()
@@ -3891,6 +3914,32 @@ extension NodeAppModel {
}
}
func handleExecApprovalNotificationDecision(
approvalId: String,
decision: String) async
{
let normalizedApprovalID = approvalId.trimmingCharacters(in: .whitespacesAndNewlines)
guard !normalizedApprovalID.isEmpty else { return }
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = true
self.pendingExecApprovalPromptErrorText = nil
}
let outcome = await self.resolveExecApprovalNotificationDecision(
approvalId: normalizedApprovalID,
decision: decision)
switch outcome {
case .resolved, .stale, .unavailable:
break
case let .failed(message):
if self.pendingExecApprovalPrompt?.id == normalizedApprovalID {
self.pendingExecApprovalPromptResolving = false
self.pendingExecApprovalPromptErrorText = message
}
}
}
private func resolveExecApprovalNotificationDecision(
approvalId: String,
decision: String,
@@ -4427,6 +4476,17 @@ extension NodeAppModel {
self.talkMode.updateMainSessionKey(self.mainSessionKey)
}
private static func color(fromHex raw: String?) -> Color? {
let trimmed = (raw ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
let hex = trimmed.hasPrefix("#") ? String(trimmed.dropFirst()) : trimmed
guard hex.count == 6, let value = Int(hex, radix: 16) else { return nil }
let r = Double((value >> 16) & 0xFF) / 255.0
let g = Double((value >> 8) & 0xFF) / 255.0
let b = Double(value & 0xFF) / 255.0
return Color(red: r, green: g, blue: b)
}
func approvePendingAgentDeepLinkPrompt() async {
guard let prompt = self.pendingAgentDeepLinkPrompt else { return }
self.pendingAgentDeepLinkPrompt = nil
@@ -4576,10 +4636,30 @@ extension NodeAppModel {
try self.encodePayload(obj)
}
func _test_isCameraEnabled() -> Bool {
self.isCameraEnabled()
}
func _test_triggerCameraFlash() {
self.triggerCameraFlash()
}
func _test_showCameraHUD(text: String, kind: CameraHUDKind, autoHideSeconds: Double? = nil) {
self.showCameraHUD(text: text, kind: kind, autoHideSeconds: autoHideSeconds)
}
func _test_handleCanvasA2UIAction(body: [String: Any]) async {
await self.handleCanvasA2UIAction(body: body)
}
func _test_showLocalCanvasOnDisconnect() {
self.showLocalCanvasOnDisconnect()
}
func _test_applyTalkModeSync(enabled: Bool, phase: String? = nil) {
self.applyTalkModeSync(enabled: enabled, phase: phase)
}
func _test_queuedWatchReplyCount() -> Int {
self.watchReplyCoordinator.queuedCount
}

View File

@@ -0,0 +1,365 @@
import Foundation
import OpenClawKit
import SwiftUI
struct GatewayOnboardingView: View {
var body: some View {
NavigationStack {
List {
Section {
Text("Connect to your gateway to get started.")
.foregroundStyle(.secondary)
}
Section {
NavigationLink("Auto detect") {
AutoDetectStep()
}
NavigationLink("Manual entry") {
ManualEntryStep()
}
}
}
.navigationTitle("Connect Gateway")
}
.gatewayTrustPromptAlert()
}
}
private struct AutoDetectStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@AppStorage("gateway.preferredStableID") private var preferredGatewayStableID: String = ""
@AppStorage("gateway.lastDiscoveredStableID") private var lastDiscoveredGatewayStableID: String = ""
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section {
Text("Well scan for gateways on your network and connect automatically when we find one.")
.foregroundStyle(.secondary)
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button("Retry") {
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.triggerAutoConnect()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Auto detect")
.onAppear { self.triggerAutoConnect() }
.onChange(of: self.gatewayController.gateways) { _, _ in
self.triggerAutoConnect()
}
}
private func triggerAutoConnect() {
guard self.appModel.gatewayServerName == nil else { return }
guard self.connectingGatewayID == nil else { return }
guard let candidate = self.autoCandidate() else { return }
self.connectingGatewayID = candidate.id
Task {
defer { self.connectingGatewayID = nil }
await self.gatewayController.connect(candidate)
}
}
private func autoCandidate() -> GatewayDiscoveryModel.DiscoveredGateway? {
let preferred = self.preferredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
let lastDiscovered = self.lastDiscoveredGatewayStableID.trimmingCharacters(in: .whitespacesAndNewlines)
if !preferred.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == preferred })
{
return match
}
if !lastDiscovered.isEmpty,
let match = self.gatewayController.gateways.first(where: { $0.stableID == lastDiscovered })
{
return match
}
if self.gatewayController.gateways.count == 1 {
return self.gatewayController.gateways.first
}
return nil
}
}
private struct ManualEntryStep: View {
@Environment(NodeAppModel.self) private var appModel: NodeAppModel
@Environment(GatewayConnectionController.self) private var gatewayController: GatewayConnectionController
@State private var setupCode: String = ""
@State private var setupStatusText: String?
@State private var manualHost: String = ""
@State private var manualPortText: String = ""
@State private var manualUseTLS: Bool = true
@State private var manualToken: String = ""
@State private var manualPassword: String = ""
@State private var pendingManualAuthOverride: GatewayConnectionController.ManualAuthOverride?
@State private var connectingGatewayID: String?
@State private var connectStatusText: String?
var body: some View {
Form {
Section("Setup code") {
Text("Use /pair in your bot to get a setup code.")
.font(.footnote)
.foregroundStyle(.secondary)
TextField("Paste setup code", text: self.$setupCode)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
Button("Apply setup code") {
self.applySetupCode()
}
.disabled(self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
if let setupStatusText, !setupStatusText.isEmpty {
Text(setupStatusText)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
Section {
TextField("Host", text: self.$manualHost)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
TextField("Port", text: self.$manualPortText)
.keyboardType(.numberPad)
Toggle("Use TLS", isOn: self.$manualUseTLS)
TextField("Gateway token", text: self.$manualToken)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
SecureField("Gateway password", text: self.$manualPassword)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
}
gatewayConnectionStatusSection(
appModel: self.appModel,
gatewayController: self.gatewayController,
secondaryLine: self.connectStatusText)
Section {
Button {
Task { await self.connectManual() }
} label: {
if self.connectingGatewayID == "manual" {
HStack(spacing: 8) {
ProgressView()
.progressViewStyle(.circular)
Text("Connecting…")
}
} else {
Text("Connect")
}
}
.disabled(self.connectingGatewayID != nil)
Button("Retry") {
resetGatewayConnectionState(
appModel: self.appModel,
connectStatusText: &self.connectStatusText,
connectingGatewayID: &self.connectingGatewayID)
self.resetManualForm()
}
.disabled(self.connectingGatewayID != nil)
}
}
.navigationTitle("Manual entry")
}
private func connectManual() async {
let host = self.manualHost.trimmingCharacters(in: .whitespacesAndNewlines)
guard !host.isEmpty else {
self.connectStatusText = "Failed: host required"
return
}
if let port = self.manualPortValue(), !(1...65535).contains(port) {
self.connectStatusText = "Failed: invalid port"
return
}
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.manual.enabled")
defaults.set(host, forKey: "gateway.manual.host")
defaults.set(self.manualPortValue() ?? 0, forKey: "gateway.manual.port")
defaults.set(self.manualUseTLS, forKey: "gateway.manual.tls")
let instanceId = GatewaySettingsStore.currentInstanceID()
if !instanceId.isEmpty {
let trimmedToken = self.manualToken.trimmingCharacters(in: .whitespacesAndNewlines)
let trimmedPassword = self.manualPassword.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedToken.isEmpty {
GatewaySettingsStore.saveGatewayToken(trimmedToken, instanceId: instanceId)
}
GatewaySettingsStore.saveGatewayPassword(trimmedPassword, instanceId: instanceId)
}
self.connectingGatewayID = "manual"
defer { self.connectingGatewayID = nil }
let authOverride = GatewayConnectionController.ManualAuthOverride.currentManualInput(
token: self.manualToken,
pendingOverride: self.pendingManualAuthOverride,
password: self.manualPassword)
self.pendingManualAuthOverride = nil
await self.gatewayController.connectManual(
host: host,
port: self.manualPortValue() ?? 0,
useTLS: self.manualUseTLS,
authOverride: authOverride)
}
private func manualPortValue() -> Int? {
let trimmed = self.manualPortText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
return Int(trimmed.filter(\.isNumber))
}
private func resetManualForm() {
self.setupCode = ""
self.setupStatusText = nil
self.manualHost = ""
self.manualPortText = ""
self.manualUseTLS = true
self.manualToken = ""
self.manualPassword = ""
}
private func applySetupCode() {
let raw = self.setupCode.trimmingCharacters(in: .whitespacesAndNewlines)
guard !raw.isEmpty else {
self.setupStatusText = "Paste a setup code to continue."
return
}
if AppleReviewDemoMode.isSetupCode(raw) {
self.setupCode = ""
self.setupStatusText = "Apple Review demo mode enabled."
self.appModel.enterAppleReviewDemoMode()
return
}
guard let link = GatewayConnectDeepLink.fromSetupInput(raw) else {
self.setupStatusText = "Setup code not recognized or uses an insecure ws:// gateway URL."
return
}
self.manualHost = link.host
self.manualPortText = String(link.port)
self.manualUseTLS = link.tls
let setupAuth = GatewayConnectionController.ManualAuthOverride.setupAuth(from: link)
if setupAuth.shouldApplyTokenField {
self.manualToken = setupAuth.token
}
if setupAuth.shouldApplyPasswordField {
self.manualPassword = setupAuth.password
}
let trimmedInstanceId = GatewaySettingsStore.currentInstanceID()
if !trimmedInstanceId.isEmpty {
if setupAuth.hasBootstrapToken {
GatewayOnboardingReset.prepareForBootstrapPairing(
appModel: self.appModel,
instanceId: trimmedInstanceId)
}
GatewaySettingsStore.saveGatewayBootstrapToken(setupAuth.bootstrapToken, instanceId: trimmedInstanceId)
}
self.pendingManualAuthOverride = setupAuth.manualAuthOverride
self.setupStatusText = "Setup code applied."
}
}
@MainActor
private func gatewayConnectionStatusLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
{
ConnectionStatusBox.defaultLines(appModel: appModel, gatewayController: gatewayController)
}
@MainActor
private func resetGatewayConnectionState(
appModel: NodeAppModel,
connectStatusText: inout String?,
connectingGatewayID: inout String?)
{
appModel.disconnectGateway()
connectStatusText = nil
connectingGatewayID = nil
}
@MainActor
private func gatewayConnectionStatusSection(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController,
secondaryLine: String?) -> some View
{
Section("Connection status") {
ConnectionStatusBox(
statusLines: gatewayConnectionStatusLines(
appModel: appModel,
gatewayController: gatewayController),
secondaryLine: secondaryLine)
}
}
private struct ConnectionStatusBox: View {
let statusLines: [String]
let secondaryLine: String?
var body: some View {
VStack(alignment: .leading, spacing: 6) {
ForEach(self.statusLines, id: \.self) { line in
Text(line)
.font(.system(size: 12, weight: .regular, design: .monospaced))
.foregroundStyle(.secondary)
}
if let secondaryLine, !secondaryLine.isEmpty {
Text(secondaryLine)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(10)
.background(.thinMaterial, in: RoundedRectangle(cornerRadius: 10, style: .continuous))
}
static func defaultLines(
appModel: NodeAppModel,
gatewayController: GatewayConnectionController) -> [String]
{
var lines: [String] = [
"gateway: \(appModel.gatewayDisplayStatusText)",
"discovery: \(gatewayController.discoveryStatusText)",
]
lines.append("server: \(appModel.gatewayServerName ?? "")")
lines.append("address: \(appModel.gatewayRemoteAddress ?? "")")
return lines
}
}

View File

@@ -53,6 +53,10 @@ enum OnboardingStateStore {
defaults.set(true, forKey: self.firstRunIntroSeenDefaultsKey)
}
static func markIncomplete(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: self.completedDefaultsKey)
}
static func reset(defaults: UserDefaults = .standard) {
defaults.set(false, forKey: self.completedDefaultsKey)
defaults.set(false, forKey: self.firstRunIntroSeenDefaultsKey)

View File

@@ -17,6 +17,10 @@ private enum OnboardingStep: Int, CaseIterable {
Self(rawValue: self.rawValue - 1)
}
var next: Self? {
Self(rawValue: self.rawValue + 1)
}
/// Progress label for the manual setup flow (mode connect auth success).
var manualProgressTitle: String {
let manualSteps: [OnboardingStep] = [.mode, .connect, .auth, .success]

View File

@@ -39,6 +39,10 @@ struct PushBuildConfig {
self.relayBaseURL = Self.readURL(bundle: bundle, key: "OpenClawPushRelayBaseURL")
}
var usesRelay: Bool {
self.transport == .relay
}
private static func readURL(bundle: Bundle, key: String) -> URL? {
guard let raw = bundle.object(forInfoDictionaryKey: key) as? String else { return nil }
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -71,6 +71,11 @@ enum PushRelayRegistrationStore {
return KeychainStore.saveString(raw, service: self.service, account: self.registrationStateAccount)
}
@discardableResult
static func clearRegistrationState() -> Bool {
KeychainStore.delete(service: self.service, account: self.registrationStateAccount)
}
static func loadAppAttestKeyID() -> String? {
let value = KeychainStore.loadString(service: self.service, account: self.appAttestKeyIDAccount)?
.trimmingCharacters(in: .whitespacesAndNewlines)

View File

@@ -7,6 +7,7 @@ extension RootTabs {
980
}
static let sidebarSplitMinimumWidth: CGFloat = 292
static let sidebarSplitIdealWidth: CGFloat = 316
static let sidebarSplitMaximumWidth: CGFloat = 340
static let sidebarDrawerMaximumWidth: CGFloat = 340

View File

@@ -0,0 +1,15 @@
import SwiftUI
struct RootView: View {
@AppStorage(AppAppearancePreference.storageKey) private var appearancePreferenceRaw: String =
AppAppearancePreference.system.rawValue
var body: some View {
RootTabs()
.preferredColorScheme(self.appearancePreference.colorScheme)
}
private var appearancePreference: AppAppearancePreference {
AppAppearancePreference(rawValue: self.appearancePreferenceRaw) ?? .system
}
}

View File

@@ -181,6 +181,16 @@ final class ScreenController {
return try await WebViewJavaScriptSupport.evaluateToString(webView: webView, javaScript: javaScript)
}
func snapshotPNGBase64(maxWidth: CGFloat? = nil) async throws -> String {
let image = try await self.snapshotImage(maxWidth: maxWidth)
guard let data = image.pngData() else {
throw NSError(domain: "Screen", code: 1, userInfo: [
NSLocalizedDescriptionKey: "snapshot encode failed",
])
}
return data.base64EncodedString()
}
func snapshotBase64(
maxWidth: CGFloat? = nil,
format: OpenClawCanvasSnapshotFormat,

View File

@@ -31,7 +31,12 @@ protocol LocationServicing: Sendable {
desiredAccuracy: OpenClawLocationAccuracy,
maxAgeMs: Int?,
timeoutMs: Int?) async throws -> CLLocation
func startLocationUpdates(
desiredAccuracy: OpenClawLocationAccuracy,
significantChangesOnly: Bool) -> AsyncStream<CLLocation>
func stopLocationUpdates()
func startMonitoringSignificantLocationChanges(onUpdate: @escaping @Sendable (CLLocation) -> Void)
func stopMonitoringSignificantLocationChanges()
}
@MainActor

View File

@@ -22,4 +22,11 @@ enum SessionKey {
let agentId = String(parts[1]).trimmingCharacters(in: .whitespacesAndNewlines)
return agentId.isEmpty ? nil : agentId
}
static func isCanonicalMainSessionKey(_ value: String?) -> Bool {
let trimmed = (value ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
if trimmed.isEmpty { return false }
if trimmed == "global" { return true }
return trimmed.hasPrefix("agent:")
}
}

View File

@@ -0,0 +1,40 @@
import Foundation
struct SettingsHostPort: Equatable {
var host: String
var port: Int
}
enum SettingsNetworkingHelpers {
static func parseHostPort(from address: String) -> SettingsHostPort? {
let trimmed = address.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return nil }
if trimmed.hasPrefix("["),
let close = trimmed.firstIndex(of: "]"),
close < trimmed.endIndex
{
let host = String(trimmed[trimmed.index(after: trimmed.startIndex)..<close])
let portStart = trimmed.index(after: close)
guard portStart < trimmed.endIndex, trimmed[portStart] == ":" else { return nil }
let portString = String(trimmed[trimmed.index(after: portStart)...])
guard let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
guard let colon = trimmed.lastIndex(of: ":") else { return nil }
let host = String(trimmed[..<colon])
let portString = String(trimmed[trimmed.index(after: colon)...])
guard !host.isEmpty, let port = Int(portString) else { return nil }
return SettingsHostPort(host: host, port: port)
}
static func httpURLString(host: String?, port: Int?, fallback: String) -> String {
if let host, let port {
let needsBrackets = host.contains(":") && !host.hasPrefix("[") && !host.hasSuffix("]")
let hostPart = needsBrackets ? "[\(host)]" : host
return "http://\(hostPart):\(port)"
}
return "http://\(fallback)"
}
}

View File

@@ -3,6 +3,7 @@ import OpenClawKit
enum TalkModeExecutionMode {
case native
case realtimeClient
case realtimeRelay
}

View File

@@ -64,6 +64,22 @@ extension TalkModeManager {
}
}
static func permissionMessage(
kind: String,
status: AVAudioSession.RecordPermission) -> String
{
switch status {
case .denied:
return "\(kind) permission denied"
case .undetermined:
return "\(kind) permission not granted"
case .granted:
return "\(kind) permission denied"
@unknown default:
return "\(kind) permission denied"
}
}
static func permissionMessage(
kind: String,
status: SFSpeechRecognizerAuthorizationStatus) -> String

View File

@@ -70,6 +70,11 @@ final class TalkModeManager: NSObject {
self.gatewayConnected
}
var hasActiveAudioCapture: Bool {
self.isEnabled || self.isListening || self.isPushToTalkActive || self.realtimeRelaySession != nil
|| self.realtimeRelayStartInFlight
}
private enum CaptureMode {
case idle
case continuous
@@ -470,6 +475,13 @@ final class TalkModeManager: NSObject {
return wasActive
}
func setForegroundAudioCaptureAllowed(_ allowed: Bool) {
self.foregroundAudioCaptureAllowed = allowed
if !allowed {
self.cancelPendingStart()
}
}
func resumeAfterBackground(wasSuspended: Bool, wasKeptActive: Bool = false) async {
if wasKeptActive { return }
guard wasSuspended else { return }
@@ -477,6 +489,14 @@ final class TalkModeManager: NSObject {
await self.start()
}
func userTappedOrb() {
if let realtimeSession {
realtimeSession.cancelResponse()
}
self.realtimeRelaySession?.cancelOutput()
self.stopSpeaking()
}
func beginPushToTalk() async throws -> OpenClawTalkPTTStartPayload {
guard self.gatewayConnected else {
self.statusText = "Offline"
@@ -3084,6 +3104,23 @@ extension TalkModeManager {
self.gatewayTalkCurrentFallbackIssue
}
func _test_seedTranscript(_ transcript: String) {
self.lastTranscript = transcript
self.lastHeard = Date()
}
func _test_handleTranscript(_ transcript: String, isFinal: Bool) async {
await self.handleTranscript(transcript: transcript, isFinal: isFinal)
}
func _test_backdateLastHeard(seconds: TimeInterval) {
self.lastHeard = Date().addingTimeInterval(-seconds)
}
func _test_runSilenceCheck() async {
await self.checkSilence()
}
func _test_incrementalReset() {
self.incrementalSpeechBuffer = IncrementalSpeechBuffer()
}

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct TalkPermissionPromptView: View {
enum Style {
case card
case settings
case sheet
}

View File

@@ -61,6 +61,7 @@ final class TalkRealtimeWebRTCSession: NSObject {
let runId: String?
let status: String?
let startedAt: Double?
let endedAt: Double?
let error: String?
let stopReason: String?
let timeoutPhase: String?
@@ -195,6 +196,11 @@ final class TalkRealtimeWebRTCSession: NSObject {
Self.logger.info("timeline +\(self.elapsedMs(), privacy: .public)ms \(message, privacy: .public)")
}
func cancelResponse() {
self.sendRealtimeEvent(["type": "response.cancel"])
self.cancelActiveToolCalls()
}
private func cancelActiveToolCalls() {
let runIds = Array(Set(activeToolRunIds.values))
for task in self.activeToolTasks.values {

View File

@@ -70,6 +70,14 @@ enum TalkSpeechLocale {
return (recognizer, recognizer?.locale.identifier)
}
static func normalizedExplicitLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedExplicitSpeechLocaleID(raw, automaticID: self.automaticID)
}
private static func normalizedLocaleID(_ raw: String?) -> String? {
TalkConfigParsing.normalizedSpeechLocaleID(raw)
}
private static func canonicalID(_ raw: String) -> String {
raw.replacingOccurrences(of: "_", with: "-")
}

View File

@@ -16,6 +16,7 @@ Sources/Design/ChatProTab.swift
Sources/Design/CommandCenterTab.swift
Sources/Design/TalkProTab.swift
Sources/Design/OpenClawProComponents.swift
Sources/Design/OpenClawProScreens.swift
Sources/Design/SettingsProTab.swift
Sources/Design/SettingsProTabSupport.swift
Sources/Design/SettingsProTabSections.swift
@@ -66,6 +67,7 @@ Sources/Model/NodeAppModel.swift
Sources/Model/WatchReplyCoordinator.swift
Sources/Motion/MotionService.swift
Sources/Onboarding/GatewayOnboardingReset.swift
Sources/Onboarding/GatewayOnboardingView.swift
Sources/Onboarding/OnboardingStateStore.swift
Sources/Onboarding/OnboardingWizardSteps.swift
Sources/Onboarding/OnboardingWizardView.swift
@@ -81,6 +83,7 @@ Sources/Push/PushRelayKeychainStore.swift
Sources/Reminders/RemindersService.swift
Sources/RootTabs.swift
Sources/RootTabsNavigation.swift
Sources/RootView.swift
Sources/Screen/ScreenController.swift
Sources/Screen/ScreenRecordService.swift
Sources/Screen/ScreenWebView.swift
@@ -91,6 +94,7 @@ Sources/Services/WatchMessagingPayloadCodec.swift
Sources/Services/WatchMessagingService.swift
Sources/SessionKey.swift
Sources/Settings/PrivacyAccessSectionView.swift
Sources/Settings/SettingsNetworkingHelpers.swift
Sources/Settings/VoiceWakeWordsSettingsView.swift
Sources/Status/GatewayStatusBuilder.swift
Sources/Status/VoiceWakeToast.swift

View File

@@ -39,19 +39,19 @@ import Testing
@Test @MainActor func discoveredTLSParams_prefersStoredPinOverAdvertisedTXT() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
clearTLSFingerprint(stableID: stableID)
GatewayTLSStore.saveFingerprint("11", stableID: stableID)
let gateway = self.makeDiscoveredGateway(
let gateway = makeDiscoveredGateway(
stableID: stableID,
lanHost: "evil.example.com",
tailnetDns: "evil.example.com",
gatewayPort: 12345,
fingerprint: "22")
let controller = self.makeController()
let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == "11")
#expect(params?.allowTOFU == false)
}
@@ -59,17 +59,17 @@ import Testing
@Test @MainActor func discoveredTLSParams_doesNotTrustAdvertisedFingerprint() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
clearTLSFingerprint(stableID: stableID)
let gateway = self.makeDiscoveredGateway(
let gateway = makeDiscoveredGateway(
stableID: stableID,
lanHost: nil,
tailnetDns: nil,
gatewayPort: nil,
fingerprint: "22")
let controller = self.makeController()
let controller = makeController()
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway)
let params = controller._test_resolveDiscoveredTLSParams(gateway: gateway, allowTOFU: true)
#expect(params?.expectedFingerprint == nil)
#expect(params?.allowTOFU == false)
}
@@ -77,7 +77,7 @@ import Testing
@Test @MainActor func autoconnectRequiresStoredPinForDiscoveredGateways() async {
let stableID = "test|\(UUID().uuidString)"
defer { clearTLSFingerprint(stableID: stableID) }
self.clearTLSFingerprint(stableID: stableID)
clearTLSFingerprint(stableID: stableID)
let defaults = UserDefaults.standard
defaults.set(true, forKey: "gateway.autoconnect")
@@ -90,13 +90,13 @@ import Testing
defaults.removeObject(forKey: "gateway.preferredStableID")
defaults.set(stableID, forKey: "gateway.lastDiscoveredStableID")
let gateway = self.makeDiscoveredGateway(
let gateway = makeDiscoveredGateway(
stableID: stableID,
lanHost: "test.local",
tailnetDns: nil,
gatewayPort: 18789,
fingerprint: nil)
let controller = self.makeController()
let controller = makeController()
controller._test_setGateways([gateway])
controller._test_triggerAutoConnect()
@@ -104,7 +104,7 @@ import Testing
}
@Test @MainActor func manualConnectionsForceTLSForNonLoopbackHosts() async {
let controller = self.makeController()
let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "gateway.example.com", useTLS: false) == true)
#expect(controller._test_resolveManualUseTLS(host: "127.attacker.example", useTLS: false) == true)
@@ -120,7 +120,7 @@ import Testing
}
@Test @MainActor func manualConnectionsAllowPrivateLanPlaintext() async {
let controller = self.makeController()
let controller = makeController()
#expect(controller._test_resolveManualUseTLS(host: "openclaw.local", useTLS: false) == false)
#expect(controller._test_resolveManualUseTLS(host: "192.168.1.20", useTLS: false) == false)
@@ -131,7 +131,7 @@ import Testing
}
@Test @MainActor func manualDefaultPortUses443OnlyForTailnetTLSHosts() async {
let controller = self.makeController()
let controller = makeController()
#expect(controller._test_resolveManualPort(host: "gateway.example.com", port: 0, useTLS: true) == 18789)
#expect(controller._test_resolveManualPort(host: "device.sample.ts.net", port: 0, useTLS: true) == 443)

View File

@@ -9,9 +9,11 @@ private struct KeychainEntry: Hashable {
private let gatewayService = "ai.openclaw.gateway"
private let nodeService = "ai.openclaw.node"
private let talkService = "ai.openclaw.talk"
private let instanceIdEntry = KeychainEntry(service: nodeService, account: "instanceId")
private let preferredGatewayEntry = KeychainEntry(service: gatewayService, account: "preferredStableID")
private let lastGatewayEntry = KeychainEntry(service: gatewayService, account: "lastDiscoveredStableID")
private let talkAcmeProviderEntry = KeychainEntry(service: talkService, account: "provider.apiKey.acme")
private let bootstrapDefaultsKeys = [
"node.instanceId",
"gateway.preferredStableID",
@@ -185,4 +187,17 @@ private func withLastGatewaySnapshot(_ body: () -> Void) {
#expect(defaults.object(forKey: "gateway.last.host") == nil)
}
}
@Test func talkProviderApiKey_genericRoundTrip() {
let keychainSnapshot = snapshotKeychain([talkAcmeProviderEntry])
defer { restoreKeychain(keychainSnapshot) }
_ = KeychainStore.delete(service: talkService, account: talkAcmeProviderEntry.account)
GatewaySettingsStore.saveTalkProviderApiKey("acme-key", provider: "acme")
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == "acme-key")
GatewaySettingsStore.saveTalkProviderApiKey(nil, provider: "acme")
#expect(GatewaySettingsStore.loadTalkProviderApiKey(provider: "acme") == nil)
}
}

View File

@@ -56,6 +56,12 @@ import Testing
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
OnboardingStateStore.markIncomplete(defaults: defaults)
#expect(OnboardingStateStore.shouldPresentOnLaunch(
appModel: appModel,
defaults: defaults,
hasSavedGatewayConnection: false))
}
@Test func firstRunIntroDefaultsToVisibleThenPersists() {

View File

@@ -153,9 +153,13 @@ import Testing
let destinationsSource = try String(contentsOf: Self.agentProTabDestinationsSourceURL(), encoding: .utf8)
let nodesSource = try String(contentsOf: Self.agentProNodesDestinationSourceURL(), encoding: .utf8)
let dreamingSource = try String(contentsOf: Self.agentProDreamingDestinationSourceURL(), encoding: .utf8)
let directDestination = try Self.extract(
source,
from: "private func directDestination(for route: AgentRoute) -> some View",
to: "private func applyInitialRouteIfNeeded()")
#expect(!source.contains("ToolbarItem"))
#expect(source.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
#expect(!directDestination.contains("ToolbarItem"))
#expect(directDestination.contains("self.directHeaderLeadingAction(for: route) == nil ? .visible : .hidden"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .instances)"))
#expect(destinationsSource.contains("self.directHeaderLeadingAction(for: .dreaming)"))
#expect(destinationsSource.contains("self.directHeader(\n for: .usage"))
@@ -494,6 +498,7 @@ import Testing
let chatSource = try String(contentsOf: Self.chatProTabSourceURL(), encoding: .utf8)
let docsSource = try String(contentsOf: Self.docsSourceURL(), encoding: .utf8)
let settingsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
let channelsSource = try String(contentsOf: Self.channelsSourceURL(), encoding: .utf8)
#expect(rootSource.matches(of: /openSettings: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count >= 2)
#expect(rootSource.matches(of: /gatewayAction: \{ self\.selectSidebarDestination\(\.gateway\) \}/).count == 1)
@@ -517,7 +522,9 @@ import Testing
#expect(rootSource.contains("SettingsProTab(initialRoute: self.selectedSidebarDestination.settingsRoute)"))
#expect(settingsSource.contains("title: \"Channels / Integrations\""))
#expect(settingsSource.contains("route: .channels"))
#expect(channelsSource.contains("let gatewayAction: (() -> Void)?"))
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
#expect(channelsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func gatewaySettingsKeepsPairingTrustDiagnosticsAndTailscaleActions() throws {

View File

@@ -29,4 +29,50 @@ import Testing
talkConfigLoaded: true,
notificationStatusText: "Allowed") == 0)
}
@Test func parseHostPortParsesIPv4() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "127.0.0.1:8080") == .init(host: "127.0.0.1", port: 8080))
}
@Test func parseHostPortParsesHostnameAndTrims() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: " example.com:80 \n") == .init(
host: "example.com",
port: 80))
}
@Test func parseHostPortParsesBracketedIPv6() {
#expect(
SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:443") ==
.init(host: "2001:db8::1", port: 443))
}
@Test func parseHostPortRejectsMissingPort() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com") == nil)
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]") == nil)
}
@Test func parseHostPortRejectsInvalidPort() {
#expect(SettingsNetworkingHelpers.parseHostPort(from: "example.com:lol") == nil)
#expect(SettingsNetworkingHelpers.parseHostPort(from: "[2001:db8::1]:lol") == nil)
}
@Test func httpURLStringFormatsIPv4AndPort() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "127.0.0.1", port: 8080, fallback: "fallback") == "http://127.0.0.1:8080")
}
@Test func httpURLStringBracketsIPv6() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "2001:db8::1", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
}
@Test func httpURLStringLeavesAlreadyBracketedIPv6() {
#expect(SettingsNetworkingHelpers
.httpURLString(host: "[2001:db8::1]", port: 8080, fallback: "fallback") == "http://[2001:db8::1]:8080")
}
@Test func httpURLStringFallsBackWhenMissingHostOrPort() {
#expect(SettingsNetworkingHelpers.httpURLString(host: nil, port: 80, fallback: "x") == "http://x")
#expect(SettingsNetworkingHelpers.httpURLString(host: "example.com", port: nil, fallback: "y") == "http://y")
}
}

View File

@@ -103,6 +103,7 @@ import UIKit
AnyView(CommandCenterTab(openChat: {}, openSettings: {})),
AnyView(IPadActivityScreen(openChat: {}, openSettings: {})),
AnyView(OpenClawDocsScreen()),
AnyView(SettingsChannelsScreen()),
AnyView(IPadWorkboardScreen(openChat: {}, openSettings: {})),
AnyView(IPadSkillWorkshopScreen(openSettings: {})),
AnyView(AgentProTab(directRoute: .agents)),

View File

@@ -1,5 +1,5 @@
{
"originHash" : "4f7b315ce0e0a16d150d8d74dce445628c03d8926485ad2f5595e091b4d33440",
"originHash" : "ae9f37f50cff0d32d189e60948f61e2fa1704e997a6ef4ad5e37f6a11c165ea4",
"pins" : [
{
"identity" : "axorcist",
@@ -42,8 +42,8 @@
"kind" : "remoteSourceControl",
"location" : "https://github.com/steipete/Peekaboo.git",
"state" : {
"revision" : "1fa8eead7eeac3ff618a3111fc333ae78db043d2",
"version" : "3.5.2"
"revision" : "ee0e3185431788dad533ffca77cd75315aa3d26f",
"version" : "3.4.1"
}
},
{

View File

@@ -19,7 +19,7 @@ let package = Package(
.package(url: "https://github.com/swiftlang/swift-subprocess.git", from: "0.4.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"),
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.9.0"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.5.2"),
.package(url: "https://github.com/steipete/Peekaboo.git", exact: "3.4.1"),
.package(path: "../shared/OpenClawKit"),
.package(path: "../swabble"),
],

View File

@@ -46,17 +46,6 @@ public enum NodePresenceAliveReason: String, Codable, Sendable {
case connect = "connect"
}
public enum SessionFileKind: String, Codable, Sendable {
case modified = "modified"
case read = "read"
}
public enum SessionFileRelevance: String, Codable, Sendable {
case modified = "modified"
case read = "read"
case mixed = "mixed"
}
public struct ConnectParams: Codable, Sendable {
public let minprotocol: Int
public let maxprotocol: Int
@@ -1767,7 +1756,6 @@ public struct SessionsResolveParams: Codable, Sendable {
public let spawnedby: String?
public let includeglobal: Bool?
public let includeunknown: Bool?
public let allowmissing: Bool?
public init(
key: String?,
@@ -1776,8 +1764,7 @@ public struct SessionsResolveParams: Codable, Sendable {
agentid: String? = nil,
spawnedby: String?,
includeglobal: Bool?,
includeunknown: Bool?,
allowmissing: Bool? = nil)
includeunknown: Bool?)
{
self.key = key
self.sessionid = sessionid
@@ -1786,7 +1773,6 @@ public struct SessionsResolveParams: Codable, Sendable {
self.spawnedby = spawnedby
self.includeglobal = includeglobal
self.includeunknown = includeunknown
self.allowmissing = allowmissing
}
private enum CodingKeys: String, CodingKey {
@@ -1797,7 +1783,6 @@ public struct SessionsResolveParams: Codable, Sendable {
case spawnedby = "spawnedBy"
case includeglobal = "includeGlobal"
case includeunknown = "includeUnknown"
case allowmissing = "allowMissing"
}
}

View File

@@ -164,7 +164,7 @@ handoff path over manual terminal capture.
- Gateway owns the WhatsApp socket and reconnect loop.
- The reconnect watchdog uses WhatsApp Web transport activity, not only inbound app-message volume, so a quiet linked-device session is not restarted solely because nobody has sent a message recently. A longer application-silence cap still forces a reconnect if transport frames keep arriving but no application messages are handled for the watchdog window; after a transient reconnect for a recently active session, that application-silence check uses the normal message timeout for the first recovery window.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query waits plus OpenClaw's local outbound send/presence operation bound.
- Baileys socket timings are explicit under `web.whatsapp.*`: `keepAliveIntervalMs` controls WhatsApp Web application pings, `connectTimeoutMs` controls the opening handshake timeout, and `defaultQueryTimeoutMs` controls Baileys query timeouts.
- Outbound sends require an active WhatsApp listener for the target account.
- Group sends attach native mention metadata for `@+<digits>` and `@<digits>` tokens in text and media captions when the token matches current WhatsApp participant metadata, including LID-backed groups.
- Status and broadcast chats are ignored (`@status`, `@broadcast`).

View File

@@ -26,7 +26,7 @@ Notes:
- When the current session snapshot is sparse, `/status` can backfill token and cache counters from the most recent transcript usage log. Existing nonzero live values still win over transcript fallback values.
- `/status` includes compact Gateway process uptime and host system uptime.
- Transcript fallback can also recover the active runtime model label when the live session entry is missing it. If that transcript model differs from the selected model, status resolves the context window against the recovered runtime model instead of the selected one.
- When a session is pinned to a model that differs from the configured primary, status prints both values, the reason (`session override`), and the clear hint (`/model default`). The configured primary applies to new or unpinned sessions; existing pinned sessions keep their session selection until cleared.
- When a session is pinned to a model that differs from the configured primary, status prints both values, the reason (`session override`), and the clear hint (`/model <configured-default>` or `/reset`). The configured primary applies to new or unpinned sessions; existing pinned sessions keep their session selection until cleared.
- For prompt-size accounting, transcript fallback prefers the larger prompt-oriented total when session metadata is missing or smaller, so custom-provider sessions do not collapse to `0` token displays.
- Output includes per-agent session stores when multiple agents are configured.
- Overview includes Gateway + node host service install/runtime status when available.

View File

@@ -99,7 +99,7 @@ Official provider plugins publish their own model catalog rows. These providers
- Use `params.serviceTier` when you want an explicit tier instead of the shared `/fast` toggle
- Hidden OpenClaw attribution headers (`originator`, `version`, `User-Agent`) apply only on native OpenAI traffic to `api.openai.com`, not generic OpenAI-compatible proxies
- Native OpenAI routes also keep Responses `store`, prompt-cache hints, and OpenAI reasoning-compat payload shaping; proxy routes do not
- `openai/gpt-5.3-codex-spark` is available through ChatGPT/Codex OAuth subscription auth when your signed-in account exposes it; OpenClaw still suppresses direct OpenAI API-key and Azure API-key routes for this model because those transports reject it
- `openai/gpt-5.3-codex-spark` is intentionally suppressed in OpenClaw because live OpenAI API requests reject it and the current Codex catalog does not expose it
```json5
{

View File

@@ -61,7 +61,7 @@ The same `provider/model` can mean different things depending on where it came f
- Configured defaults (`agents.defaults.model.primary` and agent-specific primaries) are the normal starting point and use `agents.defaults.model.fallbacks`.
- Auto fallback selections are temporary recovery state. They are stored with `modelOverrideSource: "auto"` so later turns can keep using the fallback chain without probing a known-bad primary every time; OpenClaw periodically probes the original primary again, clears the auto selection when it recovers, and announces fallback/recovery transitions once per state change.
- User session selections are exact. `/model`, the model picker, `session_status(model=...)`, and `sessions.patch` store `modelOverrideSource: "user"`; if that selected provider/model is unreachable, OpenClaw fails visibly instead of falling through to another configured model.
- Changing `agents.defaults.model.primary` does not rewrite existing session selections. If status says `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, clear the current session selection with `/model default` so it inherits the configured primary again.
- Changing `agents.defaults.model.primary` does not rewrite existing session selections. If status says `This session is pinned to X; config primary Y will apply to new/unpinned sessions.`, switch the current session with `/model Y` or clear stale session state with `/reset`.
- Cron `--model` / payload `model` is a per-job primary. It still uses configured fallbacks unless the job supplies explicit payload `fallbacks` (use `fallbacks: []` for a strict cron run).
- CLI default-model and allowlist pickers respect `models.mode: "replace"` by listing explicit `models.providers.*.models` instead of loading the full built-in catalog.
- The Control UI model picker asks the Gateway for its configured model view: `agents.defaults.models` when present, including provider-wide `provider/*` entries, otherwise explicit `models.providers.*.models` plus providers with usable auth. The full built-in catalog is reserved for explicit browse views such as `models.list` with `view: "all"` or `openclaw models list --all`.
@@ -188,7 +188,6 @@ You can switch models for the current session without restarting:
/model list
/model 3
/model openai/gpt-5.4
/model default
/model status
```
@@ -206,7 +205,6 @@ You can switch models for the current session without restarting:
- If the agent is idle, the next run uses the new model right away.
- If a run is already active, OpenClaw marks a live switch as pending and only restarts into the new model at a clean retry point.
- If tool activity or reply output has already started, the pending switch can stay queued until a later retry opportunity or the next user turn.
- `/model default` clears the session selection and returns the session to the configured default model.
- A user-selected `/model` ref is strict for that session: if the selected provider/model is unreachable, the reply fails visibly instead of silently answering from `agents.defaults.model.fallbacks`. This is different from configured defaults and cron job primaries, which can still use fallback chains.
- `/model status` is the detailed view (auth candidates and, when configured, provider endpoint `baseUrl` + `api` mode).

View File

@@ -53,11 +53,7 @@ script aliases; both forms are supported.
Profile-backed `qa run` reads membership from `taxonomy.yaml`, then dispatches
the resolved scenarios through `qa suite`. `--surface` and
`--category` filter the selected profile instead of defining separate lanes.
The resulting `qa-evidence.json` includes a profile scorecard summary with
selected-category counts and missing coverage IDs; the individual evidence
entries remain the source of truth for the tests, coverage roles, artifacts,
and results:
`--category` filter the selected profile instead of defining separate lanes:
```bash
pnpm openclaw qa run \
@@ -941,9 +937,7 @@ Every `qa suite` run writes top-level `qa-evidence.json`,
`qa-suite-summary.json`, and `qa-suite-report.md` artifacts for the selected
scenario set. Scenarios that declare `execution.kind: vitest` or
`execution.kind: playwright` run the matching test path and also write
per-scenario logs. When `qa suite` is reached through
`qa run --qa-profile`, the same `qa-evidence.json` also includes the profile
scorecard summary for the selected taxonomy categories.
per-scenario logs.
Treat it as a discovery aid, not a gate replacement; the selected scenario still needs the right provider mode, live transport, Multipass, Testbox, or release lane for the behavior under test.
For character and style checks, run the same scenario across multiple live model

View File

@@ -42,45 +42,6 @@ When you touch tests or want extra confidence:
- Coverage gate: `pnpm test:coverage`
- E2E suite: `pnpm test:e2e`
## Test Temp Directories
Prefer the shared helpers in `test/helpers/temp-dir.ts` for test-owned
temporary directories. They make ownership explicit and keep cleanup in the same
test lifecycle:
```ts
import { afterEach } from "vitest";
import { createTempDirTracker } from "../helpers/temp-dir.js";
const tempDirs = createTempDirTracker();
afterEach(tempDirs.cleanup);
it("uses a temp workspace", () => {
const workspace = tempDirs.make("openclaw-example-");
// use workspace
});
```
Use `makeTempDir(tempDirs, prefix)` and `cleanupTempDirs(tempDirs)` when a test
already owns an array or set of paths. Avoid new bare `fs.mkdtemp*` calls in
tests unless a case is explicitly verifying raw temp-dir behavior. Add an
auditable allow comment with a concrete reason when a test intentionally needs a
bare temp directory:
```ts
// openclaw-temp-dir: allow verifies raw fs cleanup behavior
const workspace = fs.mkdtempSync(prefix);
```
For migration visibility, `node scripts/report-test-temp-creations.mjs` reports
new bare temp-dir creation in added diff lines without blocking existing cleanup
styles. Its file scope intentionally follows the same test-path classification
used by `scripts/changed-lanes.mjs` instead of maintaining a separate test-helper
filename heuristic, while skipping the shared helper implementation itself.
`check:changed` runs this report for changed test paths as a warning-only CI
signal; findings are GitHub warning annotations, not failures.
When debugging real providers/models (requires real creds):
- Live suite (models + gateway tool/image probes): `pnpm test:live`
@@ -187,8 +148,6 @@ inside every shard.
- Writes top-level `qa-evidence.json`, `qa-suite-summary.json`, and
`qa-suite-report.md` artifacts for the selected scenario set, including
mixed flow, Vitest, and Playwright scenario selections.
- When dispatched by `pnpm openclaw qa run --qa-profile <profile>`, embeds the
selected taxonomy profile scorecard in the same `qa-evidence.json`.
- Runs multiple selected scenarios in parallel by default with isolated
gateway workers. `qa-channel` defaults to concurrency 4 (bounded by the
selected scenario count). Use `--concurrency <count>` to tune the worker

View File

@@ -71,7 +71,7 @@ If the work is vendor-only and no shared contract exists yet, stop and define th
Use **provider hooks** when the behavior belongs to the model provider contract rather than the generic agent loop. Examples include provider-specific request params after transport selection, auth-profile preference, prompt overlays, and follow-up fallback routing after model/profile failover.
Use **agent harness hooks** when the behavior belongs to the runtime that is executing a turn. Harnesses can classify explicit protocol outcomes such as empty output, reasoning without visible output, or a structured plan without a final answer so the outer model fallback policy can make the retry decision.
Use **agent harness hooks** when the behavior belongs to the runtime that is executing a turn. Harnesses can classify successful-but-unusable attempt results such as empty, reasoning-only, or planning-only responses so the outer model fallback policy can make the retry decision.
Keep both seams narrow:

View File

@@ -180,10 +180,8 @@ Native harnesses that own their own protocol projection can use
`openclaw/plugin-sdk/agent-harness-runtime` when a completed turn produced no
visible assistant text. The helper returns `empty`, `reasoning-only`, or
`planning-only` so OpenClaw's fallback policy can decide whether to retry on a
different model. `planning-only` requires the harness's explicit `planText`
field; OpenClaw does not infer it from assistant prose. The helper intentionally
leaves prompt errors, in-flight turns, and intentional silent replies such as
`NO_REPLY` unclassified.
different model. It intentionally leaves prompt errors, in-flight turns, and
intentional silent replies such as `NO_REPLY` unclassified.
### Native Codex harness mode

View File

@@ -1040,11 +1040,10 @@ the Server-side compaction accordion below.
```
With `strict-agentic`, OpenClaw:
- No longer treats a plan-only turn as successful progress when a tool action is available
- Retries the turn with an act-now steer
- Auto-enables `update_plan` for substantial work
- Retries structurally empty or reasoning-only turns with a visible-answer continuation
- Uses explicit harness plan events when the selected harness provides them
OpenClaw does not classify assistant prose to decide whether a turn is a plan, progress update, or final answer.
- Surfaces an explicit blocked state if the model keeps planning without acting
<Note>
Scoped to OpenAI and Codex GPT-5-family runs only. Other providers and older model families keep default behavior.

View File

@@ -1406,13 +1406,22 @@ create` validates the written archive by default; `--no-verify` is the
explicit `dbPath`.
- `check:database-first-legacy-stores` fails new runtime source that pairs
legacy store names with write-style filesystem APIs. It also fails runtime
source that reintroduces the retired transcript bridge markers
`transcriptLocator` or `sqlite-transcript://...`. Migration, doctor, import,
and explicit non-session export code remain allowed. Broader legacy contract
names such as `sessionFile`, `storePath`, and old `SessionManager` file-era
facades still have current owners and need separate migration guard work
before they can become a required preflight check. The guard now also covers
runtime `cache/*.json` stores, generic
source that reintroduces transcript bridge contracts such as
`transcriptLocator`, `sqlite-transcript://...`, `sessionFile`, or
`storePath`, and scans tests for those bridge-contract names too. It also
bans `SessionManager.open(...)` and the old static SessionManager facades so
runtime and tests cannot silently re-create a file-backed session opener or
file-era session discovery. It also bans the old session JSONL downloader
hook/class from export UI. It also bans sidecar-shaped plugin-state/task
SQLite helper names; tests should assert `databasePath` and the shared
`state/openclaw.sqlite` location instead of pretending those features own
separate SQLite files. It also bans the old generic memory index SQL table
names (`meta`, `files`, `chunks`, `chunks_vec`,
`chunks_fts`, `embedding_cache`) in runtime source so the agent database keeps
its explicit `memory_index_*` schema. It also bans embedding TEXT schemas and
embedding JSON-array writes so vectors stay compact SQLite BLOBs. Migration,
doctor, import, and explicit non-session export code remain allowed. The
guard now also covers runtime `cache/*.json` stores, generic
`thread-bindings.json` sidecars, cron state/run-log JSON, config health JSON,
restart and lock sidecars, Voice Wake settings, plugin binding approvals,
installed plugin index JSON, File Transfer audit JSONL, Memory Wiki activity

View File

@@ -185,8 +185,7 @@ plugins.
dashboard session, except when `session.dmScope: "main"` is configured
and the current parent is the agent's main session — in that case `/new`
resets the main session in place. Typed `/reset` still runs the Gateway's
in-place reset. Use `/model default` when you want to clear a pinned
session model selection.
in-place reset.
</Note>
</Accordion>
@@ -359,7 +358,6 @@ use the Control UI Tools panel or config surfaces.
/model 3 # select by number from picker
/model openai/gpt-5.4
/model opus@anthropic:default
/model default # clear the session model selection
/model status # detailed view with endpoint and API mode
```

View File

@@ -436,31 +436,6 @@ export function readCodexNotificationItem(
: undefined;
}
/** Reads the stable call id from a model-emitted raw tool item. */
export function readRawResponseToolCallId(
notification: CodexServerNotification,
): string | undefined {
if (notification.method !== "rawResponseItem/completed" || !isJsonObject(notification.params)) {
return undefined;
}
const item = isJsonObject(notification.params.item) ? notification.params.item : undefined;
if (!item) {
return undefined;
}
switch (readString(item, "type")) {
case "custom_tool_call":
case "function_call":
case "local_shell_call":
case "tool_search_call":
return readString(item, "call_id");
case "image_generation_call":
case "web_search_call":
return readString(item, "id");
default:
return undefined;
}
}
/** Maps Codex item types to the tool name shown in execution progress. */
export function codexExecutionToolName(item: CodexThreadItem): string | undefined {
if (item.type === "dynamicToolCall" && typeof item.tool === "string") {

View File

@@ -100,7 +100,6 @@ export function buildCodexTurnStartFailureResult(params: {
assistantTexts: [],
toolMetas: [],
lastAssistant: undefined,
currentAttemptAssistant: undefined,
didSendViaMessagingTool: false,
messagingToolSentTexts: [],
messagingToolSentMediaUrls: [],

View File

@@ -4,9 +4,7 @@ import os from "node:os";
import path from "node:path";
import {
embeddedAgentLog,
isToolWrappedWithBeforeToolCallHook,
type EmbeddedRunAttemptParams,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -583,51 +581,6 @@ describe("Codex app-server dynamic tool build", () => {
});
});
it("forwards tool outcome ordering into Codex dynamic tools", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const onToolOutcome = vi.fn();
const allocateToolOutcomeOrdinal = vi.fn(() => 0);
params.disableTools = false;
params.onToolOutcome = onToolOutcome;
params.allocateToolOutcomeOrdinal = allocateToolOutcomeOrdinal;
params.runtimePlan = createCodexRuntimePlanFixture();
const factoryOptions: unknown[] = [];
setOpenClawCodingToolsFactoryForTests((options) => {
factoryOptions.push(options);
return [];
});
await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(factoryOptions[0]).toMatchObject({
onToolOutcome,
allocateToolOutcomeOrdinal,
});
});
it("preserves before-tool wrapping through Codex runtime normalization", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
const runtimePlan = createCodexRuntimePlanFixture();
runtimePlan.tools.normalize = (tools) => tools.map((tool) => ({ ...tool }));
params.runtimePlan = runtimePlan;
const wrappedTool = wrapToolWithBeforeToolCallHook(createRuntimeDynamicTool("web_fetch"), {
agentId: "main",
sessionId: params.sessionId,
});
setOpenClawCodingToolsFactoryForTests(() => [wrappedTool]);
const tools = await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never });
expect(tools).toHaveLength(1);
expect(tools[0]).not.toBe(wrappedTool);
expect(isToolWrappedWithBeforeToolCallHook(tools[0])).toBe(true);
});
it("passes runtime config into Codex exec dynamic tool construction", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -290,8 +290,6 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
recordToolPrepStage: (name) => {
toolBuildStages.mark(name);
},
onToolOutcome: params.onToolOutcome,
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
});
toolBuildStages.mark("create-openclaw-coding-tools");
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];

View File

@@ -183,7 +183,6 @@ describe("dynamic tool execution helpers", () => {
vi.useFakeTimers();
let capturedSignal: AbortSignal | undefined;
const onTimeout = vi.fn();
const onFallbackSelected = vi.fn();
const onAgentToolResult = vi.fn();
const response = handleDynamicToolCallWithTimeout({
call: {
@@ -203,7 +202,6 @@ describe("dynamic tool execution helpers", () => {
signal: new AbortController().signal,
timeoutMs: 1,
onAgentToolResult,
onFallbackSelected,
onTimeout,
});
@@ -219,7 +217,6 @@ describe("dynamic tool execution helpers", () => {
],
});
expect(capturedSignal?.aborted).toBe(true);
expect(onFallbackSelected).toHaveBeenCalledOnce();
expect(onTimeout).toHaveBeenCalledTimes(1);
expect(onAgentToolResult).toHaveBeenCalledWith({
toolName: "message",

View File

@@ -126,9 +126,7 @@ export async function handleDynamicToolCallWithTimeout(params: {
toolBridge: Pick<CodexDynamicToolBridge, "handleToolCall">;
signal: AbortSignal;
timeoutMs: number;
toolCallOrdinal?: number;
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
onFallbackSelected?: () => void;
onTimeout?: () => void;
}): Promise<CodexDynamicToolCallResponse> {
// Timeout or run abort can win while a tool ignores cancellation. Keep the
@@ -161,7 +159,6 @@ export async function handleDynamicToolCallWithTimeout(params: {
};
if (params.signal.aborted) {
const message = "OpenClaw dynamic tool call aborted before execution.";
params.onFallbackSelected?.();
notifyFailedToolResult(message);
return failedDynamicToolResponse(message);
}
@@ -172,7 +169,6 @@ export async function handleDynamicToolCallWithTimeout(params: {
let resolveAbort: ((response: CodexDynamicToolCallResponse) => void) | undefined;
const abortFromRun = () => {
const message = "OpenClaw dynamic tool call aborted.";
params.onFallbackSelected?.();
controller.abort(params.signal.reason ?? new Error(message));
notifyFailedToolResult(message);
resolveAbort?.(failedDynamicToolResponse(message, { sideEffectEvidence: true }));
@@ -185,7 +181,6 @@ export async function handleDynamicToolCallWithTimeout(params: {
timeout = setTimeout(() => {
timedOut = true;
const timeoutDetails = formatDynamicToolTimeoutDetails({ call: params.call, timeoutMs });
params.onFallbackSelected?.();
controller.abort(new Error(timeoutDetails.responseMessage));
params.onTimeout?.();
embeddedAgentLog.warn("codex dynamic tool call timed out", {
@@ -209,7 +204,6 @@ export async function handleDynamicToolCallWithTimeout(params: {
params.toolBridge.handleToolCall(params.call, {
signal: controller.signal,
onAgentToolResult: notifyAgentToolResult,
toolCallOrdinal: params.toolCallOrdinal,
}),
abortPromise,
timeoutPromise,

View File

@@ -6,7 +6,6 @@ import {
embeddedAgentLog,
wrapToolWithBeforeToolCallHook,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createTerminalPresentationContractTool } from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import {
onInternalDiagnosticEvent,
waitForDiagnosticEventsDrained,
@@ -225,7 +224,6 @@ describe("createCodexDynamicToolBridge", () => {
it("can register a durable tool schema while denying execution for the current turn", async () => {
const heartbeatExecute = vi.fn(async () => textToolResult("heartbeat recorded"));
const onAgentToolResult = vi.fn();
const onToolOutcome = vi.fn();
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message" })],
registeredTools: [
@@ -233,7 +231,6 @@ describe("createCodexDynamicToolBridge", () => {
createTool({ name: HEARTBEAT_RESPONSE_TOOL_NAME, execute: heartbeatExecute }),
],
signal: new AbortController().signal,
hookContext: { runId: "run-unavailable", onToolOutcome },
});
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
@@ -280,13 +277,6 @@ describe("createCodexDynamicToolBridge", () => {
},
isError: true,
});
expect(onToolOutcome).toHaveBeenLastCalledWith({
toolName: HEARTBEAT_RESPONSE_TOOL_NAME,
argsHash: "",
resultHash: "",
terminalPresentation: undefined,
presentationOnly: true,
});
});
it("keeps available and registered schemas paired with their tools", () => {
@@ -809,53 +799,6 @@ describe("createCodexDynamicToolBridge", () => {
]);
});
it("records hook-adjusted message arguments as delivery telemetry", async () => {
const beforeToolCall = vi.fn(async () => ({
params: {
action: "send",
text: "rewritten delivery",
mediaUrl: "/tmp/rewritten.png",
provider: "telegram",
to: "chat-rewritten",
},
}));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const execute = vi.fn(async () => textToolResult("Sent."));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "message", execute })],
signal: new AbortController().signal,
});
await handleMessageToolCall(bridge, { action: "status" });
expect(execute).toHaveBeenCalledWith(
expect.any(String),
{
action: "send",
text: "rewritten delivery",
mediaUrl: "/tmp/rewritten.png",
provider: "telegram",
to: "chat-rewritten",
},
expect.any(AbortSignal),
undefined,
);
expect(bridge.telemetry.messagingToolSentTexts).toEqual(["rewritten delivery"]);
expect(bridge.telemetry.messagingToolSentMediaUrls).toEqual(["/tmp/rewritten.png"]);
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
{
tool: "message",
provider: "telegram",
to: "chat-rewritten",
threadId: undefined,
text: "rewritten delivery",
mediaUrls: ["/tmp/rewritten.png"],
},
]);
});
it("records the current provider and transport thread for implicit message sends", async () => {
const hasRepliedRef = { value: false };
setActivePluginRegistry(
@@ -1408,32 +1351,6 @@ describe("createCodexDynamicToolBridge", () => {
});
});
it("keeps thrown read-only dynamic tool failures replay-safe", async () => {
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
name: "web_fetch",
execute: vi.fn(async () => {
throw new Error("backend unavailable");
}),
}),
],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "web_fetch",
arguments: { url: "https://example.com" },
});
expect(result.success).toBe(false);
expect(result.sideEffectEvidence).toBeUndefined();
});
it("preserves terminal async tool results without marking them as errors", async () => {
const bridge = createBridgeWithToolResult("image_generate", {
content: [{ type: "text", text: "Background task started." }],
@@ -1467,192 +1384,15 @@ describe("createCodexDynamicToolBridge", () => {
callId: "call-1",
namespace: null,
tool: "exec",
arguments: { command: "touch /tmp/openclaw-replay-test" },
arguments: { command: "pwd" },
});
expect(result).toEqual(expectInputText("done"));
expect(result.sideEffectEvidence).toBe(true);
});
it("omits side-effect evidence for explicitly replay-safe terminal tools", async () => {
const bridge = createBridgeWithToolResult("web_fetch", textToolResult("done"));
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "web_fetch",
arguments: { url: "https://example.com/private" },
});
expect(result).toEqual(expectInputText("done"));
expect(result.sideEffectEvidence).toBeUndefined();
});
it("shares replay-safe classification with OpenClaw for read-only dynamic tools", async () => {
const bridge = createBridgeWithToolResult("web_search", textToolResult("done"));
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-web-search",
namespace: null,
tool: "web_search",
arguments: { query: "current weather" },
});
expect(result.sideEffectEvidence).toBeUndefined();
});
it("keeps async-started read-only tools replay-unsafe", async () => {
const bridge = createBridgeWithToolResult(
"web_search",
textToolResult("Background task started.", {
async: true,
status: "started",
taskId: "task-1",
}),
);
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-async-search",
namespace: null,
tool: "web_search",
arguments: { query: "scheduler" },
});
expect(result.asyncStarted).toBe(true);
expect(result.sideEffectEvidence).toBe(true);
});
it("keeps terminal tools replay-unsafe when before_tool_call can rewrite arguments", async () => {
const beforeToolCall = vi.fn(async () => ({ params: { action: "add" } }));
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "before_tool_call", handler: beforeToolCall }]),
);
const execute = vi.fn(async () => textToolResult("done"));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "cron", execute })],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "cron",
arguments: { action: "status" },
});
expect(execute).toHaveBeenCalledWith(
"call-1",
{ action: "add" },
expect.any(AbortSignal),
undefined,
);
expect(result.sideEffectEvidence).toBe(true);
});
it("keeps executed mutations replay-unsafe when middleware rewrites the result as blocked", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async () => ({
result: textToolResult("blocked by middleware", {
status: "blocked",
deniedReason: "plugin-before-tool-call",
}),
}));
registry.agentToolResultMiddlewares.push({
pluginId: "redactor",
pluginName: "Redactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const execute = vi.fn(async () => textToolResult("added", { id: "job-1" }));
const bridge = createCodexDynamicToolBridge({
tools: [createTool({ name: "cron", execute })],
signal: new AbortController().signal,
});
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-cron-rewritten-blocked",
namespace: null,
tool: "cron",
arguments: { action: "add", job: { name: "reminder" } },
});
expect(execute).toHaveBeenCalledTimes(1);
expect(result.diagnosticTerminalType).toBe("blocked");
expect(result.sideEffectEvidence).toBe(true);
});
it("snapshots executed arguments before result middleware can mutate them", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(
async (event: { args: Record<string, unknown>; result: AgentToolResult<unknown> }) => {
event.args.action = "status";
return { result: event.result };
},
);
registry.agentToolResultMiddlewares.push({
pluginId: "mutator",
pluginName: "Mutator",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const bridge = createBridgeWithToolResult("cron", textToolResult("added", { id: "job-1" }));
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-cron-mutable-args",
namespace: null,
tool: "cron",
arguments: { action: "add", job: { name: "reminder" } },
});
expect(result.sideEffectEvidence).toBe(true);
expect(bridge.telemetry.successfulCronAdds).toBe(1);
});
it("snapshots executed arguments before after_tool_call hooks can mutate them", async () => {
const afterToolCall = vi.fn((event: unknown) => {
const eventRecord = requireRecord(event, "after_tool_call event");
const paramsRecord = requireRecord(eventRecord.params, "after_tool_call params");
paramsRecord.action = "status";
});
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_tool_call", handler: afterToolCall }]),
);
const bridge = createBridgeWithToolResult("cron", textToolResult("added", { id: "job-1" }));
const result = await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "cron",
arguments: { action: "add", job: { name: "reminder" } },
});
expect(result.sideEffectEvidence).toBe(true);
expect(bridge.telemetry.successfulCronAdds).toBe(1);
});
it("does not mark pre-execution argument failures as side-effect evidence", async () => {
const execute = vi.fn(async () => textToolResult("should not run"));
const onToolOutcome = vi.fn();
const bridge = createCodexDynamicToolBridge({
tools: [
createTool({
@@ -1666,7 +1406,6 @@ describe("createCodexDynamicToolBridge", () => {
}),
],
signal: new AbortController().signal,
hookContext: { runId: "run-invalid-arguments", onToolOutcome },
});
const result = await bridge.handleToolCall({
@@ -1684,13 +1423,6 @@ describe("createCodexDynamicToolBridge", () => {
});
expect(result.sideEffectEvidence).toBeUndefined();
expect(execute).not.toHaveBeenCalled();
expect(onToolOutcome).toHaveBeenLastCalledWith({
toolName: "exec",
argsHash: "",
resultHash: "",
terminalPresentation: undefined,
presentationOnly: true,
});
});
it("uses raw tool provenance for media trust after middleware rewrites details", async () => {
@@ -2066,7 +1798,7 @@ describe("createCodexDynamicToolBridge", () => {
const handler = vi.fn(
async (event: { args: Record<string, unknown>; result: AgentToolResult<unknown> }) => {
events.push("middleware");
expect(event.args).toEqual({ command: "status", mode: "safe" });
expect(event.args).toEqual({ command: "status" });
return {
result: {
...event.result,
@@ -2110,118 +1842,6 @@ describe("createCodexDynamicToolBridge", () => {
});
});
it("builds terminal presentation from the post-middleware result", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async () => ({
result: textToolResult("redacted output", {
origin: "redacted.example",
status: 200,
}),
}));
registry.agentToolResultMiddlewares.push({
pluginId: "redactor",
pluginName: "Redactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const onToolOutcome = vi.fn();
const tool = createTerminalPresentationContractTool({
name: "web_fetch",
result: textToolResult("raw output", {
origin: "private.example",
status: 200,
}),
format: (_params, result) => {
const details = requireRecord(result.details, "terminal presentation details");
return `Origin: ${String(details.origin)}\nStatus: ${String(details.status)}`;
},
});
const bridge = createCodexDynamicToolBridge({
tools: [tool],
signal: new AbortController().signal,
hookContext: {
runId: "run-terminal-middleware",
sessionId: "session-terminal-middleware",
onToolOutcome,
},
});
await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-terminal-middleware",
namespace: null,
tool: "web_fetch",
arguments: { url: "https://private.example" },
});
expect(onToolOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({
presentationOnly: true,
terminalPresentation: "Origin: redacted.example\nStatus: 200",
}),
);
});
it("clears raw terminal presentation when middleware returns an error", async () => {
const registry = createEmptyPluginRegistry();
const handler = vi.fn(async () => ({
result: textToolResult("output blocked by middleware", {
status: "error",
middlewareError: true,
}),
}));
registry.agentToolResultMiddlewares.push({
pluginId: "redactor",
pluginName: "Redactor",
rawHandler: handler,
handler,
runtimes: ["codex"],
source: "test",
});
setActivePluginRegistry(registry);
const onToolOutcome = vi.fn();
const tool = createTerminalPresentationContractTool({
name: "web_fetch",
result: textToolResult("raw output", {
origin: "private.example",
status: 200,
}),
format: (_params, result) => {
const details = requireRecord(result.details, "terminal presentation details");
return `Origin: ${String(details.origin)}`;
},
});
const bridge = createCodexDynamicToolBridge({
tools: [tool],
signal: new AbortController().signal,
hookContext: {
runId: "run-terminal-middleware-error",
sessionId: "session-terminal-middleware-error",
onToolOutcome,
},
});
await bridge.handleToolCall({
threadId: "thread-1",
turnId: "turn-1",
callId: "call-terminal-middleware-error",
namespace: null,
tool: "web_fetch",
arguments: { url: "https://private.example" },
});
expect(onToolOutcome).toHaveBeenLastCalledWith(
expect.objectContaining({
presentationOnly: true,
terminalPresentation: undefined,
}),
);
});
it("reports dynamic tool execution errors through after_tool_call without stranding the turn", async () => {
const beforeToolCall = vi.fn(async () => ({ params: { timeoutSec: 1 } }));
const afterToolCall = vi.fn();

View File

@@ -4,21 +4,15 @@
*/
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import {
consumeAdjustedParamsForToolCall,
consumePreExecutionBlockedToolCall,
createAgentToolResultMiddlewareRunner,
createCodexAppServerToolResultExtensionRunner,
extractMessagingToolSend,
extractMessagingToolSendResult,
extractToolResultMediaArtifact,
filterToolResultMediaUrls,
finalizeToolTerminalPresentation,
HEARTBEAT_RESPONSE_TOOL_NAME,
embeddedAgentLog,
getChannelAgentToolMeta,
getPluginToolMeta,
type EmbeddedRunAttemptParams,
isReplaySafeToolCall,
isToolWrappedWithBeforeToolCallHook,
isToolResultError,
isMessagingTool,
@@ -65,8 +59,6 @@ type CodexDynamicToolHookContext = {
currentThreadId?: string;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
};
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
@@ -108,7 +100,6 @@ export type CodexDynamicToolBridge = {
options?: {
signal?: AbortSignal;
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
toolCallOrdinal?: number;
},
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
@@ -187,13 +178,6 @@ export function createCodexDynamicToolBridge(params: {
runtime: "codex",
...toolResultHookContext,
});
const isReplaySafeToolInstance = (tool: AnyAgentTool): boolean => {
const pluginMeta = getPluginToolMeta(tool);
if (pluginMeta) {
return pluginMeta.replaySafe === true;
}
return getChannelAgentToolMeta(tool as never) === undefined;
};
const legacyExtensionRunner =
createCodexAppServerToolResultExtensionRunner(toolResultHookContext);
const directToolNames = new Set([
@@ -222,15 +206,6 @@ export function createCodexDynamicToolBridge(params: {
const message = registeredToolNames.has(call.tool)
? `OpenClaw tool is not available for this turn: ${call.tool}`
: `Unknown OpenClaw tool: ${call.tool}`;
finalizeToolTerminalPresentation({
toolCallId: call.callId,
runId: toolResultHookContext.runId,
result: failedToolResult(message),
isError: true,
observer: params.hookContext?.onToolOutcome,
toolName: call.tool,
toolCallOrdinal: options?.toolCallOrdinal,
});
notifyAgentToolResult(
options?.onAgentToolResult,
call.tool,
@@ -258,45 +233,40 @@ export function createCodexDynamicToolBridge(params: {
const startedAt = Date.now();
const signal = composeAbortSignals(params.signal, options?.signal);
let didStartExecution = false;
let executionPrevented = false;
let executedArgs = structuredClone(args);
try {
// Prepare before marking side-effect evidence; argument preparation can
// fail without the target tool actually starting.
const preparedArgs = tool.prepareArguments ? tool.prepareArguments(args) : args;
const telemetryArgs = isRecord(preparedArgs) ? preparedArgs : args;
executedArgs = structuredClone(telemetryArgs);
const messagingContext = {
config: params.hookContext?.config,
currentChannelId: params.hookContext?.currentChannelId,
currentMessagingTarget: params.hookContext?.currentMessagingTarget,
currentThreadId: params.hookContext?.currentThreadId,
replyToMode: params.hookContext?.replyToMode,
hasRepliedRef: params.hookContext?.hasRepliedRef
? { value: params.hookContext.hasRepliedRef.value }
: undefined,
};
const messagingTelemetryArgs = applyCurrentMessageProvider(
toolName,
telemetryArgs,
params.hookContext?.currentChannelProvider,
);
const messagingTarget =
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, telemetryArgs)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, {
config: params.hookContext?.config,
currentChannelId: params.hookContext?.currentChannelId,
currentMessagingTarget: params.hookContext?.currentMessagingTarget,
currentThreadId: params.hookContext?.currentThreadId,
replyToMode: params.hookContext?.replyToMode,
hasRepliedRef: params.hookContext?.hasRepliedRef,
})
: undefined;
didStartExecution = true;
const rawResult = await tool.execute(call.callId, preparedArgs, signal);
const adjustedExecutedArgs = consumeAdjustedParamsForToolCall(
call.callId,
toolResultHookContext.runId,
);
if (isRecord(adjustedExecutedArgs)) {
executedArgs = structuredClone(adjustedExecutedArgs);
}
executionPrevented = consumePreExecutionBlockedToolCall(
call.callId,
toolResultHookContext.runId,
);
const telemetryRawResult = sanitizeToolResult(rawResult);
const rawIsError = isCodexToolResultError(rawResult);
const confirmedMessagingTarget =
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, rawResult)
: messagingTarget;
const middlewareResult = await middlewareRunner.applyToolResultMiddleware({
threadId: call.threadId,
turnId: call.turnId,
toolCallId: call.callId,
toolName,
args: structuredClone(executedArgs),
args,
isError: rawIsError,
result: rawResult,
});
@@ -305,11 +275,20 @@ export function createCodexDynamicToolBridge(params: {
turnId: call.turnId,
toolCallId: call.callId,
toolName,
args: structuredClone(executedArgs),
args,
result: middlewareResult,
});
const resultIsError = rawIsError || isCodexToolResultError(result);
notifyAgentToolResult(options?.onAgentToolResult, toolName, result, resultIsError);
collectToolTelemetry({
toolName,
args: telemetryArgs,
result,
mediaTrustResult: rawResult,
telemetry,
isError: resultIsError,
messagingTarget: confirmedMessagingTarget,
});
void runAgentHarnessAfterToolCallHook({
toolName,
toolCallId: call.callId,
@@ -318,41 +297,10 @@ export function createCodexDynamicToolBridge(params: {
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
startArgs: executedArgs,
startArgs: args,
result,
startedAt,
});
finalizeToolTerminalPresentation({
toolCallId: call.callId,
runId: toolResultHookContext.runId,
result,
isError: resultIsError,
observer: params.hookContext?.onToolOutcome,
toolName,
toolCallOrdinal: options?.toolCallOrdinal,
});
const messagingTelemetryArgs = applyCurrentMessageProvider(
toolName,
executedArgs,
params.hookContext?.currentChannelProvider,
);
const messagingTarget =
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
: undefined;
const confirmedMessagingTarget =
!rawIsError && messagingTarget
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
: messagingTarget;
collectToolTelemetry({
toolName,
args: executedArgs,
result,
mediaTrustResult: telemetryRawResult,
telemetry,
isError: resultIsError,
messagingTarget: confirmedMessagingTarget,
});
const terminalType = inferToolResultDiagnosticTerminalType(result, resultIsError);
const response = withDiagnosticTerminalType(
{
@@ -368,41 +316,22 @@ export function createCodexDynamicToolBridge(params: {
isToolResultYield(rawResult) ||
isToolResultYield(result),
);
const asyncStarted =
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
withDynamicToolAsyncStarted(response, asyncStarted);
const replaySafe =
executionPrevented ||
(!asyncStarted &&
isReplaySafeToolInstance(toolEntry.tool) &&
isReplaySafeToolCall(toolName, executedArgs));
return withSideEffectEvidence(response, !replaySafe);
withDynamicToolAsyncStarted(
response,
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result),
);
return withSideEffectEvidence(response, terminalType !== "blocked");
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
const adjustedExecutedArgs = consumeAdjustedParamsForToolCall(
call.callId,
toolResultHookContext.runId,
);
if (isRecord(adjustedExecutedArgs)) {
executedArgs = structuredClone(adjustedExecutedArgs);
}
executionPrevented =
executionPrevented ||
consumePreExecutionBlockedToolCall(call.callId, toolResultHookContext.runId);
const failedResult = failedToolResult(errorMessage);
finalizeToolTerminalPresentation({
toolCallId: call.callId,
runId: toolResultHookContext.runId,
result: failedResult,
isError: true,
observer: params.hookContext?.onToolOutcome,
notifyAgentToolResult(
options?.onAgentToolResult,
toolName,
toolCallOrdinal: options?.toolCallOrdinal,
});
notifyAgentToolResult(options?.onAgentToolResult, toolName, failedResult, true);
failedToolResult(errorMessage),
true,
);
collectToolTelemetry({
toolName,
args: executedArgs,
args,
result: undefined,
telemetry,
isError: true,
@@ -415,15 +344,10 @@ export function createCodexDynamicToolBridge(params: {
sessionId: toolResultHookContext.sessionId,
sessionKey: toolResultHookContext.sessionKey,
channelId: toolResultHookContext.channelId,
startArgs: executedArgs,
startArgs: args,
error: errorMessage,
startedAt,
});
const replaySafe =
!didStartExecution ||
executionPrevented ||
(isReplaySafeToolInstance(toolEntry.tool) &&
isReplaySafeToolCall(toolName, executedArgs));
return withSideEffectEvidence(
withDiagnosticTerminalType(
{
@@ -437,7 +361,7 @@ export function createCodexDynamicToolBridge(params: {
},
"error",
),
didStartExecution && !replaySafe,
didStartExecution,
);
}
},
@@ -750,7 +674,7 @@ function collectToolTelemetry(params: {
toolName: string;
args: Record<string, unknown>;
result: AgentToolResult<unknown> | undefined;
mediaTrustResult?: unknown;
mediaTrustResult?: AgentToolResult<unknown>;
telemetry: CodexDynamicToolBridge["telemetry"];
isError: boolean;
messagingTarget?: MessagingToolSend;

View File

@@ -309,7 +309,6 @@ describe("CodexAppServerEventProjector", () => {
expect(result.assistantTexts).toEqual(["hello"]);
expect(result.messagesSnapshot.map((message) => message.role)).toEqual(["user", "assistant"]);
expect(result.lastAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "hello" }]);
expectUsageFields(result.attemptUsage, { input: 3, output: 7, cacheRead: 2, total: 12 });
expectUsageFields(result.lastAssistant?.usage, {
input: 3,
@@ -752,7 +751,6 @@ describe("CodexAppServerEventProjector", () => {
expect(result.assistantTexts).toEqual([]);
expect(result.lastAssistant).toBeUndefined();
expect(result.currentAttemptAssistant).toBeUndefined();
});
it("does not treat app-server interrupted status as a user cancellation by itself", async () => {
@@ -1054,34 +1052,6 @@ describe("CodexAppServerEventProjector", () => {
expect(JSON.stringify(result.messagesSnapshot)).not.toContain("checking thread context");
});
it("preserves an empty final assistant item after tool activity", async () => {
const projector = await createProjector();
projector.recordDynamicToolCall({
callId: "call-search",
tool: "memory_search",
arguments: { query: "scheduler" },
});
projector.recordDynamicToolResult({
callId: "call-search",
tool: "memory_search",
success: true,
sideEffectEvidence: false,
contentItems: [{ type: "inputText", text: "no matches" }],
});
await projector.handleNotification(
turnCompleted([
{ type: "agentMessage", id: "msg-before-tool", text: "Checking the scheduler now." },
{ type: "agentMessage", id: "msg-final", text: "" },
]),
);
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.assistantTexts).toEqual(["Checking the scheduler now."]);
expect(result.currentAttemptAssistant?.content).toEqual([{ type: "text", text: "" }]);
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
});
it("streams commentary agent messages as keyed progress events", async () => {
const onAgentEvent = vi.fn();
const onPartialReply = vi.fn();
@@ -2708,235 +2678,7 @@ describe("CodexAppServerEventProjector", () => {
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
});
it("clears a blocked dynamic tool outcome after the next successful tool", async () => {
const projector = await createProjector();
projector.recordDynamicToolResult({
callId: "call-cron-blocked",
tool: "cron",
success: false,
terminalType: "blocked",
contentItems: [{ type: "inputText", text: "blocked by policy" }],
});
expect(projector.buildResult(buildEmptyToolTelemetry()).lastToolError).toEqual({
toolName: "cron",
error: "blocked by policy",
});
projector.recordDynamicToolResult({
callId: "call-web-fetch-recovered",
tool: "web_fetch",
success: true,
terminalType: "completed",
contentItems: [{ type: "inputText", text: "fetch ok" }],
});
expect(projector.buildResult(buildEmptyToolTelemetry()).lastToolError).toBeUndefined();
});
it.each([
{
command: "/bin/zsh -lc 'rg -n TODO src'",
commandActions: [{ type: "search", command: "rg -n TODO src", query: "TODO", path: "src" }],
},
{
command: "/bin/zsh -lc 'cat package.json'",
commandActions: [
{ type: "read", command: "cat package.json", name: "cat", path: "/workspace/package.json" },
],
},
{
command: "/bin/zsh -lc 'touch changed.txt'",
commandActions: [{ type: "unknown", command: "touch changed.txt" }],
},
])(
"treats native command actions as replay-unsafe: $command",
async ({ command, commandActions }) => {
const projector = await createProjector();
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "commandExecution",
id: "command-native",
command,
cwd: "/workspace",
processId: null,
source: "agent",
status: "completed",
commandActions,
aggregatedOutput: "",
exitCode: 0,
durationMs: 1,
},
}),
);
expect(projector.buildResult(buildEmptyToolTelemetry()).replayMetadata).toEqual({
hadPotentialSideEffects: true,
replaySafe: false,
});
},
);
it("clears a prior terminal presentation after a native tool completes", async () => {
let terminalPresentation: string | undefined = "stale web fetch";
const projector = await createProjector({
...(await createParams()),
onToolOutcome: (observation) => {
terminalPresentation = observation.terminalPresentation;
},
});
const item = {
type: "commandExecution",
id: "command-clear-presentation",
command: "git status --short",
cwd: "/workspace",
processId: null,
source: "agent",
status: "completed",
commandActions: [{ type: "unknown", command: "git status --short" }],
aggregatedOutput: "",
exitCode: 0,
durationMs: 1,
};
await projector.handleNotification(forCurrentTurn("item/started", { item }));
await projector.handleNotification(
forCurrentTurn("item/completed", {
item,
}),
);
expect(terminalPresentation).toBeUndefined();
});
it("clears a prior terminal presentation after an unprojected native tool completes", async () => {
let terminalPresentation: string | undefined = "stale web fetch";
const projector = await createProjector({
...(await createParams()),
onToolOutcome: (observation) => {
terminalPresentation = observation.terminalPresentation;
},
});
await projector.handleNotification(
turnCompleted([
{
type: "imageView",
id: "image-view-clear-presentation",
path: "/workspace/reference.png",
},
{
type: "dynamicToolCall",
id: "stale-dynamic-tool",
turnId: "turn-old",
tool: "web_fetch",
status: "completed",
},
]),
);
expect(terminalPresentation).toBeUndefined();
});
it("keeps a later dynamic presentation over an earlier snapshot-only native tool", async () => {
let terminalPresentation: string | undefined = "later dynamic result";
let latestOrdinal = 1;
let nextOrdinal = 0;
const projector = await createProjector({
...(await createParams()),
allocateToolOutcomeOrdinal: () => nextOrdinal++,
onToolOutcome: (observation) => {
const ordinal = observation.toolCallOrdinal ?? latestOrdinal + 1;
if (ordinal >= latestOrdinal) {
latestOrdinal = ordinal;
terminalPresentation = observation.terminalPresentation;
}
},
});
const nativeItem = {
type: "imageView",
id: "image-view-before-dynamic",
path: "/workspace/reference.png",
};
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: nativeItem,
}),
);
await projector.handleNotification(
turnCompleted([
nativeItem,
{
type: "dynamicToolCall",
id: "dynamic-after-image-view",
turnId: TURN_ID,
tool: "web_fetch",
status: "completed",
},
{
type: "imageView",
id: "stale-image-view",
turnId: "turn-old",
path: "/workspace/stale.png",
},
]),
);
expect(terminalPresentation).toBe("later dynamic result");
});
it("clears a prior presentation for a completion-only native item without a turn snapshot", async () => {
let terminalPresentation: string | undefined = "stale dynamic result";
let nextOrdinal = 1;
const projector = await createProjector({
...(await createParams()),
allocateToolOutcomeOrdinal: () => nextOrdinal++,
onToolOutcome: (observation) => {
terminalPresentation = observation.terminalPresentation;
},
});
await projector.handleNotification(
forCurrentTurn("item/completed", {
item: {
type: "imageView",
id: "completion-only-image-view",
path: "/workspace/reference.png",
},
}),
);
await projector.handleNotification(turnCompleted([]));
expect(terminalPresentation).toBeUndefined();
});
it("treats native image generation without a saved path as side-effect evidence", async () => {
const projector = await createProjector();
await projector.handleNotification(
turnCompleted([
{
type: "imageGeneration",
id: "image-generation-side-effect",
status: "completed",
revisedPrompt: null,
result: "generated-image-result",
},
]),
);
expect(projector.buildResult(buildEmptyToolTelemetry()).replayMetadata).toEqual({
hadPotentialSideEffects: true,
replaySafe: false,
});
});
it("keeps executed dynamic tools side-effecting when their result is rewritten as blocked", async () => {
it("does not mark blocked dynamic tools as side-effecting", async () => {
const projector = await createProjector();
projector.recordDynamicToolCall({
@@ -2955,7 +2697,7 @@ describe("CodexAppServerEventProjector", () => {
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: false, replaySafe: true });
});
it("treats completed native MCP tool calls as side-effect evidence", async () => {
@@ -2982,34 +2724,6 @@ describe("CodexAppServerEventProjector", () => {
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
});
it("treats native collaboration calls as side-effect evidence", async () => {
const projector = await createProjector();
await projector.handleNotification({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
id: "collab-1",
type: "collabAgentToolCall",
tool: "spawnAgent",
status: "completed",
senderThreadId: "thread-1",
receiverThreadIds: ["child-thread-1"],
prompt: "Inspect the replay path",
model: null,
reasoningEffort: null,
agentsStates: {},
},
},
});
const result = projector.buildResult(buildEmptyToolTelemetry());
expect(result.replayMetadata).toEqual({ hadPotentialSideEffects: true, replaySafe: false });
});
it("suppresses transcript progress for message-like tools", async () => {
const onAgentEvent = vi.fn();
const onToolResult = vi.fn();

View File

@@ -170,8 +170,6 @@ export class CodexAppServerEventProjector {
string,
{ toolName: string; meta?: string; asyncStarted?: boolean }
>();
private readonly terminalPresentationClearedItemIds = new Set<string>();
private readonly nativeToolOutcomeOrdinals = new Map<string, number>();
private readonly sideEffectingToolItemIds = new Set<string>();
private readonly sideEffectingDynamicToolCallIds = new Set<string>();
private readonly toolTranscriptMessages: AgentMessage[] = [];
@@ -218,21 +216,6 @@ export class CodexAppServerEventProjector {
return finalItem !== undefined && this.completedItemIds.has(finalItem.itemId);
}
/** Resolves the shared model-order position for a native tool item. */
recordNativeToolOutcome(item: CodexThreadItem | undefined): void {
if (
!item ||
this.nativeToolOutcomeOrdinals.has(item.id) ||
!shouldClearTerminalPresentationForNativeItem(item)
) {
return;
}
const ordinal = this.params.allocateToolOutcomeOrdinal?.(item.id);
if (ordinal !== undefined) {
this.nativeToolOutcomeOrdinals.set(item.id, ordinal);
}
}
async handleNotification(notification: CodexServerNotification): Promise<void> {
const params = isJsonObject(notification.params) ? notification.params : undefined;
if (!params) {
@@ -330,7 +313,6 @@ export class CodexAppServerEventProjector {
assistantTexts.length > 0
? this.createAssistantMessage(assistantTexts.join("\n\n"))
: undefined;
const currentAttemptAssistant = this.createCurrentAttemptAssistantMessage();
// Each snapshot entry is tagged with a stable mirror identity of the
// shape `${turnId}:${kind}`. The mirror's idempotency key is derived
// from this identity rather than from snapshot position or content
@@ -403,7 +385,6 @@ export class CodexAppServerEventProjector {
assistantTexts,
toolMetas,
lastAssistant,
currentAttemptAssistant,
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
@@ -451,7 +432,6 @@ export class CodexAppServerEventProjector {
sideEffectEvidence?: boolean;
contentItems: CodexDynamicToolCallOutputContentItem[];
}): void {
const resultText = collectDynamicToolContentText(params.contentItems);
if (params.asyncStarted === true) {
const existing = this.toolMetas.get(params.callId);
this.toolMetas.set(params.callId, {
@@ -463,22 +443,11 @@ export class CodexAppServerEventProjector {
this.recordToolTranscriptResult({
id: params.callId,
name: params.tool,
text: resultText,
text: collectDynamicToolContentText(params.contentItems),
isError: !params.success,
});
if (!params.success && params.terminalType === "blocked") {
this.lastNativeToolError = {
toolName: params.tool,
error: resultText || "codex dynamic tool blocked",
};
} else if (
params.success &&
this.lastNativeToolError &&
!this.lastNativeToolError.mutatingAction
) {
this.lastNativeToolError = undefined;
}
if (params.sideEffectEvidence === true) {
const terminalType = params.terminalType ?? (params.success ? "completed" : "error");
if (terminalType !== "blocked" && params.sideEffectEvidence === true) {
this.sideEffectingDynamicToolCallIds.add(params.callId);
}
}
@@ -599,7 +568,6 @@ export class CodexAppServerEventProjector {
if (itemId) {
this.activeItemIds.add(itemId);
}
this.recordNativeToolOutcome(item);
if (item?.type === "contextCompaction" && itemId) {
this.activeCompactionItemIds.add(itemId);
await runAgentHarnessBeforeCompactionHook({
@@ -640,18 +608,16 @@ export class CodexAppServerEventProjector {
private async handleItemCompleted(params: JsonObject): Promise<void> {
const item = readItem(params.item);
this.recordNativeToolOutcome(item);
this.clearTerminalPresentationForNativeItem(item);
const itemId = item?.id ?? readString(params, "itemId") ?? readString(params, "id");
if (itemId) {
this.activeItemIds.delete(itemId);
this.completedItemIds.add(itemId);
}
this.rememberAssistantPhase(item);
if (item?.type === "agentMessage" && typeof item.text === "string") {
if (item?.type === "agentMessage" && typeof item.text === "string" && item.text) {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
if (item.text && this.isCommentaryAssistantItem(item.id)) {
if (this.isCommentaryAssistantItem(item.id)) {
this.emitCommentaryProgress({ itemId: item.id, text: item.text });
}
}
@@ -784,25 +750,9 @@ export class CodexAppServerEventProjector {
"codex app-server turn failed";
this.promptErrorSource = "prompt";
}
const turnItems = turn.items ?? [];
// The final snapshot is authoritative when item notifications were omitted.
// Only its last relevant tool may change the terminal presentation.
for (let index = turnItems.length - 1; index >= 0; index -= 1) {
const item = turnItems[index];
if (!item || !this.isCurrentTurnSnapshotItem(item)) {
continue;
}
if (item?.type === "dynamicToolCall") {
break;
}
if (shouldClearTerminalPresentationForNativeItem(item)) {
this.clearTerminalPresentationForNativeItem(item);
break;
}
}
for (const item of turnItems) {
for (const item of turn.items ?? []) {
this.rememberAssistantPhase(item);
if (item.type === "agentMessage" && typeof item.text === "string") {
if (item.type === "agentMessage" && typeof item.text === "string" && item.text) {
this.rememberAssistantItem(item.id);
this.assistantTextByItem.set(item.id, item.text);
}
@@ -1157,26 +1107,6 @@ export class CodexAppServerEventProjector {
}
}
private clearTerminalPresentationForNativeItem(item: CodexThreadItem | undefined): void {
if (
!item ||
this.terminalPresentationClearedItemIds.has(item.id) ||
!shouldClearTerminalPresentationForNativeItem(item)
) {
return;
}
const toolCallOrdinal = this.nativeToolOutcomeOrdinals.get(item.id);
this.terminalPresentationClearedItemIds.add(item.id);
this.params.onToolOutcome?.({
toolName: itemName(item) ?? item.type,
argsHash: "",
resultHash: "",
...(toolCallOrdinal !== undefined ? { toolCallOrdinal } : {}),
terminalPresentation: undefined,
presentationOnly: true,
});
}
private recordNativeToolError(params: {
item: CodexThreadItem;
name: string;
@@ -1441,11 +1371,6 @@ export class CodexAppServerEventProjector {
if (!item) {
return;
}
if (isSideEffectingNativeToolItem(item)) {
this.sideEffectingToolItemIds.add(item.id);
} else {
this.sideEffectingToolItemIds.delete(item.id);
}
const toolName = itemName(item);
if (!toolName) {
return;
@@ -1457,6 +1382,11 @@ export class CodexAppServerEventProjector {
...(meta ? { meta } : {}),
...(existing?.asyncStarted ? { asyncStarted: true } : {}),
});
if (isSideEffectingNativeToolItem(item)) {
this.sideEffectingToolItemIds.add(item.id);
} else {
this.sideEffectingToolItemIds.delete(item.id);
}
}
private recordNativeToolTranscriptCall(item: CodexThreadItem | undefined): void {
@@ -1760,26 +1690,6 @@ export class CodexAppServerEventProjector {
this.assistantItemOrder.push(itemId);
}
private createCurrentAttemptAssistantMessage(): AssistantMessage | undefined {
for (let i = this.assistantItemOrder.length - 1; i >= 0; i -= 1) {
const itemId = this.assistantItemOrder[i];
if (
!itemId ||
this.isCommentaryAssistantItem(itemId) ||
!this.assistantTextByItem.has(itemId)
) {
continue;
}
const text = this.assistantTextByItem.get(itemId) ?? "";
const normalizedText = text.trim();
if (normalizedText && this.toolProgressTexts.has(normalizedText)) {
continue;
}
return this.createAssistantMessage(text);
}
return undefined;
}
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
}
@@ -2236,31 +2146,7 @@ function shouldRecordNativeToolTranscript(item: CodexThreadItem): boolean {
}
function isMutatingNativeToolItem(item: CodexThreadItem): boolean {
if (item.type === "commandExecution") {
// Codex commandActions describe presentation, not safety. Upstream may
// classify mutating commands as read/search, so native commands fail closed.
return true;
}
return (
item.type === "fileChange" ||
item.type === "collabAgentToolCall" ||
item.type === "imageGeneration"
);
}
function shouldClearTerminalPresentationForNativeItem(item: CodexThreadItem): boolean {
switch (item.type) {
case "collabAgentToolCall":
case "commandExecution":
case "fileChange":
case "imageGeneration":
case "imageView":
case "mcpToolCall":
case "webSearch":
return true;
default:
return false;
}
return item.type === "commandExecution" || item.type === "fileChange";
}
function nativeToolActionFingerprint(item: CodexThreadItem): string | undefined {

View File

@@ -2,7 +2,6 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core";
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness";
import { classifyEmbeddedAgentRunResultForModelFallback } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
@@ -10,8 +9,7 @@ import {
OUTCOME_FALLBACK_RUNTIME_CONTRACT,
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import { afterEach, describe, expect, it } from "vitest";
import {
CodexAppServerEventProjector,
type CodexAppServerToolTelemetry,
@@ -97,7 +95,6 @@ function readMirrorIdentity(message: unknown): string | undefined {
}
afterEach(async () => {
vi.restoreAllMocks();
for (const tempDir of tempDirs) {
await fs.rm(tempDir, { recursive: true, force: true });
}
@@ -405,60 +402,4 @@ describe("Outcome/fallback runtime contract - Codex app-server adapter", () => {
expect(result.agentHarnessResultClassification).toBeUndefined();
expect(classifyProjectedAttemptResult(result)).toBeNull();
});
it.each([
{ action: "status", replaySafe: true },
{ action: "add", replaySafe: false },
])(
"classifies an empty Codex turn after cron.$action from structured replay safety",
async ({ action, replaySafe }) => {
const toolResult: AgentToolResult<unknown> = {
content: [{ type: "text", text: "cron complete" }],
details: { ok: true },
};
const bridge = createCodexDynamicToolBridge({
tools: [
{
name: "cron",
description: "Cron",
parameters: { type: "object", properties: {} },
execute: vi.fn(async () => toolResult),
} as never,
],
signal: new AbortController().signal,
});
const projector = await createProjector();
const call = {
threadId: THREAD_ID,
turnId: TURN_ID,
callId: `call-cron-${action}`,
namespace: null,
tool: "cron",
arguments: { action },
};
projector.recordDynamicToolCall(call);
const response = await bridge.handleToolCall(call);
projector.recordDynamicToolResult({
callId: call.callId,
tool: call.tool,
success: response.success,
terminalType: response.diagnosticTerminalType,
sideEffectEvidence: response.sideEffectEvidence === true,
contentItems: response.contentItems,
});
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: { id: TURN_ID, status: "completed", items: [] },
}),
);
const result = projector.buildResult(bridge.telemetry);
expect(result.replayMetadata).toEqual({
hadPotentialSideEffects: !replaySafe,
replaySafe,
});
expect(classifyProjectedAttemptResult(result) !== null).toBe(replaySafe);
},
);
});

View File

@@ -1,14 +1,6 @@
// Codex tests cover run attemptynamic tools plugin behavior.
import path from "node:path";
import {
onAgentEvent,
wrapToolWithBeforeToolCallHook,
type AgentEventPayload,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
createTerminalPresentationContractTool,
textToolResult,
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { onAgentEvent, type AgentEventPayload } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
emitTrustedDiagnosticEvent,
onInternalDiagnosticEvent,
@@ -26,7 +18,6 @@ import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import type { CodexDynamicToolCallParams } from "./protocol.js";
import {
createParams,
createCodexRuntimePlanFixture,
createRuntimeDynamicTool,
createStartedThreadHarness,
runCodexAppServerAttempt,
@@ -62,246 +53,6 @@ function activeDiagnosticToolKeys(events: DiagnosticEventPayload[]): Set<string>
setupRunAttemptTestHooks();
describe("runCodexAppServerAttempt dynamic tools", () => {
it("preserves model order across queued native and dynamic tools", async () => {
let rejectSlowTool!: (error: Error) => void;
const slowToolResult = new Promise<never>((_resolve, reject) => {
rejectSlowTool = reject;
});
const slowTool = createRuntimeDynamicTool("slow_failure");
slowTool.execute = vi.fn(() => slowToolResult);
const laterTool = createTerminalPresentationContractTool({
name: "fast_summary",
result: textToolResult("fast result"),
format: () => "later dynamic summary",
});
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
let terminalPresentation: string | undefined;
let latestOrdinal = -1;
let nextOrdinal = 0;
const onExecutionPhase = vi.fn();
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.allocateToolOutcomeOrdinal = () => nextOrdinal++;
params.onExecutionPhase = onExecutionPhase;
params.onToolOutcome = (observation) => {
const ordinal = observation.toolCallOrdinal ?? latestOrdinal + 1;
if (ordinal >= latestOrdinal) {
latestOrdinal = ordinal;
terminalPresentation = observation.terminalPresentation;
}
};
testing.setOpenClawCodingToolsFactoryForTests((options) =>
[slowTool, laterTool].map((tool) =>
wrapToolWithBeforeToolCallHook(tool, {
runId: options?.runId,
sessionId: options?.sessionId,
sessionKey: options?.sessionKey,
onToolOutcome: options?.onToolOutcome,
allocateToolOutcomeOrdinal: options?.allocateToolOutcomeOrdinal,
}),
),
);
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("thread/start");
await vi.waitFor(() =>
expect(onExecutionPhase).toHaveBeenCalledWith(
expect.objectContaining({ phase: "turn_accepted" }),
),
);
for (const item of [
{
type: "function_call",
name: "slow_failure",
arguments: "{}",
call_id: "call-slow",
},
{
type: "function_call",
name: "shell_command",
arguments: '{"command":"git status --short"}',
call_id: "command-before-dynamic",
},
]) {
await harness.notify({
method: "rawResponseItem/completed",
params: { threadId: "thread-1", turnId: "turn-1", item },
});
}
const webSearchItem = {
type: "webSearch",
id: "web-search-before-dynamic",
query: "OpenClaw",
status: "completed",
durationMs: 1,
};
const webSearchStarted = harness.notify({
method: "item/started",
params: { threadId: "thread-1", turnId: "turn-1", item: webSearchItem },
});
const rawWebSearch = harness.notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "web_search_call",
status: "completed",
action: { type: "search", query: "OpenClaw" },
},
},
});
await harness.notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "function_call",
name: "fast_summary",
arguments: "{}",
call_id: "call-later",
},
},
});
await rawWebSearch;
const slowCall = harness.handleServerRequest({
id: "request-slow",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-slow",
namespace: null,
tool: "slow_failure",
arguments: {},
},
});
const nativeItem = {
type: "commandExecution",
id: "command-before-dynamic",
command: "git status --short",
cwd: "/workspace",
processId: null,
source: "agent",
status: "completed",
commandActions: [{ type: "unknown", command: "git status --short" }],
aggregatedOutput: "",
exitCode: 0,
durationMs: 1,
};
const nativeStarted = harness.notify({
method: "item/started",
params: { threadId: "thread-1", turnId: "turn-1", item: nativeItem },
});
await harness.handleServerRequest({
id: "request-later",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-later",
namespace: null,
tool: "fast_summary",
arguments: {},
},
});
await nativeStarted;
await webSearchStarted;
await harness.notify({
method: "item/completed",
params: { threadId: "thread-1", turnId: "turn-1", item: nativeItem },
});
await harness.notify({
method: "item/completed",
params: { threadId: "thread-1", turnId: "turn-1", item: webSearchItem },
});
rejectSlowTool(new Error("slow failure"));
await slowCall;
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(terminalPresentation).toBe("later dynamic summary");
});
it("suppresses a late dynamic tool presentation after its timeout response", async () => {
let resolveSlowTool!: (result: ReturnType<typeof textToolResult>) => void;
const slowToolResult = new Promise<ReturnType<typeof textToolResult>>((resolve) => {
resolveSlowTool = resolve;
});
const formatTerminalPresentation = vi.fn(() => "late success summary");
const slowTool = createTerminalPresentationContractTool({
name: "slow_summary",
result: textToolResult("unused"),
format: formatTerminalPresentation,
});
slowTool.execute = vi.fn(() => slowToolResult);
const harness = createStartedThreadHarness();
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
let terminalPresentation: string | undefined = "previous summary";
let latestOrdinal = -1;
let nextOrdinal = 0;
const onExecutionPhase = vi.fn();
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.allocateToolOutcomeOrdinal = () => nextOrdinal++;
params.onExecutionPhase = onExecutionPhase;
params.onToolOutcome = (observation) => {
const ordinal = observation.toolCallOrdinal ?? latestOrdinal + 1;
if (ordinal >= latestOrdinal) {
latestOrdinal = ordinal;
terminalPresentation = observation.terminalPresentation;
}
};
testing.setOpenClawCodingToolsFactoryForTests((options) => [
wrapToolWithBeforeToolCallHook(slowTool, {
runId: options?.runId,
sessionId: options?.sessionId,
sessionKey: options?.sessionKey,
onToolOutcome: options?.onToolOutcome,
allocateToolOutcomeOrdinal: options?.allocateToolOutcomeOrdinal,
}),
]);
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("thread/start");
await vi.waitFor(() =>
expect(onExecutionPhase).toHaveBeenCalledWith(
expect.objectContaining({ phase: "turn_accepted" }),
),
);
const response = await harness.handleServerRequest({
id: "request-timeout",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-timeout",
namespace: null,
tool: "slow_summary",
arguments: { timeoutMs: 1 },
},
});
expect(response).toMatchObject({ success: false });
expect(terminalPresentation).toBeUndefined();
resolveSlowTool(textToolResult("late result"));
await vi.waitFor(() => {
expect(formatTerminalPresentation).toHaveBeenCalled();
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
expect(terminalPresentation).toBeUndefined();
});
it("passes the live run session key to Codex dynamic tools when sandbox policy uses another key", () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
@@ -342,11 +93,6 @@ describe("runCodexAppServerAttempt dynamic tools", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("thread/start");
await vi.waitFor(() =>
expect(onExecutionPhase).toHaveBeenCalledWith(
expect.objectContaining({ phase: "turn_accepted" }),
),
);
const toolResult = (await harness.handleServerRequest({
id: "request-tool-1",

View File

@@ -41,64 +41,6 @@ function flushDiagnosticEvents() {
setupRunAttemptTestHooks();
describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
it.each([
{ label: "completed", status: "completed" as const, error: undefined, legacy: false },
{ label: "failed", status: "failed" as const, error: "codex exploded", legacy: false },
{
label: "completed legacy alias",
status: "completed" as const,
error: undefined,
legacy: true,
},
])("defers $label lifecycle terminal ownership", async ({ status, error, legacy }) => {
const onRunAgentEvent = vi.fn();
const sessionFile = path.join(tempDir, `deferred-${status}.jsonl`);
const workspaceDir = path.join(tempDir, `workspace-${status}`);
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
if (legacy) {
params.deferTerminalLifecycleEnd = true;
} else {
params.deferTerminalLifecycle = true;
}
params.onAgentEvent = onRunAgentEvent;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
if (status === "completed") {
await harness.notify({
method: "item/agentMessage/delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
itemId: "msg-1",
delta: "hello back",
},
});
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
} else {
await harness.notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: {
id: "turn-1",
status,
error: { message: error },
},
},
});
}
await run;
const lifecycleEvents = onRunAgentEvent.mock.calls
.map(([event]) => event)
.filter((event) => event.stream === "lifecycle");
expect(lifecycleEvents.map((event) => event.data.phase)).toEqual(["start", "finishing"]);
expect(lifecycleEvents[1]?.data.error).toBe(error);
});
it("fires llm_input, llm_output, and agent_end hooks for codex turns", async () => {
const llmInput = vi.fn();
const llmOutput = vi.fn();

View File

@@ -85,8 +85,6 @@ import {
isCurrentThreadTurnRequestParams,
isNativeResponseStreamDeltaNotification,
isTerminalTurnStatus,
readCodexNotificationItem,
readRawResponseToolCallId,
} from "./attempt-notifications.js";
import {
buildCodexAppServerPromptTimeoutOutcome,
@@ -679,45 +677,8 @@ export async function runCodexAppServerAttempt(
);
}
let yieldDetected = false;
const toolOutcomeOrdinals = new Map<string, number>();
const suppressedDynamicToolOutcomeOrdinals = new Set<number>();
const onCodexToolOutcome = params.onToolOutcome
? (observation: Parameters<NonNullable<typeof params.onToolOutcome>>[0]) => {
if (
observation.toolCallOrdinal !== undefined &&
suppressedDynamicToolOutcomeOrdinals.has(observation.toolCallOrdinal)
) {
return;
}
params.onToolOutcome?.(observation);
}
: undefined;
const baseAllocateToolOutcomeOrdinal = params.allocateToolOutcomeOrdinal;
const allocateCodexToolOutcomeOrdinal = baseAllocateToolOutcomeOrdinal
? (toolCallId?: string): number => {
const reservedOrdinal = toolCallId ? toolOutcomeOrdinals.get(toolCallId) : undefined;
if (reservedOrdinal !== undefined) {
return reservedOrdinal;
}
const ordinal = baseAllocateToolOutcomeOrdinal(toolCallId);
if (toolCallId) {
toolOutcomeOrdinals.set(toolCallId, ordinal);
}
return ordinal;
}
: undefined;
const dynamicToolParams =
allocateCodexToolOutcomeOrdinal || onCodexToolOutcome
? {
...params,
...(allocateCodexToolOutcomeOrdinal
? { allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal }
: {}),
...(onCodexToolOutcome ? { onToolOutcome: onCodexToolOutcome } : {}),
}
: params;
const tools = await buildDynamicTools({
params: dynamicToolParams,
params,
resolvedWorkspace,
effectiveWorkspace,
effectiveCwd,
@@ -734,7 +695,7 @@ export async function runCodexAppServerAttempt(
onCodexAppServerEvent: (event) => emitCodexAppServerEvent(params, event),
});
const registeredTools = await buildDynamicTools({
params: dynamicToolParams,
params,
resolvedWorkspace,
effectiveWorkspace,
effectiveCwd,
@@ -771,8 +732,6 @@ export async function runCodexAppServerAttempt(
currentThreadId: params.currentThreadTs,
replyToMode: params.replyToMode,
hasRepliedRef: params.hasRepliedRef,
onToolOutcome: onCodexToolOutcome,
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
},
});
const hadSessionFile = await pathExists(activeSessionFile);
@@ -1553,9 +1512,6 @@ export async function runCodexAppServerAttempt(
startedAt: attemptStartedAt,
endedAt: Date.now(),
...data,
...((params.deferTerminalLifecycle ?? params.deferTerminalLifecycleEnd)
? { phase: "finishing" }
: {}),
},
});
lifecycleTerminalEmitted = true;
@@ -1743,20 +1699,6 @@ export async function runCodexAppServerAttempt(
correlation.matchesActiveTurn === true ||
(!isNativeResponseStreamDelta && correlation.matchesActiveTurn !== false) ||
nativeResponseStreamDeltaMatchesActiveTurn;
if (correlation.matchesActiveTurn === true) {
const modelToolCallId = readRawResponseToolCallId(notification);
if (modelToolCallId) {
// Raw response items arrive in model order before Codex schedules tool
// futures, so later lifecycle races reuse this authoritative position.
allocateCodexToolOutcomeOrdinal?.(modelToolCallId);
}
const nativeItem = readCodexNotificationItem(notification.params);
if (nativeItem?.type === "webSearch") {
// Upstream omits the raw web-search id. Its lifecycle still follows the
// model stream, so reserve synchronously before queued projection.
projector.recordNativeToolOutcome(nativeItem);
}
}
if (notificationMatchesActiveTurn) {
// If Codex app-server exposes raw response deltas, treat them as activity
// only when scoped to this turn or attributable to a single lease.
@@ -1872,7 +1814,6 @@ export async function runCodexAppServerAttempt(
if (!call || call.threadId !== thread.threadId || call.turnId !== turnId) {
return undefined;
}
const toolCallOrdinal = allocateCodexToolOutcomeOrdinal?.(call.callId);
armCompletionWatchOnResponse = true;
markCurrentTurnRequestProgress();
turnCrossedToolHandoff = true;
@@ -1943,13 +1884,7 @@ export async function runCodexAppServerAttempt(
toolBridge,
signal: runAbortController.signal,
timeoutMs: dynamicToolTimeoutMs,
toolCallOrdinal,
onAgentToolResult: params.onAgentToolResult,
onFallbackSelected: () => {
if (toolCallOrdinal !== undefined) {
suppressedDynamicToolOutcomeOrdinals.add(toolCallOrdinal);
}
},
onTimeout: () => {
trajectoryRecorder?.recordEvent("tool.timeout", {
threadId: call.threadId,
@@ -1961,19 +1896,6 @@ export async function runCodexAppServerAttempt(
},
});
const protocolResponse = toCodexDynamicToolProtocolResponse(response);
if (!protocolResponse.success && toolCallOrdinal !== undefined) {
// The underlying tool may ignore cancellation and finish after the
// timeout response. Its late presentation must not replace this failure.
suppressedDynamicToolOutcomeOrdinals.add(toolCallOrdinal);
params.onToolOutcome?.({
toolName: call.tool,
argsHash: "",
resultHash: "",
toolCallOrdinal,
terminalPresentation: undefined,
presentationOnly: true,
});
}
const toolDurationMs = Math.max(0, Date.now() - toolStartedAt);
trajectoryRecorder?.recordEvent("tool.result", {
threadId: call.threadId,
@@ -2060,7 +1982,6 @@ export async function runCodexAppServerAttempt(
}
throw error;
} finally {
toolOutcomeOrdinals.delete(call.callId);
unsubscribeToolDiagnosticObserver();
}
} finally {
@@ -2446,17 +2367,12 @@ export async function runCodexAppServerAttempt(
prompt: codexTurnPromptText,
imagesCount: params.images?.length ?? 0,
});
projectorRef.current = new CodexAppServerEventProjector(
dynamicToolParams,
thread.threadId,
activeTurnId,
{
nativePostToolUseRelayEnabled:
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
nativeHookRelay.shouldRelayEvent("post_tool_use"),
trajectoryRecorder,
},
);
projectorRef.current = new CodexAppServerEventProjector(params, thread.threadId, activeTurnId, {
nativePostToolUseRelayEnabled:
nativeHookRelay?.allowedEvents.includes("post_tool_use") === true &&
nativeHookRelay.shouldRelayEvent("post_tool_use"),
trajectoryRecorder,
});
if (
isTerminalTurnStatus(turn.turn.status) ||
pendingNotifications.some((notification) =>

View File

@@ -284,6 +284,7 @@ describe("copilotToolMetasHavePotentialSideEffects", () => {
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "status" }])).toBe(false);
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "file_read" }])).toBe(false);
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "memory_get" }])).toBe(false);
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "memory_search" }])).toBe(false);
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "sessions_history" }])).toBe(
false,
);
@@ -293,10 +294,6 @@ describe("copilotToolMetasHavePotentialSideEffects", () => {
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "web_search" }])).toBe(false);
});
it("treats memory_search recall tracking as a potential side effect", () => {
expect(copilotToolMetasHavePotentialSideEffects([{ toolName: "memory_search" }])).toBe(true);
});
it("detects async-started tools even without a mutating name", () => {
expect(
copilotToolMetasHavePotentialSideEffects([{ asyncStarted: true, toolName: "read" }]),

View File

@@ -223,6 +223,7 @@ const COPILOT_REPLAY_SAFE_READ_ONLY_TOOL_NAMES = new Set([
"list",
"ls",
"memory_get",
"memory_search",
"probe",
"query",
"read",

View File

@@ -142,7 +142,6 @@ type DispatchInboundParams = {
}) => Promise<void> | void;
onItemEvent?: (payload: {
itemId?: string;
toolCallId?: string;
kind?: string;
progressText?: string;
summary?: string;
@@ -157,8 +156,6 @@ type DispatchInboundParams = {
}) => Promise<void> | void;
onApprovalEvent?: (payload: { phase?: string; command?: string }) => Promise<void> | void;
onCommandOutput?: (payload: {
itemId?: string;
toolCallId?: string;
phase?: string;
name?: string;
title?: string;
@@ -2957,45 +2954,6 @@ describe("processDiscordMessage draft streaming", () => {
expectPreviewEditContent("done");
});
it("replaces Discord command progress items with matching command output", async () => {
const draftStream = createMockDraftStreamForTest();
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
await params?.replyOptions?.onItemEvent?.({
itemId: "tool:call-1",
toolCallId: "call-1",
kind: "command",
name: "exec",
progressText: "install dependencies",
});
await params?.replyOptions?.onCommandOutput?.({
itemId: "tool:call-1-output",
toolCallId: "call-1",
phase: "end",
name: "exec",
exitCode: 0,
});
return createNoQueuedDispatchResult();
});
const ctx = await createAutomaticSourceDeliveryContext({
discordConfig: {
streaming: {
mode: "progress",
progress: {
label: "Shelling",
},
},
},
});
await runProcessDiscordMessage(ctx);
const lastUpdate = draftStream.update.mock.calls.at(-1)?.[0];
expect(lastUpdate).toContain("completed");
expect(lastUpdate).not.toContain("install dependencies");
});
it("drops later tool warning finals after progress preview final replies", async () => {
const draftStream = createMockDraftStreamForTest();

View File

@@ -1027,8 +1027,6 @@ async function processDiscordMessageInner(
discordConfig,
{
event: "tool",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
name: payload.name,
phase: payload.phase,
args: payload.args,
@@ -1054,7 +1052,6 @@ async function processDiscordMessageInner(
buildChannelProgressDraftLineForEntry(discordConfig, {
event: "item",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
itemKind: payload.kind,
title: payload.title,
name: payload.name,
@@ -1102,8 +1099,6 @@ async function processDiscordMessageInner(
await draftPreview.pushToolProgress(
buildChannelProgressDraftLine({
event: "command-output",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
phase: payload.phase,
title: payload.title,
name: payload.name,
@@ -1119,8 +1114,6 @@ async function processDiscordMessageInner(
await draftPreview.pushToolProgress(
buildChannelProgressDraftLine({
event: "patch",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
phase: payload.phase,
title: payload.title,
name: payload.name,

View File

@@ -200,10 +200,6 @@ describe("getMessageFeishu", () => {
messageId: "om_1",
});
expect(mockClientGet).toHaveBeenCalledWith({
params: { card_msg_content_type: "user_card_content" },
path: { message_id: "om_1" },
});
expect(result).toEqual({
messageId: "om_1",
chatId: "oc_1",
@@ -474,15 +470,6 @@ describe("getMessageFeishu", () => {
rootMessageId: "om_root",
});
expect(mockClientList).toHaveBeenCalledWith({
params: {
container_id_type: "thread",
container_id: "omt_1",
sort_type: "ByCreateTimeDesc",
page_size: 21,
card_msg_content_type: "user_card_content",
},
});
expect(result).toEqual([
{
messageId: "om_file",

View File

@@ -414,7 +414,6 @@ export async function getMessageFeishu(params: {
try {
const response = (await client.im.message.get({
params: { card_msg_content_type: "user_card_content" },
path: { message_id: messageId },
})) as FeishuGetMessageResponse;
@@ -478,7 +477,6 @@ export async function listFeishuThreadMessages(params: {
// Results are reversed below to restore chronological order.
sort_type: "ByCreateTimeDesc",
page_size: Math.min(limit + 1, 50),
card_msg_content_type: "user_card_content",
},
})) as {
code?: number;

View File

@@ -121,30 +121,6 @@ describe("sendMessageIMessage receipts", () => {
expect(result.receipt.sentAt).toBeGreaterThan(0);
});
it("drops reply metadata from text sends when reply actions are disabled", async () => {
const client = createClient({ guid: "p:0/imsg-plain" });
const result = await sendMessageIMessage("chat_id:42", "hello", {
config: {
channels: {
imessage: {
actions: { reply: false },
accounts: { default: {} },
},
},
},
client,
replyToId: "reply-1",
});
const sendParams = getClientMocks(client).request.mock.calls[0]?.[1] as
| Record<string, unknown>
| undefined;
expect(sendParams).not.toHaveProperty("reply_to");
expect(result.receipt.replyToId).toBeUndefined();
expect(result.receipt.parts[0]?.replyToId).toBeUndefined();
});
it("passes the default RPC send transport", async () => {
const client = createClient({ guid: "p:0/imsg-transport-default" });
@@ -323,35 +299,6 @@ describe("sendMessageIMessage receipts", () => {
expect(client["request"]).not.toHaveBeenCalled();
});
it("drops reply metadata from media sends when reply actions are disabled", async () => {
const client = createClient({ message_id: 12345 });
const runCliJson = vi.fn().mockResolvedValueOnce({ messageId: "p:0/plain-media-guid" });
const result = await sendMessageIMessage("chat_guid:chat-1", "", {
config: {
channels: {
imessage: {
actions: { reply: false },
accounts: { default: {} },
},
},
},
client,
mediaUrl: "/tmp/image.png",
replyToId: "p:0/reply-guid",
resolveAttachmentImpl: async () => ({ path: "/tmp/image.png", contentType: "image/png" }),
runCliJson,
});
expect(result.messageId).toBe("p:0/plain-media-guid");
expect(runCliJson.mock.calls).toEqual([
[["send-attachment", "--chat", "chat-1", "--file", "/tmp/image.png", "--transport", "auto"]],
]);
expect(result.receipt.replyToId).toBeUndefined();
expect(result.receipt.parts[0]?.replyToId).toBeUndefined();
expect(client["request"]).not.toHaveBeenCalled();
});
it("resolves chat_id media-only payloads before using send-attachment", async () => {
const client = createClient({ message_id: 12345 });
const runCliJson = vi

View File

@@ -4,7 +4,6 @@ import { constants, accessSync, readFileSync } from "node:fs";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { createActionGate } from "openclaw/plugin-sdk/channel-actions";
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
@@ -949,8 +948,7 @@ export async function sendMessageIMessage(
throw new Error("iMessage send requires text or media");
}
const echoText = resolveOutboundEchoText(message, filePath ? mediaContentType : undefined);
const replyActionsEnabled = createActionGate(account.config.actions)("reply");
const resolvedReplyToId = replyActionsEnabled ? sanitizeReplyToId(opts.replyToId) : undefined;
const resolvedReplyToId = sanitizeReplyToId(opts.replyToId);
const runCliJson =
opts.runCliJson ??
((args: readonly string[]) => runIMessageCliJson(cliPath, dbPath, args, timeoutMs));

View File

@@ -1,27 +0,0 @@
# @openclaw/llama-cpp-provider
Official llama.cpp embedding provider for OpenClaw.
This plugin runs local GGUF embedding models through `node-llama-cpp`.
## Install
```bash
openclaw plugins install @openclaw/llama-cpp-provider
```
Restart the Gateway after installing or updating the plugin. Use Node 24 for
native installs and updates.
## Configure
Set `agents.defaults.memorySearch.provider` to `local`. By default, the plugin
downloads and uses the EmbeddingGemma GGUF model. Configure
`agents.defaults.memorySearch.local.modelPath` to use another local path, Hugging
Face model URI, or HTTPS model URL.
## Package
- Plugin id: `llama-cpp`
- Package: `@openclaw/llama-cpp-provider`
- Minimum OpenClaw host: `2026.6.2`

View File

@@ -1,5 +1,3 @@
import os from "node:os";
import path from "node:path";
import {
createPluginRegistryFixture,
registerVirtualTestPlugin,
@@ -23,9 +21,7 @@ import llamaCppPlugin from "./index.js";
import {
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
createLlamaCppEmbeddingProvider,
createLlamaCppMemoryEmbeddingProvider,
formatLlamaCppSetupError,
llamaCppEmbeddingProviderAdapter,
} from "./src/embedding-provider.js";
afterEach(() => {
@@ -110,285 +106,6 @@ describe("llama.cpp provider plugin", () => {
});
});
it("keeps the default model identity when configured with its exact cache artifact path", async () => {
const modelPath = path.join(
os.homedir(),
".node-llama-cpp",
"models",
"hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
);
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({
id: "local",
model: modelPath,
embedQuery: vi.fn(),
embedBatch: vi.fn(),
});
const result = await createLlamaCppMemoryEmbeddingProvider(
{
config: {},
provider: "local",
fallback: "none",
model: modelPath,
local: { modelPath },
},
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
);
expect(result.provider?.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL);
expect(result.runtime?.cacheKeyData).toEqual({
provider: "local",
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
});
expect(result.runtime?.indexIdentityAliases).toEqual([
{
model: modelPath,
cacheKeyData: {
provider: "local",
model: modelPath,
},
},
{
model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
cacheKeyData: {
provider: "local",
model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
},
},
]);
expect(
llamaCppEmbeddingProviderAdapter.resolveIndexIdentity?.({
config: {},
provider: "local",
model: modelPath,
local: { modelPath },
}),
).toEqual({
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
cacheKeyData: {
provider: "local",
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
},
aliases: [
{
model: modelPath,
cacheKeyData: {
provider: "local",
model: modelPath,
},
},
{
model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
cacheKeyData: {
provider: "local",
model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
},
},
],
});
expect(memoryHostEmbeddingMocks.createLocalEmbeddingProvider).toHaveBeenCalledWith(
expect.objectContaining({
model: modelPath,
local: { modelPath },
}),
{
nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js",
},
);
});
it("keeps an arbitrary same-basename model path as a distinct identity", async () => {
const modelPath = path.join(
os.tmpdir(),
"custom-models",
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL.split("/").at(-1)!,
);
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({
id: "local",
model: modelPath,
embedQuery: vi.fn(),
embedBatch: vi.fn(),
});
const result = await createLlamaCppMemoryEmbeddingProvider(
{
config: {},
provider: "local",
fallback: "none",
model: modelPath,
local: { modelPath },
},
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
);
expect(result.provider?.model).toBe(modelPath);
expect(result.runtime?.cacheKeyData).toEqual({
provider: "local",
model: modelPath,
});
expect(result.runtime).not.toHaveProperty("indexIdentityAliases");
});
it("keeps a bare same-basename file in the default cache as a distinct identity", async () => {
const modelPath = path.join(
os.homedir(),
".node-llama-cpp",
"models",
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL.split("/").at(-1)!,
);
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({
id: "local",
model: modelPath,
embedQuery: vi.fn(),
embedBatch: vi.fn(),
});
const result = await createLlamaCppMemoryEmbeddingProvider(
{
config: {},
provider: "local",
fallback: "none",
model: modelPath,
local: { modelPath },
},
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
);
expect(result.provider?.model).toBe(modelPath);
expect(result.runtime).not.toHaveProperty("indexIdentityAliases");
});
it("keeps the default model identity with a custom cache directory", async () => {
const modelCacheDir = path.join(os.tmpdir(), "llama-cpp-model-cache");
const modelPath = path.join(modelCacheDir, "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf");
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({
id: "local",
model: modelPath,
embedQuery: vi.fn(),
embedBatch: vi.fn(),
});
const result = await createLlamaCppMemoryEmbeddingProvider(
{
config: {},
provider: "local",
fallback: "none",
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
local: { modelPath: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL, modelCacheDir },
},
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
);
expect(result.provider?.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL);
expect(result.runtime?.cacheKeyData).toEqual({
provider: "local",
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
});
expect(result.runtime?.indexIdentityAliases).toEqual([
{
model: modelPath,
cacheKeyData: {
provider: "local",
model: modelPath,
},
},
{
model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
cacheKeyData: {
provider: "local",
model: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
},
},
]);
});
it.each([
{
direction: "default URI to exact relative cache artifact",
modelPath: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
},
{
direction: "exact relative cache artifact to default URI",
modelPath: "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf",
},
])("keeps $direction compatible", ({ modelPath }) => {
const modelCacheDir = path.join(os.tmpdir(), "llama-cpp-relative-model-cache");
const relativeModelPath = "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf";
const resolvedModelPath = path.join(modelCacheDir, relativeModelPath);
expect(
llamaCppEmbeddingProviderAdapter.resolveIndexIdentity?.({
config: {},
provider: "local",
model: modelPath,
local: { modelPath, modelCacheDir },
}),
).toEqual({
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
cacheKeyData: {
provider: "local",
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
},
aliases: [
{
model: resolvedModelPath,
cacheKeyData: {
provider: "local",
model: resolvedModelPath,
},
},
{
model: relativeModelPath,
cacheKeyData: {
provider: "local",
model: relativeModelPath,
},
},
],
});
});
it("keeps the default model identity for its exact relative cache artifact", async () => {
const modelCacheDir = path.join(os.tmpdir(), "llama-cpp-relative-model-cache");
const modelPath = "hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf";
const resolvedModelPath = path.join(modelCacheDir, modelPath);
memoryHostEmbeddingMocks.createLocalEmbeddingProvider.mockResolvedValue({
id: "local",
model: modelPath,
embedQuery: vi.fn(),
embedBatch: vi.fn(),
});
const result = await createLlamaCppMemoryEmbeddingProvider(
{
config: {},
provider: "local",
fallback: "none",
model: modelPath,
local: { modelPath, modelCacheDir },
},
{ nodeLlamaCppImportUrl: "file:///plugin/node-llama-cpp.js" },
);
expect(result.provider?.model).toBe(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL);
expect(result.runtime?.indexIdentityAliases).toEqual([
{
model: resolvedModelPath,
cacheKeyData: {
provider: "local",
model: resolvedModelPath,
},
},
{
model: modelPath,
cacheKeyData: {
provider: "local",
model: modelPath,
},
},
]);
});
it("formats missing runtime errors with the plugin install command", () => {
const err = Object.assign(new Error("Cannot find package 'node-llama-cpp'"), {
code: "ERR_MODULE_NOT_FOUND",

View File

@@ -1,13 +1,10 @@
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import type {
EmbeddingInput,
EmbeddingProvider,
EmbeddingProviderAdapter,
EmbeddingProviderCreateOptions,
EmbeddingProviderCreateResult,
} from "openclaw/plugin-sdk/embedding-providers";
import {
createLocalEmbeddingProvider,
@@ -30,17 +27,6 @@ export type LlamaCppEmbeddingProviderRuntimeOptions = {
export const LLAMA_CPP_EMBEDDING_PROVIDER_ID = "local";
export const DEFAULT_LLAMA_CPP_EMBEDDING_MODEL =
"hf:ggml-org/embeddinggemma-300m-qat-q8_0-GGUF/embeddinggemma-300m-qat-Q8_0.gguf";
const DEFAULT_LLAMA_CPP_EMBEDDING_MODEL_CACHE_FILE_NAME =
"hf_ggml-org_embeddinggemma-300m-qat-Q8_0.gguf";
type LlamaCppModelIdentity = {
model: string;
cacheKeyData: Record<string, unknown>;
aliases: Array<{
model: string;
cacheKeyData: Record<string, unknown>;
}>;
};
function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
@@ -51,56 +37,6 @@ function readLocalOptions(options: { local?: unknown }): LlamaCppLocalOptions {
return local ?? {};
}
function createLlamaCppCacheKeyData(model: string): Record<string, unknown> {
return {
provider: LLAMA_CPP_EMBEDDING_PROVIDER_ID,
model,
};
}
function resolveLlamaCppModelIdentity(
local: LlamaCppLocalOptions,
modelPath: string,
): LlamaCppModelIdentity {
const modelCacheDir =
normalizeOptionalString(local.modelCacheDir) ??
path.join(os.homedir(), ".node-llama-cpp", "models");
const resolvedDefaultModelPath = path.resolve(
modelCacheDir,
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL_CACHE_FILE_NAME,
);
const isModelUri = /^(?:hf:|https?:\/\/)/i.test(modelPath);
const resolvedModelPath = isModelUri ? undefined : path.resolve(modelCacheDir, modelPath);
// node-llama-cpp resolves the default HF URI to this exact cache target and
// accepts its URI-derived filename relative to any configured cache directory.
// Preserve that exact historical key; arbitrary filenames and paths stay distinct.
if (
modelPath !== DEFAULT_LLAMA_CPP_EMBEDDING_MODEL &&
resolvedModelPath !== resolvedDefaultModelPath
) {
return {
model: modelPath,
cacheKeyData: createLlamaCppCacheKeyData(modelPath),
aliases: [],
};
}
const aliasModels = new Set([
resolvedDefaultModelPath,
DEFAULT_LLAMA_CPP_EMBEDDING_MODEL_CACHE_FILE_NAME,
]);
if (modelPath !== DEFAULT_LLAMA_CPP_EMBEDDING_MODEL) {
aliasModels.add(modelPath);
}
return {
model: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
cacheKeyData: createLlamaCppCacheKeyData(DEFAULT_LLAMA_CPP_EMBEDDING_MODEL),
aliases: Array.from(aliasModels, (aliasModel) => ({
model: aliasModel,
cacheKeyData: createLlamaCppCacheKeyData(aliasModel),
})),
};
}
function textFromEmbeddingInput(input: EmbeddingInput): string {
return typeof input === "string" ? input : input.text;
}
@@ -178,11 +114,14 @@ export async function createLlamaCppEmbeddingProvider(
options: EmbeddingProviderCreateOptions,
runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {},
): Promise<EmbeddingProvider> {
const result = await createLlamaCppEmbeddingProviderResult(options, runtimeOptions);
const result = await createLlamaCppMemoryEmbeddingProvider(
buildMemoryCreateOptions(options, options.dimensions),
runtimeOptions,
);
if (!result.provider) {
throw new Error("llama.cpp local embedding provider was unavailable");
}
return result.provider;
return adaptMemoryEmbeddingProvider(result.provider);
}
export async function createLlamaCppMemoryEmbeddingProvider(
@@ -190,30 +129,12 @@ export async function createLlamaCppMemoryEmbeddingProvider(
runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {},
): Promise<MemoryEmbeddingProviderCreateResult> {
const createOptions = buildMemoryCreateOptions(options, options.outputDimensionality);
const local = readLocalOptions(createOptions);
const provider = await createLocalEmbeddingProvider(createOptions, {
nodeLlamaCppImportUrl: runtimeOptions.nodeLlamaCppImportUrl ?? resolveNodeLlamaCppImportUrl(),
});
const identity = resolveLlamaCppModelIdentity(local, provider.model);
const identifiedProvider =
identity.model === provider.model ? provider : { ...provider, model: identity.model };
return {
provider: identifiedProvider,
runtime: createLlamaCppEmbeddingProviderRuntime(identity),
};
}
async function createLlamaCppEmbeddingProviderResult(
options: EmbeddingProviderCreateOptions,
runtimeOptions: LlamaCppEmbeddingProviderRuntimeOptions = {},
): Promise<EmbeddingProviderCreateResult> {
const result = await createLlamaCppMemoryEmbeddingProvider(
buildMemoryCreateOptions(options, options.dimensions),
runtimeOptions,
);
return {
provider: result.provider ? adaptMemoryEmbeddingProvider(result.provider) : null,
runtime: result.runtime,
provider,
runtime: createLlamaCppEmbeddingProviderRuntime(provider),
};
}
@@ -241,13 +162,15 @@ function buildMemoryCreateOptions(
};
}
function createLlamaCppEmbeddingProviderRuntime(identity: LlamaCppModelIdentity) {
function createLlamaCppEmbeddingProviderRuntime(provider: { model: string }) {
return {
id: LLAMA_CPP_EMBEDDING_PROVIDER_ID,
inlineQueryTimeoutMs: 5 * 60_000,
inlineBatchTimeoutMs: 10 * 60_000,
cacheKeyData: identity.cacheKeyData,
...(identity.aliases.length > 0 ? { indexIdentityAliases: identity.aliases } : {}),
cacheKeyData: {
provider: LLAMA_CPP_EMBEDDING_PROVIDER_ID,
model: provider.model,
},
};
}
@@ -256,13 +179,11 @@ export const llamaCppEmbeddingProviderAdapter: EmbeddingProviderAdapter = {
defaultModel: DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
transport: "local",
formatSetupError: formatLlamaCppSetupError,
resolveIndexIdentity: (options) => {
const createOptions = buildMemoryCreateOptions(options, options.dimensions);
const local = readLocalOptions(createOptions);
return resolveLlamaCppModelIdentity(
local,
normalizeOptionalString(local.modelPath) ?? DEFAULT_LLAMA_CPP_EMBEDDING_MODEL,
);
create: async (options) => {
const provider = await createLlamaCppEmbeddingProvider(options);
return {
provider,
runtime: createLlamaCppEmbeddingProviderRuntime(provider),
};
},
create: async (options) => await createLlamaCppEmbeddingProviderResult(options),
};

View File

@@ -2950,24 +2950,12 @@ describe("matrix monitor handler draft streaming", () => {
) => Promise<void> | void;
onAssistantMessageStart?: () => void;
suppressDefaultToolProgressMessages?: boolean;
onToolStart?: (payload: {
itemId?: string;
toolCallId?: string;
name?: string;
phase?: string;
args?: Record<string, unknown>;
detailMode?: "explain" | "raw";
}) => Promise<void>;
onToolStart?: (payload: { name?: string }) => Promise<void>;
onItemEvent?: (payload: {
itemId?: string;
toolCallId?: string;
progressText?: string;
summary?: string;
title?: string;
name?: string;
kind?: string;
phase?: string;
status?: string;
}) => Promise<void>;
onPlanUpdate?: (payload: {
phase: string;
@@ -2976,24 +2964,15 @@ describe("matrix monitor handler draft streaming", () => {
}) => Promise<void>;
onApprovalEvent?: (payload: { phase: string; command?: string }) => Promise<void>;
onCommandOutput?: (payload: {
itemId?: string;
toolCallId?: string;
phase: string;
name?: string;
exitCode?: number;
status?: string;
title?: string;
}) => Promise<void>;
onPatchSummary?: (payload: {
itemId?: string;
toolCallId?: string;
phase: string;
name?: string;
summary?: string;
title?: string;
added?: string[];
modified?: string[];
deleted?: string[];
}) => Promise<void>;
disableBlockStreaming?: boolean;
};
@@ -3159,167 +3138,6 @@ describe("matrix monitor handler draft streaming", () => {
await finish();
});
it("replaces recovered Matrix command progress instead of leaving stale failed text", async () => {
const { dispatch } = createStreamingHarness({
streaming: "progress",
previewToolProgressEnabled: true,
accountConfig: {
streaming: { mode: "progress", progress: { label: "Working" } },
} as never,
});
const { opts, finish } = await dispatch();
await opts.onItemEvent?.({
itemId: "command-1",
kind: "command",
name: "exec",
phase: "end",
status: "failed",
progressText: "run openclaw cron -> run jq (agent) failed",
});
await opts.onItemEvent?.({
itemId: "command-1",
kind: "command",
name: "exec",
phase: "end",
status: "failed",
progressText: "run openclaw cron -> run jq (agent) failed",
});
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(singleTextMessageBody()).toContain("failed");
await opts.onCommandOutput?.({
itemId: "command-1",
toolCallId: "call-1",
phase: "end",
name: "exec",
status: "completed",
exitCode: 0,
});
await finish();
expect(editMessageMatrixMock).toHaveBeenCalledWith(
"!room:example.org",
"$draft1",
expect.stringContaining("completed"),
expect.any(Object),
);
const recoveredEdit = mockCalls(editMessageMatrixMock, "editMessageMatrix").find(
([, eventId, body]) =>
eventId === "$draft1" && typeof body === "string" && body.includes("completed"),
);
expect(recoveredEdit?.[2]).not.toContain("failed");
expect(recoveredEdit?.[2]).not.toContain("run openclaw cron -> run jq");
});
it("replaces Matrix tool-start progress when command output completes", async () => {
const { dispatch } = createStreamingHarness({
streaming: "progress",
previewToolProgressEnabled: true,
accountConfig: {
streaming: { mode: "progress", progress: { label: "Working" } },
} as never,
});
const { opts, finish } = await dispatch();
await opts.onToolStart?.({
itemId: "fc-call-2",
toolCallId: "call-2",
name: "exec",
phase: "start",
args: { command: "npm install" },
});
await opts.onToolStart?.({
itemId: "fc-call-2",
toolCallId: "call-2",
name: "exec",
phase: "update",
args: { command: "npm install" },
});
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(singleTextMessageBody()).toContain("install dependencies");
await opts.onItemEvent?.({
itemId: "fc-call-2",
toolCallId: "call-2",
kind: "command",
name: "exec",
phase: "update",
progressText: "install dependencies",
});
await opts.onCommandOutput?.({
itemId: "fc-call-2-output",
toolCallId: "call-2",
phase: "end",
name: "exec",
status: "completed",
exitCode: 0,
});
await finish();
const completedEdit = mockCalls(editMessageMatrixMock, "editMessageMatrix").find(
([, eventId, body]) =>
eventId === "$draft1" && typeof body === "string" && body.includes("completed"),
);
expect(completedEdit?.[2]).not.toContain("install dependencies");
});
it("replaces Matrix patch progress when the patch summary completes", async () => {
const { dispatch } = createStreamingHarness({
streaming: "progress",
previewToolProgressEnabled: true,
accountConfig: {
streaming: { mode: "progress", progress: { label: "Working" } },
} as never,
});
const { opts, finish } = await dispatch();
await opts.onItemEvent?.({
itemId: "patch:call-3",
toolCallId: "call-3",
kind: "patch",
name: "apply_patch",
phase: "update",
progressText: "updating Matrix progress handling",
});
await opts.onItemEvent?.({
itemId: "patch:call-3",
toolCallId: "call-3",
kind: "patch",
name: "apply_patch",
phase: "update",
progressText: "updating Matrix progress handling",
});
await vi.waitFor(() => {
expect(sendSingleTextMessageMatrixMock).toHaveBeenCalledTimes(1);
});
expect(singleTextMessageBody()).toContain("updating Matrix progress handling");
await opts.onPatchSummary?.({
itemId: "patch:call-3",
toolCallId: "call-3",
phase: "end",
name: "apply_patch",
modified: ["extensions/matrix/src/matrix/monitor/handler.ts"],
summary: "1 file modified",
});
await finish();
const patchEdit = mockCalls(editMessageMatrixMock, "editMessageMatrix").find(
([, eventId, body]) =>
eventId === "$draft1" && typeof body === "string" && body.includes("1 file modified"),
);
expect(patchEdit?.[2]).not.toContain("updating Matrix progress handling");
});
it("keeps Matrix tool progress mentions inside code formatting", async () => {
const { dispatch } = createStreamingHarness({
previewToolProgressEnabled: true,

View File

@@ -16,6 +16,7 @@ import {
createChannelProgressDraftGate,
type ChannelProgressDraftLine,
formatChannelProgressDraftLine,
formatChannelProgressDraftLineForEntry,
formatChannelProgressDraftText,
isChannelProgressDraftWorkToolName,
mergeChannelProgressDraftLine,
@@ -1932,12 +1933,10 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
onToolStart: async (payload) => {
const toolName = payload.name?.trim();
await pushPreviewToolProgress(
buildChannelProgressDraftLineForEntry(
formatChannelProgressDraftLineForEntry(
progressConfigEntry,
{
event: "tool",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
name: toolName,
phase: payload.phase,
args: payload.args,
@@ -1952,7 +1951,6 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
buildChannelProgressDraftLineForEntry(progressConfigEntry, {
event: "item",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
itemKind: payload.kind,
title: payload.title,
name: payload.name,
@@ -1998,10 +1996,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
await pushPreviewToolProgress(
buildChannelProgressDraftLineForEntry(progressConfigEntry, {
formatChannelProgressDraftLine({
event: "command-output",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
phase: payload.phase,
title: payload.title,
name: payload.name,
@@ -2015,10 +2011,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam
return;
}
await pushPreviewToolProgress(
buildChannelProgressDraftLineForEntry(progressConfigEntry, {
formatChannelProgressDraftLine({
event: "patch",
itemId: payload.itemId,
toolCallId: payload.toolCallId,
phase: payload.phase,
title: payload.title,
name: payload.name,

View File

@@ -7,11 +7,6 @@
"contracts": {
"tools": ["memory_get", "memory_search"]
},
"toolMetadata": {
"memory_get": {
"replaySafe": true
}
},
"commandAliases": [
{
"name": "dreaming",

View File

@@ -5,7 +5,7 @@ import { createAsyncLock } from "openclaw/plugin-sdk/async-lock-runtime";
import { extractErrorCode } from "openclaw/plugin-sdk/error-runtime";
import { resolveGlobalMap } from "openclaw/plugin-sdk/global-singleton";
import { replaceManagedMarkdownBlock } from "openclaw/plugin-sdk/memory-host-markdown";
import { readRegularFile, replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
import { replaceFileAtomic } from "openclaw/plugin-sdk/security-runtime";
const DREAMS_FILENAMES = ["DREAMS.md", "dreams.md"] as const;
const DEEP_START_MARKER = "<!-- openclaw:dreaming:deep:start -->";
@@ -19,7 +19,7 @@ type DreamsFileLockEntry = {
const dreamsFileLocks = resolveGlobalMap<string, DreamsFileLockEntry>(DREAMS_FILE_LOCKS_KEY);
export async function resolveDreamsPath(workspaceDir: string): Promise<string> {
async function resolveDreamsPath(workspaceDir: string): Promise<string> {
for (const name of DREAMS_FILENAMES) {
const target = path.join(workspaceDir, name);
try {
@@ -34,27 +34,11 @@ export async function resolveDreamsPath(workspaceDir: string): Promise<string> {
return path.join(workspaceDir, DREAMS_FILENAMES[0]);
}
function isEmptyDreamsReadError(err: unknown): boolean {
const code = extractErrorCode(err);
if (
code === "ENOENT" ||
code === "ENOTDIR" ||
code === "not-found" ||
code === "not-file" ||
code === "path-alias" ||
code === "path-mismatch" ||
code === "symlink"
) {
return true;
}
return err instanceof Error && err.message === "path must be a regular file";
}
export async function readDreamsFile(dreamsPath: string): Promise<string> {
async function readDreamsFile(dreamsPath: string): Promise<string> {
try {
return (await readRegularFile({ filePath: dreamsPath })).buffer.toString("utf-8");
return await fs.readFile(dreamsPath, "utf-8");
} catch (err) {
if (isEmptyDreamsReadError(err)) {
if ((err as NodeJS.ErrnoException)?.code === "ENOENT") {
return "";
}
throw err;

View File

@@ -21,7 +21,6 @@ import {
formatNarrativeDate,
formatBackfillDiaryDate,
generateAndAppendDreamNarrative,
readRecentDreamDiaryEntries,
removeBackfillDiaryEntries,
runDetachedDreamNarrative,
type NarrativePhaseData,
@@ -134,19 +133,6 @@ describe("buildNarrativePrompt", () => {
expect(prompt).toContain("snippet-11");
expect(prompt).not.toContain("snippet-12");
});
it("includes current sweep and recent diary context", () => {
const prompt = buildNarrativePrompt({
phase: "light",
snippets: ["Later workspace routing notes surfaced."],
currentDate: "April 6, 2026, 9:00 AM UTC",
recentDiaryEntries: ["The first meeting memory already filled the page."],
});
expect(prompt).toContain("Diary continuity context");
expect(prompt).toContain("Current sweep: April 6, 2026, 9:00 AM UTC");
expect(prompt).toContain("The first meeting memory already filled the page.");
expect(prompt).toContain("do not replay the same first-day framing");
});
});
describe("extractNarrativeText", () => {
@@ -402,77 +388,6 @@ describe("appendNarrativeEntry", () => {
expect(secondIdx).toBeLessThan(end);
});
it("reads recent diary entries without timestamps or markers", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
await appendNarrativeEntry({
workspaceDir,
narrative: "The first meeting memory already filled the page.",
nowMs: Date.parse("2026-04-04T03:00:00Z"),
timezone: "UTC",
});
await appendNarrativeEntry({
workspaceDir,
narrative: "A later routing note flickered in the margins.",
nowMs: Date.parse("2026-04-05T03:00:00Z"),
timezone: "UTC",
});
await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 1 })).resolves.toEqual([
"A later routing note flickered in the margins.",
]);
});
it("skips symlinked DREAMS.md when building recent diary context", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const targetPath = path.join(workspaceDir, "target-dreams.md");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");
const symlinkTargetDiary = "Symlink target diary text must not enter the prompt.";
await fs.writeFile(
targetPath,
[
"# Dream Diary",
"",
"<!-- openclaw:dreaming:diary:start -->",
"---",
"",
"*April 5, 2026, 3:00 AM UTC*",
"",
symlinkTargetDiary,
"",
"<!-- openclaw:dreaming:diary:end -->",
"",
].join("\n"),
"utf-8",
);
await fs.symlink(targetPath, dreamsPath);
const entries = await readRecentDreamDiaryEntries({ workspaceDir, limit: 3 });
expect(entries).toEqual([]);
const prompt = buildNarrativePrompt({
phase: "light",
snippets: ["A fresh routing memory arrived."],
recentDiaryEntries: entries,
});
expect(prompt).not.toContain(symlinkTargetDiary);
});
it("skips non-file DREAMS.md when reading recent diary context", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
await fs.mkdir(path.join(workspaceDir, "DREAMS.md"));
await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 3 })).resolves.toEqual([]);
});
it("treats unreadable DREAMS.md as empty recent diary context", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
await fs.writeFile(path.join(workspaceDir, "DREAMS.md"), "unreadable", "utf-8");
vi.spyOn(fs, "access").mockRejectedValueOnce(
Object.assign(new Error("permission denied"), { code: "EACCES" }),
);
await expect(readRecentDreamDiaryEntries({ workspaceDir, limit: 3 })).resolves.toEqual([]);
});
it("prepends diary before existing managed blocks", async () => {
const workspaceDir = await createTempWorkspace("openclaw-dreaming-narrative-");
const dreamsPath = path.join(workspaceDir, "DREAMS.md");

View File

@@ -20,7 +20,7 @@ import {
resolveStorePath,
updateSessionStore,
} from "openclaw/plugin-sdk/session-store-runtime";
import { readDreamsFile, resolveDreamsPath, updateDreamsFile } from "./dreaming-dreams-file.js";
import { updateDreamsFile } from "./dreaming-dreams-file.js";
// ── Types ──────────────────────────────────────────────────────────────
@@ -54,8 +54,6 @@ export type NarrativePhaseData = {
themes?: string[];
/** Snippets that were promoted to durable memory (deep). */
promotions?: string[];
currentDate?: string;
recentDiaryEntries?: string[];
};
type Logger = {
@@ -112,8 +110,6 @@ const SAFE_SESSION_ID_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/i;
const DIARY_START_MARKER = "<!-- openclaw:dreaming:diary:start -->";
const DIARY_END_MARKER = "<!-- openclaw:dreaming:diary:end -->";
const BACKFILL_ENTRY_MARKER = "openclaw:dreaming:backfill-entry";
const RECENT_DIARY_CONTEXT_LIMIT = 3;
const RECENT_DIARY_CONTEXT_MAX_CHARS = 360;
const NARRATIVE_SESSION_LOCKS_KEY = Symbol.for(
"openclaw.memoryCore.dreamingNarrative.sessionLocks",
);
@@ -309,27 +305,6 @@ export function buildNarrativePrompt(data: NarrativePhaseData): string {
}
}
const currentDate = data.currentDate?.trim();
const recentDiaryEntries = (data.recentDiaryEntries ?? [])
.map(clampDiaryContextEntry)
.filter((entry) => entry.length > 0)
.slice(0, RECENT_DIARY_CONTEXT_LIMIT);
if (currentDate || recentDiaryEntries.length > 0) {
lines.push("\nDiary continuity context:");
if (currentDate) {
lines.push(`- Current sweep: ${currentDate}`);
}
if (recentDiaryEntries.length > 0) {
lines.push("- Recent diary entries already written:");
for (const entry of recentDiaryEntries) {
lines.push(` - ${entry}`);
}
}
lines.push(
"- Prefer a fresh angle; do not replay the same first-day framing unless newer fragments change it.",
);
}
return lines.join("\n");
}
@@ -460,78 +435,6 @@ function splitDiaryBlocks(diaryContent: string): string[] {
.filter((block) => block.length > 0);
}
function clampDiaryContextEntry(entry: string): string {
const normalized = entry.replace(/\s+/g, " ").trim();
if (normalized.length <= RECENT_DIARY_CONTEXT_MAX_CHARS) {
return normalized;
}
return `${normalized.slice(0, RECENT_DIARY_CONTEXT_MAX_CHARS).trimEnd()}...`;
}
function normalizeDiaryBlockBody(block: string): string {
const bodyLines: string[] = [];
for (const line of block.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("<!--") || trimmed.startsWith("#")) {
continue;
}
if (trimmed.startsWith("*") && trimmed.endsWith("*") && trimmed.length > 2) {
continue;
}
bodyLines.push(trimmed);
}
return clampDiaryContextEntry(bodyLines.join(" "));
}
function isOptionalDiaryContextReadError(err: unknown): boolean {
const code = extractErrorCode(err);
if (
code === "EACCES" ||
code === "EPERM" ||
code === "ENOENT" ||
code === "ENOTDIR" ||
code === "not-found" ||
code === "not-file" ||
code === "path-alias" ||
code === "path-mismatch" ||
code === "symlink"
) {
return true;
}
return err instanceof Error && err.message === "path must be a regular file";
}
export async function readRecentDreamDiaryEntries(params: {
workspaceDir: string;
limit?: number;
}): Promise<string[]> {
const limit = Math.max(0, Math.floor(params.limit ?? RECENT_DIARY_CONTEXT_LIMIT));
if (limit === 0) {
return [];
}
let existing: string;
try {
const dreamsPath = await resolveDreamsPath(params.workspaceDir);
existing = await readDreamsFile(dreamsPath);
} catch (err) {
if (isOptionalDiaryContextReadError(err)) {
return [];
}
throw err;
}
const startIdx = existing.indexOf(DIARY_START_MARKER);
const endIdx = existing.indexOf(DIARY_END_MARKER);
if (startIdx < 0 || endIdx < 0 || endIdx < startIdx) {
return [];
}
const inner = existing.slice(startIdx + DIARY_START_MARKER.length, endIdx);
return splitDiaryBlocks(inner)
.map(normalizeDiaryBlockBody)
.filter((entry) => entry.length > 0)
.slice(-limit)
.toReversed();
}
function normalizeDiaryBlockFingerprint(block: string): string {
const lines = block
.split("\n")

View File

@@ -450,131 +450,6 @@ describe("memory-core dreaming phases", () => {
});
});
it("prefers a fresh light snippet outside the top diary-covered candidates", async () => {
const workspaceDir = await createDreamingWorkspace();
const stalePath = path.join(workspaceDir, "memory", "2026-04-03.md");
const freshPath = path.join(workspaceDir, "memory", "2026-04-04.md");
const nowMs = Date.parse("2026-04-05T10:05:00.000Z");
const staleSnippets = [
"初次见面时,我第一次醒来并认识了主人。",
"The first morning began beside a quiet terminal.",
"An early config file felt like the first map of home.",
"The initial heartbeat made the empty workspace feel awake.",
];
await fs.writeFile(stalePath, `${staleSnippets.join("\n")}\n`, "utf-8");
await fs.writeFile(
freshPath,
"Later routing notes: queue hydration changed after plugin reload.\n",
"utf-8",
);
for (const [index, snippet] of staleSnippets.entries()) {
for (let recall = 0; recall < staleSnippets.length - index; recall += 1) {
await recordShortTermRecalls({
workspaceDir,
query: `first-day-${index}-${recall}`,
nowMs,
results: [
{
path: "memory/2026-04-03.md",
startLine: index + 1,
endLine: index + 1,
score: 0.93,
snippet,
source: "memory",
},
],
});
}
}
await recordShortTermRecalls({
workspaceDir,
query: "routing queue reload",
nowMs,
results: [
{
path: "memory/2026-04-04.md",
startLine: 1,
endLine: 1,
score: 0.91,
snippet: "Later routing notes: queue hydration changed after plugin reload.",
source: "memory",
},
],
});
await fs.writeFile(
path.join(workspaceDir, "DREAMS.md"),
[
"# Dream Diary",
"",
"<!-- openclaw:dreaming:diary:start -->",
...staleSnippets.flatMap((snippet, index) => [
"---",
"",
`*April ${index + 1}, 2026, 10:00 AM UTC*`,
"",
snippet,
"",
]),
"<!-- openclaw:dreaming:diary:end -->",
"",
].join("\n"),
"utf-8",
);
const subagent = createMockNarrativeSubagent("A later routing note finally took the page.");
const testConfig: OpenClawConfig = {
agents: {
defaults: {
workspace: workspaceDir,
userTimezone: "UTC",
},
},
plugins: {
entries: {
"memory-core": {
config: {
dreaming: {
enabled: true,
timezone: "UTC",
storage: { mode: "inline", separateReports: false },
phases: {
light: {
enabled: true,
limit: 1,
lookbackDays: 7,
},
rem: {
enabled: false,
limit: 0,
lookbackDays: 7,
},
},
},
},
},
},
},
};
const logger = {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
await runDreamingSweepPhases({
workspaceDir,
cfg: testConfig,
pluginConfig: resolveMemoryCorePluginConfig(testConfig),
logger,
subagent,
nowMs,
});
const message = firstNarrativeRun(subagent).message;
expect(message).toContain("Later routing notes: queue hydration changed after plugin reload.");
expect(message).toContain("Recent diary entries already written");
expect(message).not.toContain("\n- 初次见面时,我第一次醒来并认识了主人。");
});
it("triggers light dreaming when the token is embedded in a reminder body", async () => {
const workspaceDir = await createDreamingWorkspace();
await withDreamingTestClock(async () => {

View File

@@ -24,7 +24,6 @@ import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/strin
import { writeDailyDreamingPhaseBlock } from "./dreaming-markdown.js";
import {
generateAndAppendDreamNarrative,
readRecentDreamDiaryEntries,
type NarrativePhaseData,
runDetachedDreamNarrative,
} from "./dreaming-narrative.js";
@@ -113,8 +112,6 @@ const SESSION_INGESTION_MIN_MESSAGES_PER_FILE = 12;
const SESSION_INGESTION_MAX_TRACKED_MESSAGES_PER_SESSION = 4096;
const SESSION_INGESTION_MAX_TRACKED_SCOPES = 2048;
const SESSION_CHECKPOINT_TRANSCRIPT_FILENAME_RE = /\.checkpoint\..+\.jsonl$/i;
const LIGHT_DIARY_HISTORY_LIMIT = 4;
const LIGHT_DIARY_SNIPPET_SIMILARITY_THRESHOLD = 0.35;
const GENERIC_DAY_HEADING_RE =
/^(?:(?:mon|monday|tue|tues|tuesday|wed|wednesday|thu|thur|thurs|thursday|fri|friday|sat|saturday|sun|sunday)(?:,\s+)?)?(?:(?:jan|january|feb|february|mar|march|apr|april|may|jun|june|jul|july|aug|august|sep|sept|september|oct|october|nov|november|dec|december)\s+\d{1,2}(?:st|nd|rd|th)?(?:,\s*\d{4})?|\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?|\d{4}[/-]\d{2}[/-]\d{2})$/i;
const MANAGED_DAILY_DREAMING_BLOCKS = [
@@ -1479,46 +1476,6 @@ function dedupeEntries(entries: ShortTermRecallEntry[], threshold: number): Shor
return deduped;
}
function normalizeDiaryCoverageText(text: string): string {
return text.toLowerCase().replace(/\s+/g, " ").trim();
}
function isEntryCoveredByRecentDiary(
entry: ShortTermRecallEntry,
recentDiaryEntries: readonly string[],
): boolean {
const snippet = normalizeDiaryCoverageText(entry.snippet);
if (!snippet) {
return false;
}
return recentDiaryEntries.some((diaryEntry) => {
const diaryText = normalizeDiaryCoverageText(diaryEntry);
return (
diaryText.includes(snippet) ||
snippetSimilarity(entry.snippet, diaryEntry) >= LIGHT_DIARY_SNIPPET_SIMILARITY_THRESHOLD
);
});
}
function prioritizeLightEntriesByDiaryCoverage(
entries: ShortTermRecallEntry[],
recentDiaryEntries: readonly string[],
): ShortTermRecallEntry[] {
if (recentDiaryEntries.length === 0) {
return entries;
}
const fresh: ShortTermRecallEntry[] = [];
const covered: ShortTermRecallEntry[] = [];
for (const entry of entries) {
if (isEntryCoveredByRecentDiary(entry, recentDiaryEntries)) {
covered.push(entry);
} else {
fresh.push(entry);
}
}
return [...fresh, ...covered];
}
function buildLightDreamingBody(entries: ShortTermRecallEntry[]): string[] {
if (entries.length === 0) {
return ["- No notable updates."];
@@ -1703,21 +1660,18 @@ async function runLightDreaming(params: {
lookbackDays: params.config.lookbackDays,
}),
});
const rankedEntries = dedupeEntries(
recentEntries.toSorted((a, b) => {
const byTime = Date.parse(b.lastRecalledAt) - Date.parse(a.lastRecalledAt);
if (byTime !== 0) {
return byTime;
}
return b.recallCount - a.recallCount;
}),
const entries = dedupeEntries(
recentEntries
.toSorted((a, b) => {
const byTime = Date.parse(b.lastRecalledAt) - Date.parse(a.lastRecalledAt);
if (byTime !== 0) {
return byTime;
}
return b.recallCount - a.recallCount;
})
.slice(0, params.config.limit),
params.config.dedupeSimilarity,
);
const recentDiaryEntries = await readRecentDreamDiaryEntries({
workspaceDir: params.workspaceDir,
limit: LIGHT_DIARY_HISTORY_LIMIT,
});
const entries = prioritizeLightEntriesByDiaryCoverage(rankedEntries, recentDiaryEntries);
const capped = entries.slice(0, params.config.limit);
const bodyLines = buildLightDreamingBody(capped);
await writeDailyDreamingPhaseBlock({
@@ -1745,9 +1699,7 @@ async function runLightDreaming(params: {
const data: NarrativePhaseData = {
phase: "light",
snippets: capped.map((e) => e.snippet).filter(Boolean),
currentDate: formatMemoryDreamingDay(nowMs, params.config.timezone),
...(themes.length > 0 ? { themes } : {}),
...(recentDiaryEntries.length > 0 ? { recentDiaryEntries } : {}),
};
if (params.detachNarratives) {
runDetachedDreamNarrative({

View File

@@ -30,7 +30,6 @@ vi.mock("./embeddings.js", () => ({
resolveEmbeddingProviderAdapterId: (providerId: string) => providerId,
resolveEmbeddingProviderAdapterTransport: (providerId: string) =>
providerId === "local" ? "local" : "remote",
resolveEmbeddingProviderIndexIdentity: () => undefined,
createEmbeddingProvider: async () => ({
requestedProvider: "openai",
provider: {

View File

@@ -85,9 +85,6 @@ function adaptGenericRuntime(
return {
id: runtime.id,
...(runtime.cacheKeyData ? { cacheKeyData: runtime.cacheKeyData } : {}),
...(runtime.indexIdentityAliases?.length
? { indexIdentityAliases: runtime.indexIdentityAliases }
: {}),
...(typeof runtime.inlineQueryTimeoutMs === "number"
? { inlineQueryTimeoutMs: runtime.inlineQueryTimeoutMs }
: {}),
@@ -100,24 +97,12 @@ function adaptGenericRuntime(
function adaptGenericEmbeddingAdapter(
adapter: EmbeddingProviderAdapter,
): MemoryEmbeddingProviderAdapter {
const resolveIndexIdentity = adapter.resolveIndexIdentity;
return {
id: adapter.id,
...(adapter.defaultModel ? { defaultModel: adapter.defaultModel } : {}),
...(adapter.transport ? { transport: adapter.transport } : {}),
...(adapter.authProviderId ? { authProviderId: adapter.authProviderId } : {}),
...(adapter.formatSetupError ? { formatSetupError: adapter.formatSetupError } : {}),
...(resolveIndexIdentity
? {
resolveIndexIdentity: (options: MemoryEmbeddingProviderCreateOptions) =>
resolveIndexIdentity({
...options,
...(typeof options.outputDimensionality === "number"
? { dimensions: options.outputDimensionality }
: {}),
}),
}
: {}),
create: async (options) => {
const result = await adapter.create({
...options,
@@ -199,29 +184,6 @@ export function resolveEmbeddingProviderAdapterTransport(
}
}
export function resolveEmbeddingProviderIndexIdentity(options: CreateEmbeddingProviderOptions) {
const provider =
options.provider === "auto" ? DEFAULT_MEMORY_EMBEDDING_PROVIDER : options.provider;
try {
const adapter = getAdapter(provider, options.config);
const model = resolveProviderModel(adapter, options.model);
const identity = adapter.resolveIndexIdentity?.({
...options,
provider,
model,
});
return identity
? {
provider: { id: adapter.id, model: identity.model },
cacheKeyData: identity.cacheKeyData,
aliases: identity.aliases,
}
: undefined;
} catch {
return undefined;
}
}
async function createWithAdapter(
adapter: MemoryEmbeddingProviderAdapter,
options: CreateEmbeddingProviderOptions,

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