mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 02:58:45 +08:00
Compare commits
4 Commits
codex/stat
...
script-to-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
606494a5cb | ||
|
|
dacd29616b | ||
|
|
fce216acf0 | ||
|
|
da5c13b24a |
@@ -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.
|
||||
@@ -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."
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@@ -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
|
||||
|
||||
447
.github/workflows/ios-periphery-comment.yml
vendored
447
.github/workflows/ios-periphery-comment.yml
vendored
@@ -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,
|
||||
});
|
||||
229
.github/workflows/ios-periphery.yml
vendored
229
.github/workflows/ios-periphery.yml
vendored
@@ -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
2
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import OpenClawKit
|
||||
import OpenClawProtocol
|
||||
import SwiftUI
|
||||
|
||||
extension AgentProTab {
|
||||
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
3
apps/ios/Sources/Design/OpenClawProScreens.swift
Normal file
@@ -0,0 +1,3 @@
|
||||
import SwiftUI
|
||||
|
||||
// Pro UI surfaces are split by tab to keep SwiftLint file-length signal useful.
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
365
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal file
365
apps/ios/Sources/Onboarding/GatewayOnboardingView.swift
Normal 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("We’ll 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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
15
apps/ios/Sources/RootView.swift
Normal file
15
apps/ios/Sources/RootView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:")
|
||||
}
|
||||
}
|
||||
|
||||
40
apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift
Normal file
40
apps/ios/Sources/Settings/SettingsNetworkingHelpers.swift
Normal 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)"
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import OpenClawKit
|
||||
|
||||
enum TalkModeExecutionMode {
|
||||
case native
|
||||
case realtimeClient
|
||||
case realtimeRelay
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import SwiftUI
|
||||
struct TalkPermissionPromptView: View {
|
||||
enum Style {
|
||||
case card
|
||||
case settings
|
||||
case sheet
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: "-")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"),
|
||||
],
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -100,7 +100,6 @@ export function buildCodexTurnStartFailureResult(params: {
|
||||
assistantTexts: [],
|
||||
toolMetas: [],
|
||||
lastAssistant: undefined,
|
||||
currentAttemptAssistant: undefined,
|
||||
didSendViaMessagingTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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" }]),
|
||||
|
||||
@@ -223,6 +223,7 @@ const COPILOT_REPLAY_SAFE_READ_ONLY_TOOL_NAMES = new Set([
|
||||
"list",
|
||||
"ls",
|
||||
"memory_get",
|
||||
"memory_search",
|
||||
"probe",
|
||||
"query",
|
||||
"read",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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`
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,11 +7,6 @@
|
||||
"contracts": {
|
||||
"tools": ["memory_get", "memory_search"]
|
||||
},
|
||||
"toolMetadata": {
|
||||
"memory_get": {
|
||||
"replaySafe": true
|
||||
}
|
||||
},
|
||||
"commandAliases": [
|
||||
{
|
||||
"name": "dreaming",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user