merge main into fix/config-schema-key-14998

This commit is contained in:
Peter Steinberger
2026-02-14 02:58:10 +01:00
247 changed files with 12681 additions and 5135 deletions

View File

@@ -19,6 +19,7 @@ Merge a prepared PR only after deterministic validation.
- Never use `gh pr merge --auto` in this flow.
- Never run `git push` directly.
- Require `--match-head-commit` during merge.
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Contract

View File

@@ -18,6 +18,7 @@ Perform a read-only review and produce both human and machine-readable outputs.
- Never push, merge, or modify code intended to keep.
- Work only in `.worktrees/pr-<PR>`.
- Wrapper commands are cwd-agnostic; you can run them from repo root or inside the PR worktree.
## Execution Contract

View File

@@ -6,19 +6,34 @@ Docs: https://docs.openclaw.ai
### Changes
- Agents: add synthetic catalog support for `hf:zai-org/GLM-5`. (#15867) Thanks @battman21.
- Skills: remove duplicate `local-places` Google Places skill/proxy and keep `goplaces` as the single supported Google Places path.
- Discord: send voice messages with waveform previews from local audio files (including silent delivery). (#7253) Thanks @nyanjou.
- Discord: add configurable presence status/activity/type/url (custom status defaults to activity text). (#10855) Thanks @h0tp-ftw.
- Slack/Plugins: add thread-ownership outbound gating via `message_sending` hooks, including @-mention bypass tracking and Slack outbound hook wiring for cancel/modify behavior. (#15775) Thanks @DarlingtonDeveloper.
- Agents: add pre-prompt context diagnostics (`messages`, `systemPromptChars`, `promptChars`, provider/model, session file) before embedded runner prompt calls to improve overflow debugging. (#8930) Thanks @Glucksberg.
### Fixes
- Agents/Compaction: centralize exec default resolution in the shared tool factory so per-agent `tools.exec` overrides (host/security/ask/node and related defaults) persist across compaction retries. (#15833) Thanks @napetrov.
- CLI/Completion: route plugin-load logs to stderr and write generated completion scripts directly to stdout to avoid `source <(openclaw completion ...)` corruption. (#15481) Thanks @arosstale.
- Gateway/Agents: stop injecting a phantom `main` agent into gateway agent listings when `agents.list` explicitly excludes it. (#11450) Thanks @arosstale.
- Agents/Heartbeat: stop auto-creating `HEARTBEAT.md` during workspace bootstrap so missing files continue to run heartbeat as documented. (#11766) Thanks @shadril238.
- Telegram: cap bot menu registration to Telegram's 100-command limit with an overflow warning while keeping typed hidden commands available. (#15844) Thanks @battman21.
- CLI: lazily load outbound provider dependencies and remove forced success-path exits so commands terminate naturally without killing intentional long-running foreground actions. (#12906) Thanks @DrCrinkle.
- Auto-reply/Heartbeat: strip sentence-ending `HEARTBEAT_OK` tokens even when followed by up to 4 punctuation characters, while preserving surrounding sentence punctuation. (#15847) Thanks @Spacefish.
- Heartbeat: allow explicit wake (`wake`) and hook wake (`hook:*`) reasons to run even when `HEARTBEAT.md` is effectively empty so queued system events are processed. (#14527) Thanks @arosstale.
- Clawdock: avoid Zsh readonly variable collisions in helper scripts. (#15501) Thanks @nkelner.
- Discord: route autoThread replies to existing threads instead of the root channel. (#8302) Thanks @gavinbmoore, @thewilloftheshadow.
- Discord/Agents: apply channel/group `historyLimit` during embedded-runner history compaction to prevent long-running channel sessions from bypassing truncation and overflowing context windows. (#11224) Thanks @shadril238.
- Telegram: scope skill commands to the resolved agent for default accounts so `setMyCommands` no longer triggers `BOT_COMMANDS_TOO_MUCH` when multiple agents are configured. (#15599)
- Plugins/Hooks: fire `before_tool_call` hook exactly once per tool invocation in embedded runs by removing duplicate dispatch paths while preserving parameter mutation semantics. (#15635) Thanks @lailoo.
- Agents/Image tool: cap image-analysis completion `maxTokens` by model capability (`min(4096, model.maxTokens)`) to avoid over-limit provider failures while still preventing truncation. (#11770) Thanks @detecti1.
- TUI/Streaming: preserve richer streamed assistant text when final payload drops pre-tool-call text blocks, while keeping non-empty final payload authoritative for plain-text updates. (#15452) Thanks @TsekaLuk.
- Inbound/Web UI: preserve literal `\n` sequences when normalizing inbound text so Windows paths like `C:\\Work\\nxxx\\README.md` are not corrupted. (#11547) Thanks @mcaxtr.
- Daemon/Windows: preserve literal backslashes in `gateway.cmd` command parsing so drive and UNC paths are not corrupted in runtime checks and doctor entrypoint comparisons. (#15642) Thanks @arosstale.
- Security/Canvas: serve A2UI assets via the shared safe-open path (`openFileWithinRoot`) to close traversal/TOCTOU gaps, with traversal and symlink regression coverage. (#10525) Thanks @abdelsfane.
- Media: classify `text/*` MIME types as documents in media-kind routing so text attachments are no longer treated as unknown. (#12237) Thanks @arosstale.
- Security/Gateway: breaking default-behavior change - canvas IP-based auth fallback now only accepts machine-scoped addresses (RFC1918, link-local, ULA IPv6, CGNAT); public-source IP matches now require bearer token auth. (#14661) Thanks @sumleo.
- Security/Gateway: sanitize and truncate untrusted WebSocket header values in pre-handshake close logs to reduce log-poisoning risk. Thanks @thewilloftheshadow.
- Security/WhatsApp: enforce `0o600` on `creds.json` and `creds.json.bak` on save/backup/restore paths to reduce credential file exposure. (#10529) Thanks @abdelsfane.
@@ -29,13 +44,18 @@ Docs: https://docs.openclaw.ai
- MS Teams: preserve parsed mention entities/text when appending OneDrive fallback file links, and accept broader real-world Teams mention ID formats (`29:...`, `8:orgid:...`) while still rejecting placeholder patterns. (#15436) Thanks @hyojin.
- Security/Audit: distinguish external webhooks (`hooks.enabled`) from internal hooks (`hooks.internal.enabled`) in attack-surface summaries to avoid false exposure signals when only internal hooks are enabled. (#13474) Thanks @mcaxtr.
- Security/Onboarding: clarify multi-user DM isolation remediation with explicit `openclaw config set session.dmScope ...` commands in security audit, doctor security, and channel onboarding guidance. (#13129) Thanks @VintLin.
- Gateway/Hooks: preserve `408` for hook request-body timeout responses while keeping bounded auth-failure cache eviction behavior, with timeout-status regression coverage. (#15848) Thanks @AI-Reviewer-QS.
- Security/Audit: add misconfiguration checks for sandbox Docker config with sandbox mode off, ineffective `gateway.nodes.denyCommands` entries, global minimal tool-profile overrides by agent profiles, and permissive extension-plugin tool reachability.
- Security/Link understanding: block loopback/internal host patterns and private/mapped IPv6 addresses in extracted URL handling to close SSRF bypasses in link CLI flows. (#15604) Thanks @AI-Reviewer-QS.
- Android/Nodes: harden `app.update` by requiring HTTPS and gateway-host URL matching plus SHA-256 verification, stream URL camera downloads to disk with size guards to avoid memory spikes, and stop signing release builds with debug keys. (#13541) Thanks @smartprogrammer93.
- Auto-reply/Threading: auto-inject implicit reply threading so `replyToMode` works without requiring model-emitted `[[reply_to_current]]`, while preserving `replyToMode: "off"` behavior for implicit Slack replies and keeping block-streaming chunk coalescing stable under `replyToMode: "first"`. (#14976) Thanks @Diaspar4u.
- Auto-reply/Media: allow image-only inbound messages (no caption) to reach the agent instead of short-circuiting as empty text, and preserve thread context in queued/followup prompt bodies for media-only runs. (#11916) Thanks @arosstale.
- Sandbox: pass configured `sandbox.docker.env` variables to sandbox containers at `docker create` time. (#15138) Thanks @stevebot-alive.
- Gateway/Restart: clear stale command-queue and heartbeat wake runtime state after SIGUSR1 in-process restarts to prevent zombie gateway behavior where queued work stops draining. (#15195) Thanks @joeykrug.
- Onboarding/CLI: restore terminal state without resuming paused `stdin`, so onboarding exits cleanly after choosing Web UI and the installer returns instead of appearing stuck.
- Auth/OpenAI Codex: share OAuth login handling across onboarding and `models auth login --provider openai-codex`, keep onboarding alive when OAuth fails, and surface a direct OAuth help note instead of terminating the wizard. (#15406, follow-up to #14552) Thanks @zhiluo20.
- Cron: add regression coverage for announce-mode isolated jobs so runs that already report `delivered: true` do not enqueue duplicate main-session relays, including delivery configs where `mode` is omitted and defaults to announce. (#15737) Thanks @brandonwise.
- Cron: honor `deleteAfterRun` in isolated announce delivery by mapping it to subagent announce cleanup mode, so cron run sessions configured for deletion are removed after completion. (#15368) Thanks @arosstale.
- Onboarding/Providers: add vLLM as an onboarding provider with model discovery, auth profile wiring, and non-interactive auth-choice validation. (#12577) Thanks @gejifeng.
- Onboarding/Providers: preserve Hugging Face auth intent in auth-choice remapping (`tokenProvider=huggingface` with `authChoice=apiKey`) and skip env-override prompts when an explicit token is provided. (#13472) Thanks @Josephrp.
- OpenAI Codex/Spark: implement end-to-end `gpt-5.3-codex-spark` support across fallback/thinking/model resolution and `models list` forward-compat visibility. (#14990, #15174) Thanks @L-U-C-K-Y, @loiie45e.
@@ -47,6 +67,7 @@ Docs: https://docs.openclaw.ai
- macOS Voice Wake: fix a crash in trigger trimming for CJK/Unicode transcripts by matching and slicing on original-string ranges instead of transformed-string indices. (#11052) Thanks @Flash-LHR.
- Heartbeat: prevent scheduler silent-death races during runner reloads, preserve retry cooldown backoff under wake bursts, and prioritize user/action wake causes over interval/retry reasons when coalescing. (#15108) Thanks @joeykrug.
- Outbound targets: fail closed for WhatsApp/Twitch/Google Chat fallback paths so invalid or missing targets are dropped instead of rerouted, and align resolver hints with strict target requirements. (#13578) Thanks @mcaxtr.
- Outbound: add a write-ahead delivery queue with crash-recovery retries to prevent lost outbound messages after gateway restarts. (#15636) Thanks @nabbilkhan, @thewilloftheshadow.
- Exec/Allowlist: allow multiline heredoc bodies (`<<`, `<<-`) while keeping multiline non-heredoc shell commands blocked, so exec approval parsing permits heredoc input safely without allowing general newline command chaining. (#13811) Thanks @mcaxtr.
- Docs/Mermaid: remove hardcoded Mermaid init theme blocks from four docs diagrams so dark mode inherits readable theme defaults. (#15157) Thanks @heytulsiprasad.
- Outbound/Threading: pass `replyTo` and `threadId` from `message send` tool actions through the core outbound send path to channel adapters, preserving thread/reply routing. (#14948) Thanks @mcaxtr.
@@ -56,12 +77,16 @@ Docs: https://docs.openclaw.ai
- Signal/Install: auto-install `signal-cli` via Homebrew on non-x64 Linux architectures, avoiding x86_64 native binary `Exec format error` failures on arm64/arm hosts. (#15443) Thanks @jogvan-k.
- Discord: avoid misrouting numeric guild allowlist entries to `/channels/<guildId>` by prefixing guild-only inputs with `guild:` during resolution. (#12326) Thanks @headswim.
- Config: preserve `${VAR}` env references when writing config files so `openclaw config set/apply/patch` does not persist secrets to disk. Thanks @thewilloftheshadow.
- Config: remove a cross-request env-snapshot race in config writes by carrying read-time env context into write calls per request, preserving `${VAR}` refs safely under concurrent gateway config mutations. (#11560) Thanks @akoscz.
- Config: log overwrite audit entries (path, backup target, and hash transition) whenever an existing config file is replaced, improving traceability for unexpected config clobbers.
- Process/Exec: avoid shell execution for `.exe` commands on Windows so env overrides work reliably in `runCommandWithTimeout`. Thanks @thewilloftheshadow.
- Web tools/web_fetch: prefer `text/markdown` responses for Cloudflare Markdown for Agents, add `cf-markdown` extraction for markdown bodies, and redact fetched URLs in `x-markdown-tokens` debug logs to avoid leaking raw paths/query params. (#15376) Thanks @Yaxuan42.
- Config: keep legacy audio transcription migration strict by rejecting non-string/unsafe command tokens while still migrating valid custom script executables. (#5042) Thanks @shayan919293.
- Status/Sessions: stop clamping derived `totalTokens` to context-window size, keep prompt-token snapshots wired through session accounting, and surface context usage as unknown when fresh snapshot data is missing to avoid false 100% reports. (#15114) Thanks @echoVic.
- Providers/MiniMax: switch implicit MiniMax API-key provider from `openai-completions` to `anthropic-messages` with the correct Anthropic-compatible base URL, fixing `invalid role: developer (2013)` errors on MiniMax M2.5. (#15275) Thanks @lailoo.
- Config: accept `$schema` key in config file so JSON Schema editor tooling works without validation errors. (#14998)
- Web UI: add `img` to DOMPurify allowed tags and `src`/`alt` to allowed attributes so markdown images render in webchat instead of being stripped. (#15437) Thanks @lailoo.
- Ollama/Agents: use resolved model/provider base URLs for native `/api/chat` streaming (including aliased providers), normalize `/v1` endpoints, and forward abort + `maxTokens` stream options for reliable cancellation and token caps. (#11853) Thanks @BrokenFinger98.
## 2026.2.12
@@ -103,6 +128,7 @@ Docs: https://docs.openclaw.ai
- Cron: pass `agentId` to `runHeartbeatOnce` for main-session jobs. (#14140) Thanks @ishikawa-pro.
- Cron: re-arm timers when `onTimer` fires while a job is still executing. (#14233) Thanks @tomron87.
- Cron: prevent duplicate fires when multiple jobs trigger simultaneously. (#14256) Thanks @xinhuagu.
- Cron: prevent duplicate announce-mode isolated cron deliveries, and keep main-session fallback active when best-effort structured delivery attempts fail to send any message. (#15739) Thanks @widingmarcus-cyber.
- Cron: isolate scheduler errors so one bad job does not break all jobs. (#14385) Thanks @MarvinDontPanic.
- Cron: prevent one-shot `at` jobs from re-firing on restart after skipped/errored runs. (#13878) Thanks @lailoo.
- Heartbeat: prevent scheduler stalls on unexpected run errors and avoid immediate rerun loops after `requests-in-flight` skips. (#14901) Thanks @joeykrug.
@@ -131,6 +157,7 @@ Docs: https://docs.openclaw.ai
- Onboarding/Providers: update MiniMax API default/recommended models from M2.1 to M2.5, add M2.5/M2.5-Lightning model entries, and include `minimax-m2.5` in modern model filtering. (#14865) Thanks @adao-max.
- Ollama: use configured `models.providers.ollama.baseUrl` for model discovery and normalize `/v1` endpoints to the native Ollama API root. (#14131) Thanks @shtse8.
- Voice Call: pass Twilio stream auth token via `<Parameter>` instead of query string. (#14029) Thanks @mcwigglesmcgee.
- Config/Models: allow full `models.providers.*.models[*].compat` keys used by `openai-completions` (`thinkingFormat`, `supportsStrictMode`, and streaming/tool-result compatibility flags) so valid provider overrides no longer fail strict config validation. (#11063) Thanks @ikari-pl.
- Feishu: pass `Buffer` directly to the Feishu SDK upload APIs instead of `Readable.from(...)` to avoid form-data upload failures. (#10345) Thanks @youngerstyle.
- Feishu: trigger mention-gated group handling only when the bot itself is mentioned (not just any mention). (#11088) Thanks @openperf.
- Feishu: probe status uses the resolved account context for multi-account credential checks. (#11233) Thanks @onevcat.
@@ -156,6 +183,7 @@ Docs: https://docs.openclaw.ai
- Agents: keep followup-runner session `totalTokens` aligned with post-compaction context by using last-call usage and shared token-accounting logic. (#14979) Thanks @shtse8.
- Hooks/Plugins: wire 9 previously unwired plugin lifecycle hooks into core runtime paths (session, compaction, gateway, and outbound message hooks). (#14882) Thanks @shtse8.
- Hooks/Tools: dispatch `before_tool_call` and `after_tool_call` hooks from both tool execution paths with rebased conflict fixes. (#15012) Thanks @Patrick-Barletta, @Takhoffman.
- Hooks: replace loader `console.*` output with subsystem logger messages so hook loading errors/warnings route through standard logging. (#11029) Thanks @shadril238.
- Discord: allow channel-edit to archive/lock threads and set auto-archive duration. (#5542) Thanks @stumct.
- Discord tests: use a partial @buape/carbon mock in slash command coverage. (#13262) Thanks @arosstale.
- Tests: update thread ID handling in Slack message collection tests. (#14108) Thanks @swizzmagik.

View File

@@ -619,7 +619,29 @@ actor GatewayEndpointStore {
}
extension GatewayEndpointStore {
static func dashboardURL(for config: GatewayConnection.Config) throws -> URL {
private static func normalizeDashboardPath(_ rawPath: String?) -> String {
let trimmed = (rawPath ?? "").trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return "/" }
let withLeadingSlash = trimmed.hasPrefix("/") ? trimmed : "/" + trimmed
guard withLeadingSlash != "/" else { return "/" }
return withLeadingSlash.hasSuffix("/") ? withLeadingSlash : withLeadingSlash + "/"
}
private static func localControlUiBasePath() -> String {
let root = OpenClawConfigFile.loadDict()
guard let gateway = root["gateway"] as? [String: Any],
let controlUi = gateway["controlUi"] as? [String: Any]
else {
return "/"
}
return self.normalizeDashboardPath(controlUi["basePath"] as? String)
}
static func dashboardURL(
for config: GatewayConnection.Config,
mode: AppState.ConnectionMode,
localBasePath: String? = nil) throws -> URL
{
guard var components = URLComponents(url: config.url, resolvingAgainstBaseURL: false) else {
throw NSError(domain: "Dashboard", code: 1, userInfo: [
NSLocalizedDescriptionKey: "Invalid gateway URL",
@@ -633,7 +655,17 @@ extension GatewayEndpointStore {
default:
components.scheme = "http"
}
components.path = "/"
let urlPath = self.normalizeDashboardPath(components.path)
if urlPath != "/" {
components.path = urlPath
} else if mode == .local {
let fallbackPath = localBasePath ?? self.localControlUiBasePath()
components.path = self.normalizeDashboardPath(fallbackPath)
} else {
components.path = "/"
}
var queryItems: [URLQueryItem] = []
if let token = config.token?.trimmingCharacters(in: .whitespacesAndNewlines),
!token.isEmpty

View File

@@ -337,7 +337,7 @@ struct MenuContent: View {
private func openDashboard() async {
do {
let config = try await GatewayEndpointStore.shared.requireConfig()
let url = try GatewayEndpointStore.dashboardURL(for: config)
let url = try GatewayEndpointStore.dashboardURL(for: config, mode: self.state.connectionMode)
NSWorkspace.shared.open(url)
} catch {
let alert = NSAlert()

View File

@@ -3,6 +3,7 @@ import Foundation
enum OpenClawConfigFile {
private static let logger = Logger(subsystem: "ai.openclaw", category: "config")
private static let configAuditFileName = "config-audit.jsonl"
static func url() -> URL {
OpenClawPaths.configURL
@@ -35,15 +36,61 @@ enum OpenClawConfigFile {
static func saveDict(_ dict: [String: Any]) {
// Nix mode disables config writes in production, but tests rely on saving temp configs.
if ProcessInfo.processInfo.isNixMode, !ProcessInfo.processInfo.isRunningTests { return }
let url = self.url()
let previousData = try? Data(contentsOf: url)
let previousRoot = previousData.flatMap { self.parseConfigData($0) }
let previousBytes = previousData?.count
let hadMetaBefore = self.hasMeta(previousRoot)
let gatewayModeBefore = self.gatewayMode(previousRoot)
var output = dict
self.stampMeta(&output)
do {
let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys])
let url = self.url()
let data = try JSONSerialization.data(withJSONObject: output, options: [.prettyPrinted, .sortedKeys])
try FileManager().createDirectory(
at: url.deletingLastPathComponent(),
withIntermediateDirectories: true)
try data.write(to: url, options: [.atomic])
let nextBytes = data.count
let gatewayModeAfter = self.gatewayMode(output)
let suspicious = self.configWriteSuspiciousReasons(
existsBefore: previousData != nil,
previousBytes: previousBytes,
nextBytes: nextBytes,
hadMetaBefore: hadMetaBefore,
gatewayModeBefore: gatewayModeBefore,
gatewayModeAfter: gatewayModeAfter)
if !suspicious.isEmpty {
self.logger.warning("config write anomaly (\(suspicious.joined(separator: ", "))) at \(url.path)")
}
self.appendConfigWriteAudit([
"result": "success",
"configPath": url.path,
"existsBefore": previousData != nil,
"previousBytes": previousBytes ?? NSNull(),
"nextBytes": nextBytes,
"hasMetaBefore": hadMetaBefore,
"hasMetaAfter": self.hasMeta(output),
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
"gatewayModeAfter": gatewayModeAfter ?? NSNull(),
"suspicious": suspicious,
])
} catch {
self.logger.error("config save failed: \(error.localizedDescription)")
self.appendConfigWriteAudit([
"result": "failed",
"configPath": url.path,
"existsBefore": previousData != nil,
"previousBytes": previousBytes ?? NSNull(),
"nextBytes": NSNull(),
"hasMetaBefore": hadMetaBefore,
"hasMetaAfter": self.hasMeta(output),
"gatewayModeBefore": gatewayModeBefore ?? NSNull(),
"gatewayModeAfter": self.gatewayMode(output) ?? NSNull(),
"suspicious": [],
"error": error.localizedDescription,
])
}
}
@@ -214,4 +261,100 @@ enum OpenClawConfigFile {
}
return nil
}
private static func stampMeta(_ root: inout [String: Any]) {
var meta = root["meta"] as? [String: Any] ?? [:]
let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "macos-app"
meta["lastTouchedVersion"] = version
meta["lastTouchedAt"] = ISO8601DateFormatter().string(from: Date())
root["meta"] = meta
}
private static func hasMeta(_ root: [String: Any]?) -> Bool {
guard let root else { return false }
return root["meta"] is [String: Any]
}
private static func hasMeta(_ root: [String: Any]) -> Bool {
root["meta"] is [String: Any]
}
private static func gatewayMode(_ root: [String: Any]?) -> String? {
guard let root else { return nil }
return self.gatewayMode(root)
}
private static func gatewayMode(_ root: [String: Any]) -> String? {
guard let gateway = root["gateway"] as? [String: Any],
let mode = gateway["mode"] as? String
else { return nil }
let trimmed = mode.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : trimmed
}
private static func configWriteSuspiciousReasons(
existsBefore: Bool,
previousBytes: Int?,
nextBytes: Int,
hadMetaBefore: Bool,
gatewayModeBefore: String?,
gatewayModeAfter: String?) -> [String]
{
var reasons: [String] = []
if !existsBefore {
return reasons
}
if let previousBytes, previousBytes >= 512, nextBytes < max(1, previousBytes / 2) {
reasons.append("size-drop:\(previousBytes)->\(nextBytes)")
}
if !hadMetaBefore {
reasons.append("missing-meta-before-write")
}
if gatewayModeBefore != nil, gatewayModeAfter == nil {
reasons.append("gateway-mode-removed")
}
return reasons
}
private static func configAuditLogURL() -> URL {
self.stateDirURL()
.appendingPathComponent("logs", isDirectory: true)
.appendingPathComponent(self.configAuditFileName, isDirectory: false)
}
private static func appendConfigWriteAudit(_ fields: [String: Any]) {
var record: [String: Any] = [
"ts": ISO8601DateFormatter().string(from: Date()),
"source": "macos-openclaw-config-file",
"event": "config.write",
"pid": ProcessInfo.processInfo.processIdentifier,
"argv": Array(ProcessInfo.processInfo.arguments.prefix(8)),
]
for (key, value) in fields {
record[key] = value is NSNull ? NSNull() : value
}
guard JSONSerialization.isValidJSONObject(record),
let data = try? JSONSerialization.data(withJSONObject: record)
else {
return
}
var line = Data()
line.append(data)
line.append(0x0A)
let logURL = self.configAuditLogURL()
do {
try FileManager().createDirectory(
at: logURL.deletingLastPathComponent(),
withIntermediateDirectories: true)
if !FileManager().fileExists(atPath: logURL.path) {
FileManager().createFile(atPath: logURL.path, contents: nil)
}
let handle = try FileHandle(forWritingTo: logURL)
defer { try? handle.close() }
try handle.seekToEnd()
try handle.write(contentsOf: line)
} catch {
// best-effort
}
}
}

View File

@@ -176,6 +176,48 @@ import Testing
#expect(host == "192.168.1.10")
}
@Test func dashboardURLUsesLocalBasePathInLocalMode() throws {
let config: GatewayConnection.Config = (
url: try #require(URL(string: "ws://127.0.0.1:18789")),
token: nil,
password: nil
)
let url = try GatewayEndpointStore.dashboardURL(
for: config,
mode: .local,
localBasePath: " control ")
#expect(url.absoluteString == "http://127.0.0.1:18789/control/")
}
@Test func dashboardURLSkipsLocalBasePathInRemoteMode() throws {
let config: GatewayConnection.Config = (
url: try #require(URL(string: "ws://gateway.example:18789")),
token: nil,
password: nil
)
let url = try GatewayEndpointStore.dashboardURL(
for: config,
mode: .remote,
localBasePath: "/local-ui")
#expect(url.absoluteString == "http://gateway.example:18789/")
}
@Test func dashboardURLPrefersPathFromConfigURL() throws {
let config: GatewayConnection.Config = (
url: try #require(URL(string: "wss://gateway.example:443/remote-ui")),
token: nil,
password: nil
)
let url = try GatewayEndpointStore.dashboardURL(
for: config,
mode: .remote,
localBasePath: "/local-ui")
#expect(url.absoluteString == "https://gateway.example:443/remote-ui/")
}
@Test func normalizeGatewayUrlAddsDefaultPortForWs() {
let url = GatewayRemoteConfig.normalizeGatewayUrl("ws://gateway")
#expect(url?.port == 18789)

View File

@@ -76,4 +76,43 @@ struct OpenClawConfigFileTests {
#expect(OpenClawConfigFile.url().path == "\(dir)/openclaw.json")
}
}
@MainActor
@Test
func saveDictAppendsConfigAuditLog() async throws {
let stateDir = FileManager().temporaryDirectory
.appendingPathComponent("openclaw-state-\(UUID().uuidString)", isDirectory: true)
let configPath = stateDir.appendingPathComponent("openclaw.json")
let auditPath = stateDir.appendingPathComponent("logs/config-audit.jsonl")
defer { try? FileManager().removeItem(at: stateDir) }
try await TestIsolation.withEnvValues([
"OPENCLAW_STATE_DIR": stateDir.path,
"OPENCLAW_CONFIG_PATH": configPath.path,
]) {
OpenClawConfigFile.saveDict([
"gateway": ["mode": "local"],
])
let configData = try Data(contentsOf: configPath)
let configRoot = try JSONSerialization.jsonObject(with: configData) as? [String: Any]
#expect((configRoot?["meta"] as? [String: Any]) != nil)
let rawAudit = try String(contentsOf: auditPath, encoding: .utf8)
let lines = rawAudit
.split(whereSeparator: \.isNewline)
.map(String.init)
#expect(!lines.isEmpty)
guard let last = lines.last else {
Issue.record("Missing config audit line")
return
}
let auditRoot = try JSONSerialization.jsonObject(with: Data(last.utf8)) as? [String: Any]
#expect(auditRoot?["source"] as? String == "macos-openclaw-config-file")
#expect(auditRoot?["event"] as? String == "config.write")
#expect(auditRoot?["result"] as? String == "success")
#expect(auditRoot?["configPath"] as? String == configPath.path)
}
}
}

View File

@@ -41,9 +41,10 @@ The hooks system allows you to:
### Bundled Hooks
OpenClaw ships with three bundled hooks that are automatically discovered:
OpenClaw ships with four bundled hooks that are automatically discovered:
- **💾 session-memory**: Saves session context to your agent workspace (default `~/.openclaw/workspace/memory/`) when you issue `/new`
- **📎 bootstrap-extra-files**: Injects additional workspace bootstrap files from configured glob/path patterns during `agent:bootstrap`
- **📝 command-logger**: Logs all command events to `~/.openclaw/logs/commands.log`
- **🚀 boot-md**: Runs `BOOT.md` when the gateway starts (requires internal hooks enabled)
@@ -484,6 +485,47 @@ Saves session context to memory when you issue `/new`.
openclaw hooks enable session-memory
```
### bootstrap-extra-files
Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
**Events**: `agent:bootstrap`
**Requirements**: `workspace.dir` must be configured
**Output**: No files written; bootstrap context is modified in-memory only.
**Config**:
```json
{
"hooks": {
"internal": {
"enabled": true,
"entries": {
"bootstrap-extra-files": {
"enabled": true,
"paths": ["packages/*/AGENTS.md", "packages/*/TOOLS.md"]
}
}
}
}
}
```
**Notes**:
- Paths are resolved relative to workspace.
- Files must stay inside workspace (realpath-checked).
- Only recognized bootstrap basenames are loaded.
- Subagent allowlist is preserved (`AGENTS.md` and `TOOLS.md` only).
**Enable**:
```bash
openclaw hooks enable bootstrap-extra-files
```
### command-logger
Logs all command events to a centralized audit file.
@@ -618,6 +660,7 @@ The gateway logs hook loading at startup:
```
Registered hook: session-memory -> command:new
Registered hook: bootstrap-extra-files -> agent:bootstrap
Registered hook: command-logger -> command
Registered hook: boot-md -> gateway:startup
```

View File

@@ -32,10 +32,11 @@ List all discovered hooks from workspace, managed, and bundled directories.
**Example output:**
```
Hooks (3/3 ready)
Hooks (4/4 ready)
Ready:
🚀 boot-md ✓ - Run BOOT.md on gateway startup
📎 bootstrap-extra-files ✓ - Inject extra workspace bootstrap files during agent bootstrap
📝 command-logger ✓ - Log all command events to a centralized audit file
💾 session-memory ✓ - Save session context to memory when /new command is issued
```
@@ -249,6 +250,18 @@ openclaw hooks enable session-memory
**See:** [session-memory documentation](/automation/hooks#session-memory)
### bootstrap-extra-files
Injects additional bootstrap files (for example monorepo-local `AGENTS.md` / `TOOLS.md`) during `agent:bootstrap`.
**Enable:**
```bash
openclaw hooks enable bootstrap-extra-files
```
**See:** [bootstrap-extra-files documentation](/automation/hooks#bootstrap-extra-files)
### command-logger
Logs all command events to a centralized audit file.

View File

@@ -21,7 +21,7 @@ Compaction **persists** in the sessions JSONL history.
## Configuration
See [Compaction config & modes](/concepts/compaction) for the `agents.defaults.compaction` settings.
Use the `agents.defaults.compaction` setting in your `openclaw.json` to configure compaction behavior (mode, target tokens, etc.).
## Auto-compaction (default on)

View File

@@ -8,7 +8,7 @@ title: "Ollama"
# Ollama
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's OpenAI-compatible API and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. OpenClaw integrates with Ollama's native API (`/api/chat`), supporting streaming and tool calling, and can **auto-discover tool-capable models** when you opt in with `OLLAMA_API_KEY` (or an auth profile) and do not define an explicit `models.providers.ollama` entry.
## Quick start
@@ -101,10 +101,9 @@ Use explicit config when:
models: {
providers: {
ollama: {
// Use a host that includes /v1 for OpenAI-compatible APIs
baseUrl: "http://ollama-host:11434/v1",
baseUrl: "http://ollama-host:11434",
apiKey: "ollama-local",
api: "openai-completions",
api: "ollama",
models: [
{
id: "gpt-oss:20b",
@@ -134,7 +133,7 @@ If Ollama is running on a different host or port (explicit config disables auto-
providers: {
ollama: {
apiKey: "ollama-local",
baseUrl: "http://ollama-host:11434/v1",
baseUrl: "http://ollama-host:11434",
},
},
},
@@ -174,45 +173,28 @@ Ollama is free and runs locally, so all model costs are set to $0.
### Streaming Configuration
Due to a [known issue](https://github.com/badlogic/pi-mono/issues/1205) in the underlying SDK with Ollama's response format, **streaming is disabled by default** for Ollama models. This prevents corrupted responses when using tool-capable models.
OpenClaw's Ollama integration uses the **native Ollama API** (`/api/chat`) by default, which fully supports streaming and tool calling simultaneously. No special configuration is needed.
When streaming is disabled, responses are delivered all at once (non-streaming mode), which avoids the issue where interleaved content/reasoning deltas cause garbled output.
#### Legacy OpenAI-Compatible Mode
#### Re-enable Streaming (Advanced)
If you want to re-enable streaming for Ollama (may cause issues with tool-capable models):
If you need to use the OpenAI-compatible endpoint instead (e.g., behind a proxy that only supports OpenAI format), set `api: "openai-completions"` explicitly:
```json5
{
agents: {
defaults: {
models: {
"ollama/gpt-oss:20b": {
streaming: true,
},
},
},
},
models: {
providers: {
ollama: {
baseUrl: "http://ollama-host:11434/v1",
api: "openai-completions",
apiKey: "ollama-local",
models: [...]
}
}
}
}
```
#### Disable Streaming for Other Providers
You can also disable streaming for any provider if needed:
```json5
{
agents: {
defaults: {
models: {
"openai/gpt-4": {
streaming: false,
},
},
},
},
}
```
Note: The OpenAI-compatible endpoint may not support streaming + tool calling simultaneously. You may need to disable streaming with `params: { streaming: false }` in model config.
### Context windows
@@ -261,15 +243,6 @@ ps aux | grep ollama
ollama serve
```
### Corrupted responses or tool names in output
If you see garbled responses containing tool names (like `sessions_send`, `memory_get`) or fragmented text when using Ollama models, this is due to an upstream SDK issue with streaming responses. **This is fixed by default** in the latest OpenClaw version by disabling streaming for Ollama models.
If you manually enabled streaming and experience this issue:
1. Remove the `streaming: true` configuration from your Ollama model entries, or
2. Explicitly set `streaming: false` for Ollama models (see [Streaming Configuration](#streaming-configuration))
## See Also
- [Model Providers](/concepts/model-providers) - Overview of all providers

View File

@@ -6,7 +6,6 @@ import type {
TimedFileInfo,
VideoFileInfo,
} from "@vector-im/matrix-bot-sdk";
import { parseBuffer, type IFileInfo } from "music-metadata";
import { getMatrixRuntime } from "../../runtime.js";
import { applyMatrixFormatting } from "./formatting.js";
import {
@@ -18,6 +17,7 @@ import {
} from "./types.js";
const getCore = () => getMatrixRuntime();
type IFileInfo = import("music-metadata").IFileInfo;
export function buildMatrixMediaInfo(params: {
size: number;
@@ -164,6 +164,7 @@ export async function resolveMediaDurationMs(params: {
return undefined;
}
try {
const { parseBuffer } = await import("music-metadata");
const fileInfo: IFileInfo | string | undefined =
params.contentType || params.fileName
? {

View File

@@ -0,0 +1,180 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import register from "./index.js";
describe("thread-ownership plugin", () => {
const hooks: Record<string, Function> = {};
const api = {
pluginConfig: {},
config: {
agents: {
list: [{ id: "test-agent", default: true, identity: { name: "TestBot" } }],
},
},
id: "thread-ownership",
name: "Thread Ownership",
logger: { info: vi.fn(), warn: vi.fn(), debug: vi.fn() },
on: vi.fn((hookName: string, handler: Function) => {
hooks[hookName] = handler;
}),
};
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
vi.clearAllMocks();
for (const key of Object.keys(hooks)) delete hooks[key];
process.env.SLACK_FORWARDER_URL = "http://localhost:8750";
process.env.SLACK_BOT_USER_ID = "U999";
originalFetch = globalThis.fetch;
globalThis.fetch = vi.fn();
});
afterEach(() => {
globalThis.fetch = originalFetch;
delete process.env.SLACK_FORWARDER_URL;
delete process.env.SLACK_BOT_USER_ID;
vi.restoreAllMocks();
});
it("registers message_received and message_sending hooks", () => {
register(api as any);
expect(api.on).toHaveBeenCalledTimes(2);
expect(api.on).toHaveBeenCalledWith("message_received", expect.any(Function));
expect(api.on).toHaveBeenCalledWith("message_sending", expect.any(Function));
});
describe("message_sending", () => {
beforeEach(() => {
register(api as any);
});
it("allows non-slack channels", async () => {
const result = await hooks.message_sending(
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
{ channelId: "discord", conversationId: "C123" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("allows top-level messages (no threadTs)", async () => {
const result = await hooks.message_sending(
{ content: "hello", metadata: {}, to: "C123" },
{ channelId: "slack", conversationId: "C123" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("claims ownership successfully", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }),
);
const result = await hooks.message_sending(
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
{ channelId: "slack", conversationId: "C123" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).toHaveBeenCalledWith(
"http://localhost:8750/api/v1/ownership/C123/1234.5678",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ agent_id: "test-agent" }),
}),
);
});
it("cancels when thread owned by another agent", async () => {
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "other-agent" }), { status: 409 }),
);
const result = await hooks.message_sending(
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
{ channelId: "slack", conversationId: "C123" },
);
expect(result).toEqual({ cancel: true });
expect(api.logger.info).toHaveBeenCalledWith(expect.stringContaining("cancelled send"));
});
it("fails open on network error", async () => {
vi.mocked(globalThis.fetch).mockRejectedValue(new Error("ECONNREFUSED"));
const result = await hooks.message_sending(
{ content: "hello", metadata: { threadTs: "1234.5678", channelId: "C123" }, to: "C123" },
{ channelId: "slack", conversationId: "C123" },
);
expect(result).toBeUndefined();
expect(api.logger.warn).toHaveBeenCalledWith(
expect.stringContaining("ownership check failed"),
);
});
});
describe("message_received @-mention tracking", () => {
beforeEach(() => {
register(api as any);
});
it("tracks @-mentions and skips ownership check for mentioned threads", async () => {
// Simulate receiving a message that @-mentions the agent.
await hooks.message_received(
{ content: "Hey @TestBot help me", metadata: { threadTs: "9999.0001", channelId: "C456" } },
{ channelId: "slack", conversationId: "C456" },
);
// Now send in the same thread -- should skip the ownership HTTP call.
const result = await hooks.message_sending(
{ content: "Sure!", metadata: { threadTs: "9999.0001", channelId: "C456" }, to: "C456" },
{ channelId: "slack", conversationId: "C456" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
it("ignores @-mentions on non-slack channels", async () => {
// Use a unique thread key so module-level state from other tests doesn't interfere.
await hooks.message_received(
{ content: "Hey @TestBot", metadata: { threadTs: "7777.0001", channelId: "C999" } },
{ channelId: "discord", conversationId: "C999" },
);
// The mention should not have been tracked, so sending should still call fetch.
vi.mocked(globalThis.fetch).mockResolvedValue(
new Response(JSON.stringify({ owner: "test-agent" }), { status: 200 }),
);
await hooks.message_sending(
{ content: "Sure!", metadata: { threadTs: "7777.0001", channelId: "C999" }, to: "C999" },
{ channelId: "slack", conversationId: "C999" },
);
expect(globalThis.fetch).toHaveBeenCalled();
});
it("tracks bot user ID mentions via <@U999> syntax", async () => {
await hooks.message_received(
{ content: "Hey <@U999> help", metadata: { threadTs: "8888.0001", channelId: "C789" } },
{ channelId: "slack", conversationId: "C789" },
);
const result = await hooks.message_sending(
{ content: "On it!", metadata: { threadTs: "8888.0001", channelId: "C789" }, to: "C789" },
{ channelId: "slack", conversationId: "C789" },
);
expect(result).toBeUndefined();
expect(globalThis.fetch).not.toHaveBeenCalled();
});
});
});

View File

@@ -0,0 +1,133 @@
import type { OpenClawConfig, OpenClawPluginApi } from "openclaw/plugin-sdk";
type ThreadOwnershipConfig = {
forwarderUrl?: string;
abTestChannels?: string[];
};
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
// In-memory set of {channel}:{thread} keys where this agent was @-mentioned.
// Entries expire after 5 minutes.
const mentionedThreads = new Map<string, number>();
const MENTION_TTL_MS = 5 * 60 * 1000;
function cleanExpiredMentions(): void {
const now = Date.now();
for (const [key, ts] of mentionedThreads) {
if (now - ts > MENTION_TTL_MS) {
mentionedThreads.delete(key);
}
}
}
function resolveOwnershipAgent(config: OpenClawConfig): { id: string; name: string } {
const list = Array.isArray(config.agents?.list)
? config.agents.list.filter((entry): entry is AgentEntry =>
Boolean(entry && typeof entry === "object"),
)
: [];
const selected = list.find((entry) => entry.default === true) ?? list[0];
const id =
typeof selected?.id === "string" && selected.id.trim() ? selected.id.trim() : "unknown";
const identityName =
typeof selected?.identity?.name === "string" ? selected.identity.name.trim() : "";
const fallbackName = typeof selected?.name === "string" ? selected.name.trim() : "";
const name = identityName || fallbackName;
return { id, name };
}
export default function register(api: OpenClawPluginApi) {
const pluginCfg = (api.pluginConfig ?? {}) as ThreadOwnershipConfig;
const forwarderUrl = (
pluginCfg.forwarderUrl ??
process.env.SLACK_FORWARDER_URL ??
"http://slack-forwarder:8750"
).replace(/\/$/, "");
const abTestChannels = new Set(
pluginCfg.abTestChannels ??
process.env.THREAD_OWNERSHIP_CHANNELS?.split(",").filter(Boolean) ??
[],
);
const { id: agentId, name: agentName } = resolveOwnershipAgent(api.config);
const botUserId = process.env.SLACK_BOT_USER_ID ?? "";
// ---------------------------------------------------------------------------
// message_received: track @-mentions so the agent can reply even if it
// doesn't own the thread.
// ---------------------------------------------------------------------------
api.on("message_received", async (event, ctx) => {
if (ctx.channelId !== "slack") return;
const text = event.content ?? "";
const threadTs = (event.metadata?.threadTs as string) ?? "";
const channelId = (event.metadata?.channelId as string) ?? ctx.conversationId ?? "";
if (!threadTs || !channelId) return;
// Check if this agent was @-mentioned.
const mentioned =
(agentName && text.includes(`@${agentName}`)) ||
(botUserId && text.includes(`<@${botUserId}>`));
if (mentioned) {
cleanExpiredMentions();
mentionedThreads.set(`${channelId}:${threadTs}`, Date.now());
}
});
// ---------------------------------------------------------------------------
// message_sending: check thread ownership before sending to Slack.
// Returns { cancel: true } if another agent owns the thread.
// ---------------------------------------------------------------------------
api.on("message_sending", async (event, ctx) => {
if (ctx.channelId !== "slack") return;
const threadTs = (event.metadata?.threadTs as string) ?? "";
const channelId = (event.metadata?.channelId as string) ?? event.to;
// Top-level messages (no thread) are always allowed.
if (!threadTs) return;
// Only enforce in A/B test channels (if set is empty, skip entirely).
if (abTestChannels.size > 0 && !abTestChannels.has(channelId)) return;
// If this agent was @-mentioned in this thread recently, skip ownership check.
cleanExpiredMentions();
if (mentionedThreads.has(`${channelId}:${threadTs}`)) return;
// Try to claim ownership via the forwarder HTTP API.
try {
const resp = await fetch(`${forwarderUrl}/api/v1/ownership/${channelId}/${threadTs}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ agent_id: agentId }),
signal: AbortSignal.timeout(3000),
});
if (resp.ok) {
// We own it (or just claimed it), proceed.
return;
}
if (resp.status === 409) {
// Another agent owns this thread — cancel the send.
const body = (await resp.json()) as { owner?: string };
api.logger.info?.(
`thread-ownership: cancelled send to ${channelId}:${threadTs} — owned by ${body.owner}`,
);
return { cancel: true };
}
// Unexpected status — fail open.
api.logger.warn?.(`thread-ownership: unexpected status ${resp.status}, allowing send`);
} catch (err) {
// Network error — fail open.
api.logger.warn?.(`thread-ownership: ownership check failed (${String(err)}), allowing send`);
}
});
}

View File

@@ -0,0 +1,28 @@
{
"id": "thread-ownership",
"name": "Thread Ownership",
"description": "Prevents multiple agents from responding in the same Slack thread. Uses HTTP calls to the slack-forwarder ownership API.",
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {
"forwarderUrl": {
"type": "string"
},
"abTestChannels": {
"type": "array",
"items": { "type": "string" }
}
}
},
"uiHints": {
"forwarderUrl": {
"label": "Forwarder URL",
"help": "Base URL of the slack-forwarder ownership API (default: http://slack-forwarder:8750)"
},
"abTestChannels": {
"label": "A/B Test Channels",
"help": "Slack channel IDs where thread ownership is enforced"
}
}
}

View File

@@ -2,6 +2,18 @@
set -euo pipefail
# If invoked from a linked worktree copy of this script, re-exec the canonical
# script from the repository root so behavior stays consistent across worktrees.
script_self="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
script_parent_dir="$(dirname "$script_self")"
if common_git_dir=$(git -C "$script_parent_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
canonical_repo_root="$(dirname "$common_git_dir")"
canonical_self="$canonical_repo_root/scripts/$(basename "${BASH_SOURCE[0]}")"
if [ "$script_self" != "$canonical_self" ] && [ -x "$canonical_self" ]; then
exec "$canonical_self" "$@"
fi
fi
usage() {
cat <<USAGE
Usage:
@@ -38,9 +50,18 @@ require_cmds() {
}
repo_root() {
# Resolve canonical root from script location so wrappers work from root or worktree cwd.
# Resolve canonical repository root from git common-dir so wrappers work
# the same from main checkout or any linked worktree.
local script_dir
local common_git_dir
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
if common_git_dir=$(git -C "$script_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
(cd "$(dirname "$common_git_dir")" && pwd)
return
fi
# Fallback for environments where git common-dir is unavailable.
(cd "$script_dir/.." && pwd)
}

View File

@@ -2,6 +2,13 @@
set -euo pipefail
script_dir="$(cd "$(dirname "$0")" && pwd)"
base="$script_dir/pr"
if common_git_dir=$(git -C "$script_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
canonical_base="$(dirname "$common_git_dir")/scripts/pr"
if [ -x "$canonical_base" ]; then
base="$canonical_base"
fi
fi
usage() {
cat <<USAGE
@@ -13,7 +20,7 @@ USAGE
}
if [ "$#" -eq 1 ]; then
exec "$script_dir/pr" merge-verify "$1"
exec "$base" merge-verify "$1"
fi
if [ "$#" -eq 2 ]; then
@@ -21,10 +28,10 @@ if [ "$#" -eq 2 ]; then
pr="$2"
case "$mode" in
verify)
exec "$script_dir/pr" merge-verify "$pr"
exec "$base" merge-verify "$pr"
;;
run)
exec "$script_dir/pr" merge-run "$pr"
exec "$base" merge-run "$pr"
;;
*)
usage

View File

@@ -8,7 +8,14 @@ fi
mode="$1"
pr="$2"
base="$(cd "$(dirname "$0")" && pwd)/pr"
script_dir="$(cd "$(dirname "$0")" && pwd)"
base="$script_dir/pr"
if common_git_dir=$(git -C "$script_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
canonical_base="$(dirname "$common_git_dir")/scripts/pr"
if [ -x "$canonical_base" ]; then
base="$canonical_base"
fi
fi
case "$mode" in
init)

View File

@@ -1,3 +1,13 @@
#!/usr/bin/env bash
set -euo pipefail
exec "$(cd "$(dirname "$0")" && pwd)/pr" review-init "$@"
script_dir="$(cd "$(dirname "$0")" && pwd)"
base="$script_dir/pr"
if common_git_dir=$(git -C "$script_dir" rev-parse --path-format=absolute --git-common-dir 2>/dev/null); then
canonical_base="$(dirname "$common_git_dir")/scripts/pr"
if [ -x "$canonical_base" ]; then
base="$canonical_base"
fi
fi
exec "$base" review-init "$@"

View File

@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# Scan for orphaned coding agent processes after a gateway restart.
#
# Background coding agents (Claude Code, Codex CLI) spawned by the gateway
# can outlive the session that started them when the gateway restarts.
# This script finds them and reports their state.
#
# Usage:
# recover-orphaned-processes.sh
#
# Output: JSON object with `orphaned` array and `ts` timestamp.
set -euo pipefail
usage() {
cat <<'USAGE'
Usage: recover-orphaned-processes.sh
Scans for likely orphaned coding agent processes and prints JSON.
USAGE
}
if [ "${1:-}" = "--help" ] || [ "${1:-}" = "-h" ]; then
usage
exit 0
fi
if [ "$#" -gt 0 ]; then
usage >&2
exit 2
fi
if ! command -v node &>/dev/null; then
_ts="unknown"
command -v date &>/dev/null && _ts="$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null)" || true
[ -z "$_ts" ] && _ts="unknown"
printf '{"error":"node not found on PATH","orphaned":[],"ts":"%s"}\n' "$_ts"
exit 0
fi
node <<'NODE'
const { execFileSync } = require("node:child_process");
const fs = require("node:fs");
let username = process.env.USER || process.env.LOGNAME || "";
if (username && !/^[a-zA-Z0-9._-]+$/.test(username)) {
username = "";
}
function runFile(file, args) {
try {
return execFileSync(file, args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
} catch (err) {
if (err && typeof err.stdout === "string") {
return err.stdout;
}
if (err && err.stdout && Buffer.isBuffer(err.stdout)) {
return err.stdout.toString("utf8");
}
return "";
}
}
function resolveStarted(pid) {
const started = runFile("ps", ["-o", "lstart=", "-p", String(pid)]).trim();
return started.length > 0 ? started : "unknown";
}
function resolveCwd(pid) {
if (process.platform === "linux") {
try {
return fs.readlinkSync(`/proc/${pid}/cwd`);
} catch {
return "unknown";
}
}
const lsof = runFile("lsof", ["-a", "-d", "cwd", "-p", String(pid), "-Fn"]);
const match = lsof.match(/^n(.+)$/m);
return match ? match[1] : "unknown";
}
function sanitizeCommand(cmd) {
// Avoid leaking obvious secrets when this diagnostic output is shared.
return cmd
.replace(
/(--(?:token|api[-_]?key|password|secret|authorization)\s+)([^\s]+)/gi,
"$1<redacted>",
)
.replace(
/((?:token|api[-_]?key|password|secret|authorization)=)([^\s]+)/gi,
"$1<redacted>",
)
.replace(/(Bearer\s+)[A-Za-z0-9._~+/=-]+/g, "$1<redacted>");
}
// Pre-filter candidate PIDs using pgrep to avoid scanning all processes.
// Only falls back to a full ps scan when pgrep is genuinely unavailable
// (ENOENT), not when it simply finds no matches (exit code 1).
let pgrepUnavailable = false;
const pgrepResult = (() => {
const args =
username.length > 0
? ["-u", username, "-f", "codex|claude"]
: ["-f", "codex|claude"];
try {
return execFileSync("pgrep", args, {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
} catch (err) {
if (err && err.code === "ENOENT") {
pgrepUnavailable = true;
return "";
}
// pgrep exit code 1 = no matches — return stdout (empty)
if (err && typeof err.stdout === "string") return err.stdout;
return "";
}
})();
const candidatePids = pgrepResult
.split("\n")
.map((s) => s.trim())
.filter((s) => s.length > 0 && /^\d+$/.test(s));
let lines;
if (candidatePids.length > 0) {
// Fetch command info only for candidate PIDs.
lines = runFile("ps", ["-o", "pid=,command=", "-p", candidatePids.join(",")]).split("\n");
} else if (pgrepUnavailable && username.length > 0) {
// pgrep not installed — fall back to user-scoped ps scan.
lines = runFile("ps", ["-U", username, "-o", "pid=,command="]).split("\n");
} else if (pgrepUnavailable) {
// pgrep not installed and no username — full scan as last resort.
lines = runFile("ps", ["-axo", "pid=,command="]).split("\n");
} else {
// pgrep ran successfully but found no matches — no orphans.
lines = [];
}
const includePattern = /codex|claude/i;
const excludePatterns = [
/openclaw-gateway/i,
/signal-cli/i,
/node_modules\/\.bin\/openclaw/i,
/recover-orphaned-processes\.sh/i,
];
const orphaned = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) {
continue;
}
const match = line.match(/^(\d+)\s+(.+)$/);
if (!match) {
continue;
}
const pid = Number(match[1]);
const cmd = match[2];
if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) {
continue;
}
if (!includePattern.test(cmd)) {
continue;
}
if (excludePatterns.some((pattern) => pattern.test(cmd))) {
continue;
}
orphaned.push({
pid,
cmd: sanitizeCommand(cmd),
cwd: resolveCwd(pid),
started: resolveStarted(pid),
});
}
process.stdout.write(
JSON.stringify({
orphaned,
ts: new Date().toISOString(),
}) + "\n",
);
NODE

View File

@@ -1,5 +1,5 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { spawn, spawnSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
@@ -15,6 +15,7 @@ const distEntry = path.join(distRoot, "/entry.js");
const buildStampPath = path.join(distRoot, ".buildstamp");
const srcRoot = path.join(cwd, "src");
const configFiles = [path.join(cwd, "tsconfig.json"), path.join(cwd, "package.json")];
const gitWatchedPaths = ["src", "tsconfig.json", "package.json"];
const statMtime = (filePath) => {
try {
@@ -74,12 +75,70 @@ const findLatestMtime = (dirPath, shouldSkip) => {
return latest;
};
const runGit = (args) => {
try {
const result = spawnSync("git", args, {
cwd,
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
});
if (result.status !== 0) {
return null;
}
return (result.stdout ?? "").trim();
} catch {
return null;
}
};
const resolveGitHead = () => {
const head = runGit(["rev-parse", "HEAD"]);
return head || null;
};
const hasDirtySourceTree = () => {
const output = runGit([
"status",
"--porcelain",
"--untracked-files=normal",
"--",
...gitWatchedPaths,
]);
if (output === null) {
return null;
}
return output.length > 0;
};
const readBuildStamp = () => {
const mtime = statMtime(buildStampPath);
if (mtime == null) {
return { mtime: null, head: null };
}
try {
const raw = fs.readFileSync(buildStampPath, "utf8").trim();
if (!raw.startsWith("{")) {
return { mtime, head: null };
}
const parsed = JSON.parse(raw);
const head = typeof parsed?.head === "string" && parsed.head.trim() ? parsed.head.trim() : null;
return { mtime, head };
} catch {
return { mtime, head: null };
}
};
const hasSourceMtimeChanged = (stampMtime) => {
const srcMtime = findLatestMtime(srcRoot, isExcludedSource);
return srcMtime != null && srcMtime > stampMtime;
};
const shouldBuild = () => {
if (env.OPENCLAW_FORCE_BUILD === "1") {
return true;
}
const stampMtime = statMtime(buildStampPath);
if (stampMtime == null) {
const stamp = readBuildStamp();
if (stamp.mtime == null) {
return true;
}
if (statMtime(distEntry) == null) {
@@ -88,13 +147,29 @@ const shouldBuild = () => {
for (const filePath of configFiles) {
const mtime = statMtime(filePath);
if (mtime != null && mtime > stampMtime) {
if (mtime != null && mtime > stamp.mtime) {
return true;
}
}
const srcMtime = findLatestMtime(srcRoot, isExcludedSource);
if (srcMtime != null && srcMtime > stampMtime) {
const currentHead = resolveGitHead();
if (currentHead && !stamp.head) {
return hasSourceMtimeChanged(stamp.mtime);
}
if (currentHead && stamp.head && currentHead !== stamp.head) {
return hasSourceMtimeChanged(stamp.mtime);
}
if (currentHead) {
const dirty = hasDirtySourceTree();
if (dirty === true) {
return true;
}
if (dirty === false) {
return false;
}
}
if (hasSourceMtimeChanged(stamp.mtime)) {
return true;
}
return false;
@@ -125,7 +200,11 @@ const runNode = () => {
const writeBuildStamp = () => {
try {
fs.mkdirSync(distRoot, { recursive: true });
fs.writeFileSync(buildStampPath, `${Date.now()}\n`);
const stamp = {
builtAt: Date.now(),
head: resolveGitHead(),
};
fs.writeFileSync(buildStampPath, `${JSON.stringify(stamp)}\n`);
} catch (error) {
// Best-effort stamp; still allow the runner to start.
logRunner(`Failed to write build stamp: ${error?.message ?? "unknown error"}`);

View File

@@ -20,7 +20,6 @@ const unitIsolatedFiles = [
"src/browser/server.post-tabs-open-profile-unknown-returns-404.test.ts",
"src/browser/server.agent-contract-snapshot-endpoints.test.ts",
"src/browser/server.agent-contract-form-layout-act-commands.test.ts",
"src/browser/server.serves-status-starts-browser-requested.test.ts",
"src/browser/server.skips-default-maxchars-explicitly-set-zero.test.ts",
"src/browser/server.auth-token-gates-http.test.ts",
"src/browser/server-context.remote-tab-ops.test.ts",

View File

@@ -6,6 +6,12 @@ const args = process.argv.slice(2);
const env = { ...process.env };
const cwd = process.cwd();
const compiler = "tsdown";
const watchSession = `${Date.now()}-${process.pid}`;
env.OPENCLAW_WATCH_MODE = "1";
env.OPENCLAW_WATCH_SESSION = watchSession;
if (args.length > 0) {
env.OPENCLAW_WATCH_COMMAND = args.join(" ");
}
const initialBuild = spawnSync("pnpm", ["exec", compiler], {
cwd,

View File

@@ -11,7 +11,7 @@ import { isMainModule } from "../infra/is-main.js";
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js";
import { AcpGatewayAgent } from "./translator.js";
export function serveAcpGateway(opts: AcpServerOptions = {}): void {
export function serveAcpGateway(opts: AcpServerOptions = {}): Promise<void> {
const cfg = loadConfig();
const connection = buildGatewayConnectionDetails({
config: cfg,
@@ -34,6 +34,12 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
auth.password;
let agent: AcpGatewayAgent | null = null;
let onClosed!: () => void;
const closed = new Promise<void>((resolve) => {
onClosed = resolve;
});
let stopped = false;
const gateway = new GatewayClient({
url: connection.url,
token: token || undefined,
@@ -50,9 +56,29 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
},
onClose: (code, reason) => {
agent?.handleGatewayDisconnect(`${code}: ${reason}`);
// Resolve only on intentional shutdown (gateway.stop() sets closed
// which skips scheduleReconnect, then fires onClose). Transient
// disconnects are followed by automatic reconnect attempts.
if (stopped) {
onClosed();
}
},
});
const shutdown = () => {
if (stopped) {
return;
}
stopped = true;
gateway.stop();
// If no WebSocket is active (e.g. between reconnect attempts),
// gateway.stop() won't trigger onClose, so resolve directly.
onClosed();
};
process.once("SIGINT", shutdown);
process.once("SIGTERM", shutdown);
const input = Writable.toWeb(process.stdout);
const output = Readable.toWeb(process.stdin) as unknown as ReadableStream<Uint8Array>;
const stream = ndJsonStream(input, output);
@@ -64,6 +90,7 @@ export function serveAcpGateway(opts: AcpServerOptions = {}): void {
}, stream);
gateway.start();
return closed;
}
function parseArgs(args: string[]): AcpServerOptions {
@@ -140,5 +167,8 @@ Options:
if (isMainModule({ currentFile: fileURLToPath(import.meta.url) })) {
const opts = parseArgs(process.argv.slice(2));
serveAcpGateway(opts);
serveAcpGateway(opts).catch((err) => {
console.error(String(err));
process.exit(1);
});
}

View File

@@ -30,6 +30,7 @@ export async function resolveBootstrapFilesForRun(params: {
await loadWorkspaceBootstrapFiles(params.workspaceDir),
sessionKey,
);
return applyBootstrapHookOverrides({
files: bootstrapFiles,
workspaceDir: params.workspaceDir,

View File

@@ -0,0 +1,240 @@
import type { Api, Model } from "@mariozechner/pi-ai";
import type { ModelRegistry } from "./pi-model-discovery.js";
import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js";
import { normalizeModelCompat } from "./model-compat.js";
import { normalizeProviderId } from "./model-selection.js";
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
const ZAI_GLM5_MODEL_ID = "glm-5";
const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const;
const ANTIGRAVITY_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTIGRAVITY_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
const ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID = "claude-opus-4-6-thinking";
const ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID = "claude-opus-4.6-thinking";
const ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS = [
"claude-opus-4-5-thinking",
"claude-opus-4.5-thinking",
] as const;
export const ANTIGRAVITY_OPUS_46_FORWARD_COMPAT_CANDIDATES = [
{
id: ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID,
templatePrefixes: [
"google-antigravity/claude-opus-4-5-thinking",
"google-antigravity/claude-opus-4.5-thinking",
],
},
{
id: ANTIGRAVITY_OPUS_46_MODEL_ID,
templatePrefixes: ["google-antigravity/claude-opus-4-5", "google-antigravity/claude-opus-4.5"],
},
] as const;
function resolveOpenAICodexGpt53FallbackModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
const trimmedModelId = modelId.trim();
if (normalizedProvider !== "openai-codex") {
return undefined;
}
if (trimmedModelId.toLowerCase() !== OPENAI_CODEX_GPT_53_MODEL_ID) {
return undefined;
}
for (const templateId of OPENAI_CODEX_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
api: "openai-codex-responses",
provider: normalizedProvider,
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
function resolveAnthropicOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "anthropic") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
const isOpus46 =
lower === ANTHROPIC_OPUS_46_MODEL_ID ||
lower === ANTHROPIC_OPUS_46_DOT_MODEL_ID ||
lower.startsWith(`${ANTHROPIC_OPUS_46_MODEL_ID}-`) ||
lower.startsWith(`${ANTHROPIC_OPUS_46_DOT_MODEL_ID}-`);
if (!isOpus46) {
return undefined;
}
const templateIds: string[] = [];
if (lower.startsWith(ANTHROPIC_OPUS_46_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_MODEL_ID, "claude-opus-4-5"));
}
if (lower.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5"));
}
templateIds.push(...ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS);
for (const templateId of [...new Set(templateIds)].filter(Boolean)) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return undefined;
}
// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet.
// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback.
function resolveZaiGlm5ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
if (normalizeProviderId(provider) !== "zai") {
return undefined;
}
const trimmed = modelId.trim();
const lower = trimmed.toLowerCase();
if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) {
return undefined;
}
for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find("zai", templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmed,
name: trimmed,
reasoning: true,
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmed,
name: trimmed,
api: "openai-completions",
provider: "zai",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
function resolveAntigravityOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "google-antigravity") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
const isOpus46 =
lower === ANTIGRAVITY_OPUS_46_MODEL_ID ||
lower === ANTIGRAVITY_OPUS_46_DOT_MODEL_ID ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_MODEL_ID}-`) ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_MODEL_ID}-`);
const isOpus46Thinking =
lower === ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID ||
lower === ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID}-`) ||
lower.startsWith(`${ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID}-`);
if (!isOpus46 && !isOpus46Thinking) {
return undefined;
}
const templateIds: string[] = [];
if (lower.startsWith(ANTIGRAVITY_OPUS_46_MODEL_ID)) {
templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_MODEL_ID, "claude-opus-4-5"));
}
if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID)) {
templateIds.push(lower.replace(ANTIGRAVITY_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5"));
}
if (lower.startsWith(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID)) {
templateIds.push(
lower.replace(ANTIGRAVITY_OPUS_46_THINKING_MODEL_ID, "claude-opus-4-5-thinking"),
);
}
if (lower.startsWith(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID)) {
templateIds.push(
lower.replace(ANTIGRAVITY_OPUS_46_DOT_THINKING_MODEL_ID, "claude-opus-4.5-thinking"),
);
}
templateIds.push(...ANTIGRAVITY_OPUS_TEMPLATE_MODEL_IDS);
templateIds.push(...ANTIGRAVITY_OPUS_THINKING_TEMPLATE_MODEL_IDS);
for (const templateId of [...new Set(templateIds)].filter(Boolean)) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return undefined;
}
export function resolveForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
return (
resolveOpenAICodexGpt53FallbackModel(provider, modelId, modelRegistry) ??
resolveAnthropicOpus46ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry) ??
resolveAntigravityOpus46ForwardCompatModel(provider, modelId, modelRegistry)
);
}

View File

@@ -29,25 +29,20 @@ describe("Ollama provider", () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
const providers = await resolveImplicitProviders({ agentDir });
// Ollama requires explicit configuration via OLLAMA_API_KEY env var or profile
expect(providers?.ollama).toBeUndefined();
});
it("should disable streaming by default for Ollama models", async () => {
it("should use native ollama api type", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "openclaw-test-"));
process.env.OLLAMA_API_KEY = "test-key";
try {
const providers = await resolveImplicitProviders({ agentDir });
// Provider should be defined with OLLAMA_API_KEY set
expect(providers?.ollama).toBeDefined();
expect(providers?.ollama?.apiKey).toBe("OLLAMA_API_KEY");
// Note: discoverOllamaModels() returns empty array in test environments (VITEST env var check)
// so we can't test the actual model discovery here. The streaming: false setting
// is applied in the model mapping within discoverOllamaModels().
// The configuration structure itself is validated by TypeScript and the Zod schema.
expect(providers?.ollama?.api).toBe("ollama");
expect(providers?.ollama?.baseUrl).toBe("http://127.0.0.1:11434");
} finally {
delete process.env.OLLAMA_API_KEY;
}
@@ -69,15 +64,14 @@ describe("Ollama provider", () => {
},
});
expect(providers?.ollama?.baseUrl).toBe("http://192.168.20.14:11434/v1");
// Native API strips /v1 suffix via resolveOllamaApiBase()
expect(providers?.ollama?.baseUrl).toBe("http://192.168.20.14:11434");
} finally {
delete process.env.OLLAMA_API_KEY;
}
});
it("should have correct model structure with streaming disabled (unit test)", () => {
// This test directly verifies the model configuration structure
// since discoverOllamaModels() returns empty array in test mode
it("should have correct model structure without streaming override", () => {
const mockOllamaModel = {
id: "llama3.3:latest",
name: "llama3.3:latest",
@@ -86,13 +80,9 @@ describe("Ollama provider", () => {
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 128000,
maxTokens: 8192,
params: {
streaming: false,
},
};
// Verify the model structure matches what discoverOllamaModels() would return
expect(mockOllamaModel.params?.streaming).toBe(false);
expect(mockOllamaModel.params).toHaveProperty("streaming");
// Native Ollama provider does not need streaming: false workaround
expect(mockOllamaModel).not.toHaveProperty("params");
});
});

View File

@@ -17,6 +17,7 @@ import {
buildHuggingfaceModelDefinition,
} from "./huggingface-models.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { OLLAMA_NATIVE_BASE_URL } from "./ollama-stream.js";
import {
buildSyntheticModelDefinition,
SYNTHETIC_BASE_URL,
@@ -79,8 +80,8 @@ const QWEN_PORTAL_DEFAULT_COST = {
cacheWrite: 0,
};
const OLLAMA_BASE_URL = "http://127.0.0.1:11434/v1";
const OLLAMA_API_BASE_URL = "http://127.0.0.1:11434";
const OLLAMA_BASE_URL = OLLAMA_NATIVE_BASE_URL;
const OLLAMA_API_BASE_URL = OLLAMA_BASE_URL;
const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
const OLLAMA_DEFAULT_COST = {
@@ -180,11 +181,6 @@ async function discoverOllamaModels(baseUrl?: string): Promise<ModelDefinitionCo
cost: OLLAMA_DEFAULT_COST,
contextWindow: OLLAMA_DEFAULT_CONTEXT_WINDOW,
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
// Disable streaming by default for Ollama to avoid SDK issue #1205
// See: https://github.com/badlogic/pi-mono/issues/1205
params: {
streaming: false,
},
};
});
} catch (error) {
@@ -541,8 +537,8 @@ async function buildVeniceProvider(): Promise<ProviderConfig> {
async function buildOllamaProvider(configuredBaseUrl?: string): Promise<ProviderConfig> {
const models = await discoverOllamaModels(configuredBaseUrl);
return {
baseUrl: configuredBaseUrl ?? OLLAMA_BASE_URL,
api: "openai-completions",
baseUrl: resolveOllamaApiBase(configuredBaseUrl),
api: "ollama",
models,
};
}

View File

@@ -0,0 +1,290 @@
import { describe, expect, it, vi } from "vitest";
import {
createOllamaStreamFn,
convertToOllamaMessages,
buildAssistantMessage,
parseNdjsonStream,
} from "./ollama-stream.js";
describe("convertToOllamaMessages", () => {
it("converts user text messages", () => {
const messages = [{ role: "user", content: "hello" }];
const result = convertToOllamaMessages(messages);
expect(result).toEqual([{ role: "user", content: "hello" }]);
});
it("converts user messages with content parts", () => {
const messages = [
{
role: "user",
content: [
{ type: "text", text: "describe this" },
{ type: "image", data: "base64data" },
],
},
];
const result = convertToOllamaMessages(messages);
expect(result).toEqual([{ role: "user", content: "describe this", images: ["base64data"] }]);
});
it("prepends system message when provided", () => {
const messages = [{ role: "user", content: "hello" }];
const result = convertToOllamaMessages(messages, "You are helpful.");
expect(result[0]).toEqual({ role: "system", content: "You are helpful." });
expect(result[1]).toEqual({ role: "user", content: "hello" });
});
it("converts assistant messages with toolCall content blocks", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me check." },
{ type: "toolCall", id: "call_1", name: "bash", arguments: { command: "ls" } },
],
},
];
const result = convertToOllamaMessages(messages);
expect(result[0].role).toBe("assistant");
expect(result[0].content).toBe("Let me check.");
expect(result[0].tool_calls).toEqual([
{ function: { name: "bash", arguments: { command: "ls" } } },
]);
});
it("converts tool result messages with 'tool' role", () => {
const messages = [{ role: "tool", content: "file1.txt\nfile2.txt" }];
const result = convertToOllamaMessages(messages);
expect(result).toEqual([{ role: "tool", content: "file1.txt\nfile2.txt" }]);
});
it("converts SDK 'toolResult' role to Ollama 'tool' role", () => {
const messages = [{ role: "toolResult", content: "command output here" }];
const result = convertToOllamaMessages(messages);
expect(result).toEqual([{ role: "tool", content: "command output here" }]);
});
it("includes tool_name from SDK toolResult messages", () => {
const messages = [{ role: "toolResult", content: "file contents here", toolName: "read" }];
const result = convertToOllamaMessages(messages);
expect(result).toEqual([{ role: "tool", content: "file contents here", tool_name: "read" }]);
});
it("omits tool_name when not provided in toolResult", () => {
const messages = [{ role: "toolResult", content: "output" }];
const result = convertToOllamaMessages(messages);
expect(result).toEqual([{ role: "tool", content: "output" }]);
expect(result[0]).not.toHaveProperty("tool_name");
});
it("handles empty messages array", () => {
const result = convertToOllamaMessages([]);
expect(result).toEqual([]);
});
});
describe("buildAssistantMessage", () => {
const modelInfo = { api: "ollama", provider: "ollama", id: "qwen3:32b" };
it("builds text-only response", () => {
const response = {
model: "qwen3:32b",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant" as const, content: "Hello!" },
done: true,
prompt_eval_count: 10,
eval_count: 5,
};
const result = buildAssistantMessage(response, modelInfo);
expect(result.role).toBe("assistant");
expect(result.content).toEqual([{ type: "text", text: "Hello!" }]);
expect(result.stopReason).toBe("stop");
expect(result.usage.input).toBe(10);
expect(result.usage.output).toBe(5);
expect(result.usage.totalTokens).toBe(15);
});
it("builds response with tool calls", () => {
const response = {
model: "qwen3:32b",
created_at: "2026-01-01T00:00:00Z",
message: {
role: "assistant" as const,
content: "",
tool_calls: [{ function: { name: "bash", arguments: { command: "ls -la" } } }],
},
done: true,
prompt_eval_count: 20,
eval_count: 10,
};
const result = buildAssistantMessage(response, modelInfo);
expect(result.stopReason).toBe("toolUse");
expect(result.content.length).toBe(1); // toolCall only (empty content is skipped)
expect(result.content[0].type).toBe("toolCall");
const toolCall = result.content[0] as {
type: "toolCall";
id: string;
name: string;
arguments: Record<string, unknown>;
};
expect(toolCall.name).toBe("bash");
expect(toolCall.arguments).toEqual({ command: "ls -la" });
expect(toolCall.id).toMatch(/^ollama_call_[0-9a-f-]{36}$/);
});
it("sets all costs to zero for local models", () => {
const response = {
model: "qwen3:32b",
created_at: "2026-01-01T00:00:00Z",
message: { role: "assistant" as const, content: "ok" },
done: true,
};
const result = buildAssistantMessage(response, modelInfo);
expect(result.usage.cost).toEqual({
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
total: 0,
});
});
});
// Helper: build a ReadableStreamDefaultReader from NDJSON lines
function mockNdjsonReader(lines: string[]): ReadableStreamDefaultReader<Uint8Array> {
const encoder = new TextEncoder();
const payload = lines.join("\n") + "\n";
let consumed = false;
return {
read: async () => {
if (consumed) {
return { done: true as const, value: undefined };
}
consumed = true;
return { done: false as const, value: encoder.encode(payload) };
},
releaseLock: () => {},
cancel: async () => {},
closed: Promise.resolve(undefined),
} as unknown as ReadableStreamDefaultReader<Uint8Array>;
}
describe("parseNdjsonStream", () => {
it("parses text-only streaming chunks", async () => {
const reader = mockNdjsonReader([
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"Hello"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":" world"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":5,"eval_count":2}',
]);
const chunks = [];
for await (const chunk of parseNdjsonStream(reader)) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(3);
expect(chunks[0].message.content).toBe("Hello");
expect(chunks[1].message.content).toBe(" world");
expect(chunks[2].done).toBe(true);
});
it("parses tool_calls from intermediate chunk (not final)", async () => {
// Ollama sends tool_calls in done:false chunk, final done:true has no tool_calls
const reader = mockNdjsonReader([
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":10,"eval_count":5}',
]);
const chunks = [];
for await (const chunk of parseNdjsonStream(reader)) {
chunks.push(chunk);
}
expect(chunks).toHaveLength(2);
expect(chunks[0].done).toBe(false);
expect(chunks[0].message.tool_calls).toHaveLength(1);
expect(chunks[0].message.tool_calls![0].function.name).toBe("bash");
expect(chunks[1].done).toBe(true);
expect(chunks[1].message.tool_calls).toBeUndefined();
});
it("accumulates tool_calls across multiple intermediate chunks", async () => {
const reader = mockNdjsonReader([
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"read","arguments":{"path":"/tmp/a"}}}]},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","tool_calls":[{"function":{"name":"bash","arguments":{"command":"ls"}}}]},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true}',
]);
// Simulate the accumulation logic from createOllamaStreamFn
const accumulatedToolCalls: Array<{
function: { name: string; arguments: Record<string, unknown> };
}> = [];
const chunks = [];
for await (const chunk of parseNdjsonStream(reader)) {
chunks.push(chunk);
if (chunk.message?.tool_calls) {
accumulatedToolCalls.push(...chunk.message.tool_calls);
}
}
expect(accumulatedToolCalls).toHaveLength(2);
expect(accumulatedToolCalls[0].function.name).toBe("read");
expect(accumulatedToolCalls[1].function.name).toBe("bash");
// Final done:true chunk has no tool_calls
expect(chunks[2].message.tool_calls).toBeUndefined();
});
});
describe("createOllamaStreamFn", () => {
it("normalizes /v1 baseUrl and maps maxTokens + signal", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => {
const payload = [
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
].join("\n");
return new Response(`${payload}\n`, {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
const streamFn = createOllamaStreamFn("http://ollama-host:11434/v1/");
const signal = new AbortController().signal;
const stream = streamFn(
{
id: "qwen3:32b",
api: "ollama",
provider: "custom-ollama",
contextWindow: 131072,
} as unknown as Parameters<typeof streamFn>[0],
{
messages: [{ role: "user", content: "hello" }],
} as unknown as Parameters<typeof streamFn>[1],
{
maxTokens: 123,
signal,
} as unknown as Parameters<typeof streamFn>[2],
);
const events = [];
for await (const event of stream) {
events.push(event);
}
expect(events.at(-1)?.type).toBe("done");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, requestInit] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe("http://ollama-host:11434/api/chat");
expect(requestInit.signal).toBe(signal);
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as {
options: { num_ctx?: number; num_predict?: number };
};
expect(requestBody.options.num_ctx).toBe(131072);
expect(requestBody.options.num_predict).toBe(123);
} finally {
globalThis.fetch = originalFetch;
}
});
});

419
src/agents/ollama-stream.ts Normal file
View File

@@ -0,0 +1,419 @@
import type { StreamFn } from "@mariozechner/pi-agent-core";
import type {
AssistantMessage,
StopReason,
TextContent,
ToolCall,
Tool,
Usage,
} from "@mariozechner/pi-ai";
import { createAssistantMessageEventStream } from "@mariozechner/pi-ai";
import { randomUUID } from "node:crypto";
export const OLLAMA_NATIVE_BASE_URL = "http://127.0.0.1:11434";
// ── Ollama /api/chat request types ──────────────────────────────────────────
interface OllamaChatRequest {
model: string;
messages: OllamaChatMessage[];
stream: boolean;
tools?: OllamaTool[];
options?: Record<string, unknown>;
}
interface OllamaChatMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
images?: string[];
tool_calls?: OllamaToolCall[];
tool_name?: string;
}
interface OllamaTool {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, unknown>;
};
}
interface OllamaToolCall {
function: {
name: string;
arguments: Record<string, unknown>;
};
}
// ── Ollama /api/chat response types ─────────────────────────────────────────
interface OllamaChatResponse {
model: string;
created_at: string;
message: {
role: "assistant";
content: string;
tool_calls?: OllamaToolCall[];
};
done: boolean;
done_reason?: string;
total_duration?: number;
load_duration?: number;
prompt_eval_count?: number;
prompt_eval_duration?: number;
eval_count?: number;
eval_duration?: number;
}
// ── Message conversion ──────────────────────────────────────────────────────
type InputContentPart =
| { type: "text"; text: string }
| { type: "image"; data: string }
| { type: "toolCall"; id: string; name: string; arguments: Record<string, unknown> }
| { type: "tool_use"; id: string; name: string; input: Record<string, unknown> };
function extractTextContent(content: unknown): string {
if (typeof content === "string") {
return content;
}
if (!Array.isArray(content)) {
return "";
}
return (content as InputContentPart[])
.filter((part): part is { type: "text"; text: string } => part.type === "text")
.map((part) => part.text)
.join("");
}
function extractOllamaImages(content: unknown): string[] {
if (!Array.isArray(content)) {
return [];
}
return (content as InputContentPart[])
.filter((part): part is { type: "image"; data: string } => part.type === "image")
.map((part) => part.data);
}
function extractToolCalls(content: unknown): OllamaToolCall[] {
if (!Array.isArray(content)) {
return [];
}
const parts = content as InputContentPart[];
const result: OllamaToolCall[] = [];
for (const part of parts) {
if (part.type === "toolCall") {
result.push({ function: { name: part.name, arguments: part.arguments } });
} else if (part.type === "tool_use") {
result.push({ function: { name: part.name, arguments: part.input } });
}
}
return result;
}
export function convertToOllamaMessages(
messages: Array<{ role: string; content: unknown }>,
system?: string,
): OllamaChatMessage[] {
const result: OllamaChatMessage[] = [];
if (system) {
result.push({ role: "system", content: system });
}
for (const msg of messages) {
const { role } = msg;
if (role === "user") {
const text = extractTextContent(msg.content);
const images = extractOllamaImages(msg.content);
result.push({
role: "user",
content: text,
...(images.length > 0 ? { images } : {}),
});
} else if (role === "assistant") {
const text = extractTextContent(msg.content);
const toolCalls = extractToolCalls(msg.content);
result.push({
role: "assistant",
content: text,
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
});
} else if (role === "tool" || role === "toolResult") {
// SDK uses "toolResult" (camelCase) for tool result messages.
// Ollama API expects "tool" role with tool_name per the native spec.
const text = extractTextContent(msg.content);
const toolName =
typeof (msg as { toolName?: unknown }).toolName === "string"
? (msg as { toolName?: string }).toolName
: undefined;
result.push({
role: "tool",
content: text,
...(toolName ? { tool_name: toolName } : {}),
});
}
}
return result;
}
// ── Tool extraction ─────────────────────────────────────────────────────────
function extractOllamaTools(tools: Tool[] | undefined): OllamaTool[] {
if (!tools || !Array.isArray(tools)) {
return [];
}
const result: OllamaTool[] = [];
for (const tool of tools) {
if (typeof tool.name !== "string" || !tool.name) {
continue;
}
result.push({
type: "function",
function: {
name: tool.name,
description: typeof tool.description === "string" ? tool.description : "",
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
},
});
}
return result;
}
// ── Response conversion ─────────────────────────────────────────────────────
export function buildAssistantMessage(
response: OllamaChatResponse,
modelInfo: { api: string; provider: string; id: string },
): AssistantMessage {
const content: (TextContent | ToolCall)[] = [];
if (response.message.content) {
content.push({ type: "text", text: response.message.content });
}
const toolCalls = response.message.tool_calls;
if (toolCalls && toolCalls.length > 0) {
for (const tc of toolCalls) {
content.push({
type: "toolCall",
id: `ollama_call_${randomUUID()}`,
name: tc.function.name,
arguments: tc.function.arguments,
});
}
}
const hasToolCalls = toolCalls && toolCalls.length > 0;
const stopReason: StopReason = hasToolCalls ? "toolUse" : "stop";
const usage: Usage = {
input: response.prompt_eval_count ?? 0,
output: response.eval_count ?? 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: (response.prompt_eval_count ?? 0) + (response.eval_count ?? 0),
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
return {
role: "assistant",
content,
stopReason,
api: modelInfo.api,
provider: modelInfo.provider,
model: modelInfo.id,
usage,
timestamp: Date.now(),
};
}
// ── NDJSON streaming parser ─────────────────────────────────────────────────
export async function* parseNdjsonStream(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<OllamaChatResponse> {
const decoder = new TextDecoder();
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
continue;
}
try {
yield JSON.parse(trimmed) as OllamaChatResponse;
} catch {
console.warn("[ollama-stream] Skipping malformed NDJSON line:", trimmed.slice(0, 120));
}
}
}
if (buffer.trim()) {
try {
yield JSON.parse(buffer.trim()) as OllamaChatResponse;
} catch {
console.warn(
"[ollama-stream] Skipping malformed trailing data:",
buffer.trim().slice(0, 120),
);
}
}
}
// ── Main StreamFn factory ───────────────────────────────────────────────────
function resolveOllamaChatUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
const normalizedBase = trimmed.replace(/\/v1$/i, "");
const apiBase = normalizedBase || OLLAMA_NATIVE_BASE_URL;
return `${apiBase}/api/chat`;
}
export function createOllamaStreamFn(baseUrl: string): StreamFn {
const chatUrl = resolveOllamaChatUrl(baseUrl);
return (model, context, options) => {
const stream = createAssistantMessageEventStream();
const run = async () => {
try {
const ollamaMessages = convertToOllamaMessages(
context.messages ?? [],
context.systemPrompt,
);
const ollamaTools = extractOllamaTools(context.tools);
// Ollama defaults to num_ctx=4096 which is too small for large
// system prompts + many tool definitions. Use model's contextWindow.
const ollamaOptions: Record<string, unknown> = { num_ctx: model.contextWindow ?? 65536 };
if (typeof options?.temperature === "number") {
ollamaOptions.temperature = options.temperature;
}
if (typeof options?.maxTokens === "number") {
ollamaOptions.num_predict = options.maxTokens;
}
const body: OllamaChatRequest = {
model: model.id,
messages: ollamaMessages,
stream: true,
...(ollamaTools.length > 0 ? { tools: ollamaTools } : {}),
options: ollamaOptions,
};
const headers: Record<string, string> = {
"Content-Type": "application/json",
...options?.headers,
};
if (options?.apiKey) {
headers.Authorization = `Bearer ${options.apiKey}`;
}
const response = await fetch(chatUrl, {
method: "POST",
headers,
body: JSON.stringify(body),
signal: options?.signal,
});
if (!response.ok) {
const errorText = await response.text().catch(() => "unknown error");
throw new Error(`Ollama API error ${response.status}: ${errorText}`);
}
if (!response.body) {
throw new Error("Ollama API returned empty response body");
}
const reader = response.body.getReader();
let accumulatedContent = "";
const accumulatedToolCalls: OllamaToolCall[] = [];
let finalResponse: OllamaChatResponse | undefined;
for await (const chunk of parseNdjsonStream(reader)) {
if (chunk.message?.content) {
accumulatedContent += chunk.message.content;
}
// Ollama sends tool_calls in intermediate (done:false) chunks,
// NOT in the final done:true chunk. Collect from all chunks.
if (chunk.message?.tool_calls) {
accumulatedToolCalls.push(...chunk.message.tool_calls);
}
if (chunk.done) {
finalResponse = chunk;
break;
}
}
if (!finalResponse) {
throw new Error("Ollama API stream ended without a final response");
}
finalResponse.message.content = accumulatedContent;
if (accumulatedToolCalls.length > 0) {
finalResponse.message.tool_calls = accumulatedToolCalls;
}
const assistantMessage = buildAssistantMessage(finalResponse, {
api: model.api,
provider: model.provider,
id: model.id,
});
const reason: Extract<StopReason, "stop" | "length" | "toolUse"> =
assistantMessage.stopReason === "toolUse" ? "toolUse" : "stop";
stream.push({
type: "done",
reason,
message: assistantMessage,
});
} catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err);
stream.push({
type: "error",
reason: "error",
error: {
role: "assistant" as const,
content: [],
stopReason: "error" as StopReason,
errorMessage,
api: model.api,
provider: model.provider,
model: model.id,
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
timestamp: Date.now(),
},
});
} finally {
stream.end();
}
};
queueMicrotask(() => void run());
return stream;
};
}

View File

@@ -18,198 +18,169 @@ function buildModel(): Model<"openai-responses"> {
};
}
function installFailingFetchCapture() {
const originalFetch = globalThis.fetch;
let lastBody: unknown;
const fetchImpl: typeof fetch = async (_input, init) => {
const rawBody = init?.body;
const bodyText = (() => {
if (!rawBody) {
return "";
}
if (typeof rawBody === "string") {
return rawBody;
}
if (rawBody instanceof Uint8Array) {
return Buffer.from(rawBody).toString("utf8");
}
if (rawBody instanceof ArrayBuffer) {
return Buffer.from(new Uint8Array(rawBody)).toString("utf8");
}
return null;
})();
lastBody = bodyText ? (JSON.parse(bodyText) as unknown) : undefined;
throw new Error("intentional fetch abort (test)");
};
globalThis.fetch = fetchImpl;
return {
getLastBody: () => lastBody as Record<string, unknown> | undefined,
restore: () => {
globalThis.fetch = originalFetch;
},
};
}
describe("openai-responses reasoning replay", () => {
it("replays reasoning for tool-call-only turns (OpenAI requires it)", async () => {
const cap = installFailingFetchCapture();
try {
const model = buildModel();
const model = buildModel();
const controller = new AbortController();
controller.abort();
let payload: Record<string, unknown> | undefined;
const assistantToolOnly: AssistantMessage = {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
const assistantToolOnly: AssistantMessage = {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "toolUse",
timestamp: Date.now(),
content: [
{
type: "thinking",
thinking: "internal",
thinkingSignature: JSON.stringify({
type: "reasoning",
id: "rs_test",
summary: [],
}),
},
stopReason: "toolUse",
timestamp: Date.now(),
content: [
{
type: "toolCall",
id: "call_123|fc_123",
name: "noop",
arguments: {},
},
],
};
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: "call_123|fc_123",
toolName: "noop",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now(),
};
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "system",
messages: [
{
type: "thinking",
thinking: "internal",
thinkingSignature: JSON.stringify({
type: "reasoning",
id: "rs_test",
summary: [],
}),
role: "user",
content: "Call noop.",
timestamp: Date.now(),
},
assistantToolOnly,
toolResult,
{
type: "toolCall",
id: "call_123|fc_123",
name: "noop",
arguments: {},
role: "user",
content: "Now reply with ok.",
timestamp: Date.now(),
},
],
};
const toolResult: ToolResultMessage = {
role: "toolResult",
toolCallId: "call_123|fc_123",
toolName: "noop",
content: [{ type: "text", text: "ok" }],
isError: false,
timestamp: Date.now(),
};
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "system",
messages: [
{
role: "user",
content: "Call noop.",
timestamp: Date.now(),
},
assistantToolOnly,
toolResult,
{
role: "user",
content: "Now reply with ok.",
timestamp: Date.now(),
},
],
tools: [
{
name: "noop",
description: "no-op",
parameters: Type.Object({}, { additionalProperties: false }),
},
],
tools: [
{
name: "noop",
description: "no-op",
parameters: Type.Object({}, { additionalProperties: false }),
},
],
},
{
apiKey: "test",
signal: controller.signal,
onPayload: (nextPayload) => {
payload = nextPayload as Record<string, unknown>;
},
{ apiKey: "test" },
);
},
);
await stream.result();
await stream.result();
const body = cap.getLastBody();
const input = Array.isArray(body?.input) ? body?.input : [];
const types = input
.map((item) =>
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
const input = Array.isArray(payload?.input) ? payload?.input : [];
const types = input
.map((item) =>
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
expect(types).toContain("reasoning");
expect(types).toContain("function_call");
expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call"));
} finally {
cap.restore();
}
expect(types).toContain("reasoning");
expect(types).toContain("function_call");
expect(types.indexOf("reasoning")).toBeLessThan(types.indexOf("function_call"));
});
it("still replays reasoning when paired with an assistant message", async () => {
const cap = installFailingFetchCapture();
try {
const model = buildModel();
const model = buildModel();
const controller = new AbortController();
controller.abort();
let payload: Record<string, unknown> | undefined;
const assistantWithText: AssistantMessage = {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
content: [
{
type: "thinking",
thinking: "internal",
thinkingSignature: JSON.stringify({
type: "reasoning",
id: "rs_test",
summary: [],
}),
},
{ type: "text", text: "hello", textSignature: "msg_test" },
],
};
const stream = streamOpenAIResponses(
model,
const assistantWithText: AssistantMessage = {
role: "assistant",
api: "openai-responses",
provider: "openai",
model: "gpt-5.2",
usage: {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
},
stopReason: "stop",
timestamp: Date.now(),
content: [
{
systemPrompt: "system",
messages: [
{ role: "user", content: "Hi", timestamp: Date.now() },
assistantWithText,
{ role: "user", content: "Ok", timestamp: Date.now() },
],
type: "thinking",
thinking: "internal",
thinkingSignature: JSON.stringify({
type: "reasoning",
id: "rs_test",
summary: [],
}),
},
{ apiKey: "test" },
);
{ type: "text", text: "hello", textSignature: "msg_test" },
],
};
await stream.result();
const stream = streamOpenAIResponses(
model,
{
systemPrompt: "system",
messages: [
{ role: "user", content: "Hi", timestamp: Date.now() },
assistantWithText,
{ role: "user", content: "Ok", timestamp: Date.now() },
],
},
{
apiKey: "test",
signal: controller.signal,
onPayload: (nextPayload) => {
payload = nextPayload as Record<string, unknown>;
},
},
);
const body = cap.getLastBody();
const input = Array.isArray(body?.input) ? body?.input : [];
const types = input
.map((item) =>
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
await stream.result();
expect(types).toContain("reasoning");
expect(types).toContain("message");
} finally {
cap.restore();
}
const input = Array.isArray(payload?.input) ? payload?.input : [];
const types = input
.map((item) =>
item && typeof item === "object" ? (item as Record<string, unknown>).type : undefined,
)
.filter((t): t is string => typeof t === "string");
expect(types).toContain("reasoning");
expect(types).toContain("message");
});
});

View File

@@ -143,14 +143,23 @@ describe("getDmHistoryLimitFromSessionKey", () => {
9,
);
});
it("returns undefined for non-dm session kinds", () => {
it("returns historyLimit for channel session kinds when configured", () => {
const config = {
channels: {
telegram: { dmHistoryLimit: 15 },
slack: { dmHistoryLimit: 10 },
slack: { historyLimit: 10, dmHistoryLimit: 15 },
discord: { historyLimit: 8 },
},
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:c1", config)).toBeUndefined();
expect(getDmHistoryLimitFromSessionKey("agent:beta:slack:channel:c1", config)).toBe(10);
expect(getDmHistoryLimitFromSessionKey("discord:channel:123456", config)).toBe(8);
});
it("returns undefined for non-dm/channel/group session kinds", () => {
const config = {
channels: {
telegram: { dmHistoryLimit: 15, historyLimit: 10 },
},
} as OpenClawConfig;
// "slash" is not dm, channel, or group
expect(getDmHistoryLimitFromSessionKey("telegram:slash:123", config)).toBeUndefined();
});
it("returns undefined for unknown provider", () => {
@@ -228,6 +237,46 @@ describe("getDmHistoryLimitFromSessionKey", () => {
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(5);
});
it("returns historyLimit for channel sessions for all providers", () => {
const providers = [
"telegram",
"whatsapp",
"discord",
"slack",
"signal",
"imessage",
"msteams",
"nextcloud-talk",
] as const;
for (const provider of providers) {
const config = {
channels: { [provider]: { historyLimit: 12 } },
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey(`${provider}:channel:123`, config)).toBe(12);
expect(getDmHistoryLimitFromSessionKey(`agent:main:${provider}:channel:456`, config)).toBe(
12,
);
}
});
it("returns historyLimit for group sessions", () => {
const config = {
channels: {
discord: { historyLimit: 15 },
slack: { historyLimit: 10 },
},
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey("discord:group:123", config)).toBe(15);
expect(getDmHistoryLimitFromSessionKey("agent:main:slack:group:abc", config)).toBe(10);
});
it("returns undefined for channel sessions when historyLimit is not configured", () => {
const config = {
channels: {
discord: { dmHistoryLimit: 10 }, // only dmHistoryLimit, no historyLimit
},
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey("discord:channel:123", config)).toBeUndefined();
});
describe("backward compatibility", () => {
it("accepts both legacy :dm: and new :direct: session keys", () => {

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { getDmHistoryLimitFromSessionKey } from "./pi-embedded-runner.js";
describe("getDmHistoryLimitFromSessionKey", () => {
it("keeps backward compatibility for dm/direct session kinds", () => {
const config = {
channels: { telegram: { dmHistoryLimit: 10 } },
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey("telegram:dm:123", config)).toBe(10);
expect(getDmHistoryLimitFromSessionKey("telegram:direct:123", config)).toBe(10);
});
it("returns historyLimit for channel and group session kinds", () => {
const config = {
channels: { discord: { historyLimit: 12, dmHistoryLimit: 5 } },
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey("discord:channel:123", config)).toBe(12);
expect(getDmHistoryLimitFromSessionKey("discord:group:456", config)).toBe(12);
});
it("returns undefined for unsupported session kinds", () => {
const config = {
channels: { discord: { historyLimit: 12, dmHistoryLimit: 5 } },
} as OpenClawConfig;
expect(getDmHistoryLimitFromSessionKey("discord:slash:123", config)).toBeUndefined();
});
});

View File

@@ -5,6 +5,7 @@ export { applyExtraParamsToAgent, resolveExtraParams } from "./pi-embedded-runne
export { applyGoogleTurnOrderingFix } from "./pi-embedded-runner/google.js";
export {
getDmHistoryLimitFromSessionKey,
getHistoryLimitFromSessionKey,
limitHistoryTurns,
} from "./pi-embedded-runner/history.js";
export { resolveEmbeddedSessionLane } from "./pi-embedded-runner/lanes.js";

View File

@@ -1,3 +1,4 @@
import type { AgentMessage } from "@mariozechner/pi-agent-core";
import {
createAgentSession,
estimateTokens,
@@ -13,6 +14,7 @@ import type { EmbeddedPiCompactResult } from "./types.js";
import { resolveHeartbeatPrompt } from "../../auto-reply/heartbeat.js";
import { resolveChannelCapabilities } from "../../config/channel-capabilities.js";
import { getMachineDisplayName } from "../../infra/machine-name.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { type enqueueCommand, enqueueCommandInLane } from "../../process/command-queue.js";
import { isSubagentSessionKey } from "../../routing/session-key.js";
import { resolveSignalReactionLevel } from "../../signal/reaction-level.js";
@@ -73,11 +75,12 @@ import {
createSystemPromptOverride,
} from "./system-prompt.js";
import { splitSdkTools } from "./tool-split.js";
import { describeUnknownError, mapThinkingLevel, resolveExecToolDefaults } from "./utils.js";
import { describeUnknownError, mapThinkingLevel } from "./utils.js";
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
export type CompactEmbeddedPiSessionParams = {
sessionId: string;
runId?: string;
sessionKey?: string;
messageChannel?: string;
messageProvider?: string;
@@ -104,12 +107,132 @@ export type CompactEmbeddedPiSessionParams = {
reasoningLevel?: ReasoningLevel;
bashElevated?: ExecElevatedDefaults;
customInstructions?: string;
trigger?: "overflow" | "manual" | "cache_ttl" | "safeguard";
diagId?: string;
attempt?: number;
maxAttempts?: number;
lane?: string;
enqueue?: typeof enqueueCommand;
extraSystemPrompt?: string;
ownerNumbers?: string[];
};
type CompactionMessageMetrics = {
messages: number;
historyTextChars: number;
toolResultChars: number;
estTokens?: number;
contributors: Array<{ role: string; chars: number; tool?: string }>;
};
function createCompactionDiagId(): string {
return `cmp-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
function getMessageTextChars(msg: AgentMessage): number {
const content = (msg as { content?: unknown }).content;
if (typeof content === "string") {
return content.length;
}
if (!Array.isArray(content)) {
return 0;
}
let total = 0;
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const text = (block as { text?: unknown }).text;
if (typeof text === "string") {
total += text.length;
}
}
return total;
}
function resolveMessageToolLabel(msg: AgentMessage): string | undefined {
const candidate =
(msg as { toolName?: unknown }).toolName ??
(msg as { name?: unknown }).name ??
(msg as { tool?: unknown }).tool;
return typeof candidate === "string" && candidate.trim().length > 0 ? candidate : undefined;
}
function summarizeCompactionMessages(messages: AgentMessage[]): CompactionMessageMetrics {
let historyTextChars = 0;
let toolResultChars = 0;
const contributors: Array<{ role: string; chars: number; tool?: string }> = [];
let estTokens = 0;
let tokenEstimationFailed = false;
for (const msg of messages) {
const role = typeof msg.role === "string" ? msg.role : "unknown";
const chars = getMessageTextChars(msg);
historyTextChars += chars;
if (role === "toolResult") {
toolResultChars += chars;
}
contributors.push({ role, chars, tool: resolveMessageToolLabel(msg) });
if (!tokenEstimationFailed) {
try {
estTokens += estimateTokens(msg);
} catch {
tokenEstimationFailed = true;
}
}
}
return {
messages: messages.length,
historyTextChars,
toolResultChars,
estTokens: tokenEstimationFailed ? undefined : estTokens,
contributors: contributors.toSorted((a, b) => b.chars - a.chars).slice(0, 3),
};
}
function classifyCompactionReason(reason?: string): string {
const text = (reason ?? "").trim().toLowerCase();
if (!text) {
return "unknown";
}
if (text.includes("nothing to compact")) {
return "no_compactable_entries";
}
if (text.includes("below threshold")) {
return "below_threshold";
}
if (text.includes("already compacted")) {
return "already_compacted_recently";
}
if (text.includes("guard")) {
return "guard_blocked";
}
if (text.includes("summary")) {
return "summary_failed";
}
if (text.includes("timed out") || text.includes("timeout")) {
return "timeout";
}
if (
text.includes("400") ||
text.includes("401") ||
text.includes("403") ||
text.includes("429")
) {
return "provider_error_4xx";
}
if (
text.includes("500") ||
text.includes("502") ||
text.includes("503") ||
text.includes("504")
) {
return "provider_error_5xx";
}
return "unknown";
}
/**
* Core compaction logic without lane queueing.
* Use this when already inside a session/global lane to avoid deadlocks.
@@ -117,6 +240,12 @@ export type CompactEmbeddedPiSessionParams = {
export async function compactEmbeddedPiSessionDirect(
params: CompactEmbeddedPiSessionParams,
): Promise<EmbeddedPiCompactResult> {
const startedAt = Date.now();
const diagId = params.diagId?.trim() || createCompactionDiagId();
const trigger = params.trigger ?? "manual";
const attempt = params.attempt ?? 1;
const maxAttempts = params.maxAttempts ?? 1;
const runId = params.runId ?? params.sessionId;
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const prevCwd = process.cwd();
@@ -131,10 +260,17 @@ export async function compactEmbeddedPiSessionDirect(
params.config,
);
if (!model) {
const reason = error ?? `Unknown model: ${provider}/${modelId}`;
log.warn(
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` +
`durationMs=${Date.now() - startedAt}`,
);
return {
ok: false,
compacted: false,
reason: error ?? `Unknown model: ${provider}/${modelId}`,
reason,
};
}
try {
@@ -161,10 +297,17 @@ export async function compactEmbeddedPiSessionDirect(
authStorage.setRuntimeApiKey(model.provider, apiKeyInfo.apiKey);
}
} catch (err) {
const reason = describeUnknownError(err);
log.warn(
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` +
`durationMs=${Date.now() - startedAt}`,
);
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
reason,
};
}
@@ -221,7 +364,6 @@ export async function compactEmbeddedPiSessionDirect(
const runAbortController = new AbortController();
const toolsRaw = createOpenClawCodingTools({
exec: {
...resolveExecToolDefaults(params.config),
elevated: params.bashElevated,
},
sandbox,
@@ -431,6 +573,8 @@ export async function compactEmbeddedPiSessionDirect(
const validated = transcriptPolicy.validateAnthropicTurns
? validateAnthropicTurns(validatedGemini)
: validatedGemini;
// Capture full message history BEFORE limiting — plugins need the complete conversation
const preCompactionMessages = [...session.messages];
const truncated = limitHistoryTurns(
validated,
getDmHistoryLimitFromSessionKey(params.sessionKey, params.config),
@@ -444,6 +588,50 @@ export async function compactEmbeddedPiSessionDirect(
if (limited.length > 0) {
session.agent.replaceMessages(limited);
}
// Run before_compaction hooks (fire-and-forget).
// The session JSONL already contains all messages on disk, so plugins
// can read sessionFile asynchronously and process in parallel with
// the compaction LLM call — no need to block or wait for after_compaction.
const hookRunner = getGlobalHookRunner();
const hookCtx = {
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageChannel ?? params.messageProvider,
};
if (hookRunner?.hasHooks("before_compaction")) {
hookRunner
.runBeforeCompaction(
{
messageCount: preCompactionMessages.length,
compactingCount: limited.length,
messages: preCompactionMessages,
sessionFile: params.sessionFile,
},
hookCtx,
)
.catch((hookErr: unknown) => {
log.warn(`before_compaction hook failed: ${String(hookErr)}`);
});
}
const diagEnabled = log.isEnabled("debug");
const preMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined;
if (diagEnabled && preMetrics) {
log.debug(
`[compaction-diag] start runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
`attempt=${attempt} maxAttempts=${maxAttempts} ` +
`pre.messages=${preMetrics.messages} pre.historyTextChars=${preMetrics.historyTextChars} ` +
`pre.toolResultChars=${preMetrics.toolResultChars} pre.estTokens=${preMetrics.estTokens ?? "unknown"}`,
);
log.debug(
`[compaction-diag] contributors diagId=${diagId} top=${JSON.stringify(preMetrics.contributors)}`,
);
}
const compactStartedAt = Date.now();
const result = await session.compact(params.customInstructions);
// Estimate tokens after compaction by summing token estimates for remaining messages
let tokensAfter: number | undefined;
@@ -460,6 +648,40 @@ export async function compactEmbeddedPiSessionDirect(
// If estimation fails, leave tokensAfter undefined
tokensAfter = undefined;
}
// Run after_compaction hooks (fire-and-forget).
// Also includes sessionFile for plugins that only need to act after
// compaction completes (e.g. analytics, cleanup).
if (hookRunner?.hasHooks("after_compaction")) {
hookRunner
.runAfterCompaction(
{
messageCount: session.messages.length,
tokenCount: tokensAfter,
compactedCount: limited.length - session.messages.length,
sessionFile: params.sessionFile,
},
hookCtx,
)
.catch((hookErr) => {
log.warn(`after_compaction hook failed: ${hookErr}`);
});
}
const postMetrics = diagEnabled ? summarizeCompactionMessages(session.messages) : undefined;
if (diagEnabled && preMetrics && postMetrics) {
log.debug(
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=compacted reason=none ` +
`durationMs=${Date.now() - compactStartedAt} retrying=false ` +
`post.messages=${postMetrics.messages} post.historyTextChars=${postMetrics.historyTextChars} ` +
`post.toolResultChars=${postMetrics.toolResultChars} post.estTokens=${postMetrics.estTokens ?? "unknown"} ` +
`delta.messages=${postMetrics.messages - preMetrics.messages} ` +
`delta.historyTextChars=${postMetrics.historyTextChars - preMetrics.historyTextChars} ` +
`delta.toolResultChars=${postMetrics.toolResultChars - preMetrics.toolResultChars} ` +
`delta.estTokens=${typeof preMetrics.estTokens === "number" && typeof postMetrics.estTokens === "number" ? postMetrics.estTokens - preMetrics.estTokens : "unknown"}`,
);
}
return {
ok: true,
compacted: true,
@@ -482,10 +704,17 @@ export async function compactEmbeddedPiSessionDirect(
await sessionLock.release();
}
} catch (err) {
const reason = describeUnknownError(err);
log.warn(
`[compaction-diag] end runId=${runId} sessionKey=${params.sessionKey ?? params.sessionId} ` +
`diagId=${diagId} trigger=${trigger} provider=${provider}/${modelId} ` +
`attempt=${attempt} maxAttempts=${maxAttempts} outcome=failed reason=${classifyCompactionReason(reason)} ` +
`durationMs=${Date.now() - startedAt}`,
);
return {
ok: false,
compacted: false,
reason: describeUnknownError(err),
reason,
};
} finally {
restoreSkillEnv?.();

View File

@@ -38,8 +38,9 @@ export function limitHistoryTurns(
/**
* Extract provider + user ID from a session key and look up dmHistoryLimit.
* Supports per-DM overrides and provider defaults.
* For channel/group sessions, uses historyLimit from provider config.
*/
export function getDmHistoryLimitFromSessionKey(
export function getHistoryLimitFromSessionKey(
sessionKey: string | undefined,
config: OpenClawConfig | undefined,
): number | undefined {
@@ -58,32 +59,17 @@ export function getDmHistoryLimitFromSessionKey(
const kind = providerParts[1]?.toLowerCase();
const userIdRaw = providerParts.slice(2).join(":");
const userId = stripThreadSuffix(userIdRaw);
// Accept both "direct" (new) and "dm" (legacy) for backward compat
if (kind !== "direct" && kind !== "dm") {
return undefined;
}
const getLimit = (
providerConfig:
| {
dmHistoryLimit?: number;
dms?: Record<string, { historyLimit?: number }>;
}
| undefined,
): number | undefined => {
if (!providerConfig) {
return undefined;
}
if (userId && providerConfig.dms?.[userId]?.historyLimit !== undefined) {
return providerConfig.dms[userId].historyLimit;
}
return providerConfig.dmHistoryLimit;
};
const resolveProviderConfig = (
cfg: OpenClawConfig | undefined,
providerId: string,
): { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> } | undefined => {
):
| {
historyLimit?: number;
dmHistoryLimit?: number;
dms?: Record<string, { historyLimit?: number }>;
}
| undefined => {
const channels = cfg?.channels;
if (!channels || typeof channels !== "object") {
return undefined;
@@ -92,8 +78,38 @@ export function getDmHistoryLimitFromSessionKey(
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
return undefined;
}
return entry as { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> };
return entry as {
historyLimit?: number;
dmHistoryLimit?: number;
dms?: Record<string, { historyLimit?: number }>;
};
};
return getLimit(resolveProviderConfig(config, provider));
const providerConfig = resolveProviderConfig(config, provider);
if (!providerConfig) {
return undefined;
}
// For DM sessions: per-DM override -> dmHistoryLimit.
// Accept both "direct" (new) and "dm" (legacy) for backward compat.
if (kind === "dm" || kind === "direct") {
if (userId && providerConfig.dms?.[userId]?.historyLimit !== undefined) {
return providerConfig.dms[userId].historyLimit;
}
return providerConfig.dmHistoryLimit;
}
// For channel/group sessions: use historyLimit from provider config
// This prevents context overflow in long-running channel sessions
if (kind === "channel" || kind === "group") {
return providerConfig.historyLimit;
}
return undefined;
}
/**
* @deprecated Use getHistoryLimitFromSessionKey instead.
* Alias for backward compatibility.
*/
export const getDmHistoryLimitFromSessionKey = getHistoryLimitFromSessionKey;

View File

@@ -172,43 +172,6 @@ describe("resolveModel", () => {
});
});
it("builds an openai-codex fallback for gpt-5.3-codex-spark", () => {
const templateModel = {
id: "gpt-5.2-codex",
name: "GPT-5.2 Codex",
provider: "openai-codex",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 1.75, output: 14, cacheRead: 0.175, cacheWrite: 0 },
contextWindow: 272000,
maxTokens: 128000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "openai-codex" && modelId === "gpt-5.2-codex") {
return templateModel;
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("openai-codex", "gpt-5.3-codex-spark", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "openai-codex",
id: "gpt-5.3-codex-spark",
api: "openai-codex-responses",
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
contextWindow: 272000,
maxTokens: 128000,
});
});
it("builds an anthropic forward-compat fallback for claude-opus-4-6", () => {
const templateModel = {
id: "claude-opus-4-5",
@@ -244,7 +207,7 @@ describe("resolveModel", () => {
});
});
it("builds a google-antigravity forward-compat fallback for claude-opus-4-6-thinking", () => {
it("builds an antigravity forward-compat fallback for claude-opus-4-6-thinking", () => {
const templateModel = {
id: "claude-opus-4-5-thinking",
name: "Claude Opus 4.5 Thinking",
@@ -253,8 +216,8 @@ describe("resolveModel", () => {
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: 1000000,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
@@ -276,6 +239,45 @@ describe("resolveModel", () => {
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
});
});
it("builds an antigravity forward-compat fallback for claude-opus-4-6", () => {
const templateModel = {
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
provider: "google-antigravity",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
input: ["text", "image"] as const,
cost: { input: 5, output: 25, cacheRead: 0.5, cacheWrite: 6.25 },
contextWindow: 200000,
maxTokens: 64000,
};
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn((provider: string, modelId: string) => {
if (provider === "google-antigravity" && modelId === "claude-opus-4-5") {
return templateModel;
}
return null;
}),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent");
expect(result.error).toBeUndefined();
expect(result.model).toMatchObject({
provider: "google-antigravity",
id: "claude-opus-4-6",
api: "google-gemini-cli",
baseUrl: "https://daily-cloudcode-pa.sandbox.googleapis.com",
reasoning: true,
contextWindow: 200000,
maxTokens: 64000,
});
});
@@ -314,18 +316,34 @@ describe("resolveModel", () => {
});
});
it("keeps unknown-model errors when no antigravity thinking template exists", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn(() => null),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6-thinking", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6-thinking");
});
it("keeps unknown-model errors when no antigravity non-thinking template exists", () => {
vi.mocked(discoverModels).mockReturnValue({
find: vi.fn(() => null),
} as unknown as ReturnType<typeof discoverModels>);
const result = resolveModel("google-antigravity", "claude-opus-4-6", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: google-antigravity/claude-opus-4-6");
});
it("keeps unknown-model errors for non-gpt-5 openai-codex ids", () => {
const result = resolveModel("openai-codex", "gpt-4.1-mini", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: openai-codex/gpt-4.1-mini");
});
it("errors for unknown gpt-5.3-codex-* variants", () => {
const result = resolveModel("openai-codex", "gpt-5.3-codex-unknown", "/tmp/agent");
expect(result.model).toBeUndefined();
expect(result.error).toBe("Unknown model: openai-codex/gpt-5.3-codex-unknown");
});
it("uses codex fallback even when openai-codex provider is configured", () => {
// This test verifies the ordering: codex fallback must fire BEFORE the generic providerCfg fallback.
// If ordering is wrong, the generic fallback would use api: "openai-responses" (the default)

View File

@@ -4,6 +4,7 @@ import type { ModelDefinitionConfig } from "../../config/types.js";
import { resolveOpenClawAgentDir } from "../agent-paths.js";
import { DEFAULT_CONTEXT_TOKENS } from "../defaults.js";
import { normalizeModelCompat } from "../model-compat.js";
import { resolveForwardCompatModel } from "../model-forward-compat.js";
import { normalizeProviderId } from "../model-selection.js";
import {
discoverAuthStorage,
@@ -19,188 +20,6 @@ type InlineProviderConfig = {
models?: ModelDefinitionConfig[];
};
const OPENAI_CODEX_GPT_53_MODEL_ID = "gpt-5.3-codex";
const OPENAI_CODEX_GPT_53_SPARK_MODEL_ID = "gpt-5.3-codex-spark";
const OPENAI_CODEX_TEMPLATE_MODEL_IDS = ["gpt-5.2-codex"] as const;
// pi-ai's built-in Anthropic catalog can lag behind OpenClaw's defaults/docs.
// Add forward-compat fallbacks for known-new IDs by cloning an older template model.
const ANTHROPIC_OPUS_46_MODEL_ID = "claude-opus-4-6";
const ANTHROPIC_OPUS_46_DOT_MODEL_ID = "claude-opus-4.6";
const ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS = ["claude-opus-4-5", "claude-opus-4.5"] as const;
function resolveOpenAICodexGpt53FallbackModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
const trimmedModelId = modelId.trim();
if (normalizedProvider !== "openai-codex") {
return undefined;
}
const lower = trimmedModelId.toLowerCase();
const isGpt53 = lower === OPENAI_CODEX_GPT_53_MODEL_ID;
const isSpark = lower === OPENAI_CODEX_GPT_53_SPARK_MODEL_ID;
if (!isGpt53 && !isSpark) {
return undefined;
}
for (const templateId of OPENAI_CODEX_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
// Spark is a low-latency variant; keep api/baseUrl from template.
...(isSpark ? { reasoning: true } : {}),
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmedModelId,
name: trimmedModelId,
api: "openai-codex-responses",
provider: normalizedProvider,
baseUrl: "https://chatgpt.com/backend-api",
reasoning: true,
input: ["text", "image"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
function resolveAnthropicOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
const normalizedProvider = normalizeProviderId(provider);
if (normalizedProvider !== "anthropic") {
return undefined;
}
const trimmedModelId = modelId.trim();
const lower = trimmedModelId.toLowerCase();
const isOpus46 =
lower === ANTHROPIC_OPUS_46_MODEL_ID ||
lower === ANTHROPIC_OPUS_46_DOT_MODEL_ID ||
lower.startsWith(`${ANTHROPIC_OPUS_46_MODEL_ID}-`) ||
lower.startsWith(`${ANTHROPIC_OPUS_46_DOT_MODEL_ID}-`);
if (!isOpus46) {
return undefined;
}
const templateIds: string[] = [];
if (lower.startsWith(ANTHROPIC_OPUS_46_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_MODEL_ID, "claude-opus-4-5"));
}
if (lower.startsWith(ANTHROPIC_OPUS_46_DOT_MODEL_ID)) {
templateIds.push(lower.replace(ANTHROPIC_OPUS_46_DOT_MODEL_ID, "claude-opus-4.5"));
}
templateIds.push(...ANTHROPIC_OPUS_TEMPLATE_MODEL_IDS);
for (const templateId of [...new Set(templateIds)].filter(Boolean)) {
const template = modelRegistry.find(normalizedProvider, templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmedModelId,
name: trimmedModelId,
} as Model<Api>);
}
return undefined;
}
// Z.ai's GLM-5 may not be present in pi-ai's built-in model catalog yet.
// When a user configures zai/glm-5 without a models.json entry, clone glm-4.7 as a forward-compat fallback.
const ZAI_GLM5_MODEL_ID = "glm-5";
const ZAI_GLM5_TEMPLATE_MODEL_IDS = ["glm-4.7"] as const;
function resolveZaiGlm5ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
if (normalizeProviderId(provider) !== "zai") {
return undefined;
}
const trimmed = modelId.trim();
const lower = trimmed.toLowerCase();
if (lower !== ZAI_GLM5_MODEL_ID && !lower.startsWith(`${ZAI_GLM5_MODEL_ID}-`)) {
return undefined;
}
for (const templateId of ZAI_GLM5_TEMPLATE_MODEL_IDS) {
const template = modelRegistry.find("zai", templateId) as Model<Api> | null;
if (!template) {
continue;
}
return normalizeModelCompat({
...template,
id: trimmed,
name: trimmed,
reasoning: true,
} as Model<Api>);
}
return normalizeModelCompat({
id: trimmed,
name: trimmed,
api: "openai-completions",
provider: "zai",
reasoning: true,
input: ["text"],
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
contextWindow: DEFAULT_CONTEXT_TOKENS,
maxTokens: DEFAULT_CONTEXT_TOKENS,
} as Model<Api>);
}
// google-antigravity's model catalog in pi-ai can lag behind the actual platform.
// When a google-antigravity model ID contains "opus-4-6" (or "opus-4.6") but isn't
// in the registry yet, clone the opus-4-5 template so the correct api
// ("google-gemini-cli") and baseUrl are preserved.
const ANTIGRAVITY_OPUS_46_STEMS = ["claude-opus-4-6", "claude-opus-4.6"] as const;
const ANTIGRAVITY_OPUS_45_TEMPLATES = ["claude-opus-4-5-thinking", "claude-opus-4-5"] as const;
function resolveAntigravityOpus46ForwardCompatModel(
provider: string,
modelId: string,
modelRegistry: ModelRegistry,
): Model<Api> | undefined {
if (normalizeProviderId(provider) !== "google-antigravity") {
return undefined;
}
const lower = modelId.trim().toLowerCase();
const isOpus46 = ANTIGRAVITY_OPUS_46_STEMS.some(
(stem) => lower === stem || lower.startsWith(`${stem}-`),
);
if (!isOpus46) {
return undefined;
}
for (const templateId of ANTIGRAVITY_OPUS_45_TEMPLATES) {
const template = modelRegistry.find("google-antigravity", templateId) as Model<Api> | null;
if (template) {
return normalizeModelCompat({
...template,
id: modelId.trim(),
name: modelId.trim(),
} as Model<Api>);
}
}
return undefined;
}
export function buildInlineProviderModels(
providers: Record<string, InlineProviderConfig>,
): InlineModelEntry[] {
@@ -267,36 +86,11 @@ export function resolveModel(
modelRegistry,
};
}
// Codex gpt-5.3 forward-compat fallback must be checked BEFORE the generic providerCfg fallback.
// Otherwise, if cfg.models.providers["openai-codex"] is configured, the generic fallback fires
// with api: "openai-responses" instead of the correct "openai-codex-responses".
const codexForwardCompat = resolveOpenAICodexGpt53FallbackModel(
provider,
modelId,
modelRegistry,
);
if (codexForwardCompat) {
return { model: codexForwardCompat, authStorage, modelRegistry };
}
const anthropicForwardCompat = resolveAnthropicOpus46ForwardCompatModel(
provider,
modelId,
modelRegistry,
);
if (anthropicForwardCompat) {
return { model: anthropicForwardCompat, authStorage, modelRegistry };
}
const antigravityForwardCompat = resolveAntigravityOpus46ForwardCompatModel(
provider,
modelId,
modelRegistry,
);
if (antigravityForwardCompat) {
return { model: antigravityForwardCompat, authStorage, modelRegistry };
}
const zaiForwardCompat = resolveZaiGlm5ForwardCompatModel(provider, modelId, modelRegistry);
if (zaiForwardCompat) {
return { model: zaiForwardCompat, authStorage, modelRegistry };
// Forward-compat fallbacks must be checked BEFORE the generic providerCfg fallback.
// Otherwise, configured providers can default to a generic API and break specific transports.
const forwardCompat = resolveForwardCompatModel(provider, modelId, modelRegistry);
if (forwardCompat) {
return { model: forwardCompat, authStorage, modelRegistry };
}
const providerCfg = providers[provider];
if (providerCfg || modelId.startsWith("mock-")) {

View File

@@ -97,6 +97,10 @@ const createUsageAccumulator = (): UsageAccumulator => ({
lastInput: 0,
});
function createCompactionDiagId(): string {
return `ovf-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
const hasUsageValues = (
usage: ReturnType<typeof normalizeUsage>,
): usage is NonNullable<ReturnType<typeof normalizeUsage>> =>
@@ -515,13 +519,15 @@ export async function runEmbeddedPiAgent(
: null;
if (contextOverflowError) {
const overflowDiagId = createCompactionDiagId();
const errorText = contextOverflowError.text;
const msgCount = attempt.messagesSnapshot?.length ?? 0;
log.warn(
`[context-overflow-diag] sessionKey=${params.sessionKey ?? params.sessionId} ` +
`provider=${provider}/${modelId} source=${contextOverflowError.source} ` +
`messages=${msgCount} sessionFile=${params.sessionFile} ` +
`compactionAttempts=${overflowCompactionAttempts} error=${errorText.slice(0, 200)}`,
`diagId=${overflowDiagId} compactionAttempts=${overflowCompactionAttempts} ` +
`error=${errorText.slice(0, 200)}`,
);
const isCompactionFailure = isCompactionFailureError(errorText);
// Attempt auto-compaction on context overflow (not compaction_failure)
@@ -529,6 +535,13 @@ export async function runEmbeddedPiAgent(
!isCompactionFailure &&
overflowCompactionAttempts < MAX_OVERFLOW_COMPACTION_ATTEMPTS
) {
if (log.isEnabled("debug")) {
log.debug(
`[compaction-diag] decision diagId=${overflowDiagId} branch=compact ` +
`isCompactionFailure=${isCompactionFailure} hasOversizedToolResults=unknown ` +
`attempt=${overflowCompactionAttempts + 1} maxAttempts=${MAX_OVERFLOW_COMPACTION_ATTEMPTS}`,
);
}
overflowCompactionAttempts++;
log.warn(
`context overflow detected (attempt ${overflowCompactionAttempts}/${MAX_OVERFLOW_COMPACTION_ATTEMPTS}); attempting auto-compaction for ${provider}/${modelId}`,
@@ -548,11 +561,16 @@ export async function runEmbeddedPiAgent(
senderIsOwner: params.senderIsOwner,
provider,
model: modelId,
runId: params.runId,
thinkLevel,
reasoningLevel: params.reasoningLevel,
bashElevated: params.bashElevated,
extraSystemPrompt: params.extraSystemPrompt,
ownerNumbers: params.ownerNumbers,
trigger: "overflow",
diagId: overflowDiagId,
attempt: overflowCompactionAttempts,
maxAttempts: MAX_OVERFLOW_COMPACTION_ATTEMPTS,
});
if (compactResult.compacted) {
autoCompactionCount += 1;
@@ -576,6 +594,13 @@ export async function runEmbeddedPiAgent(
: false;
if (hasOversized) {
if (log.isEnabled("debug")) {
log.debug(
`[compaction-diag] decision diagId=${overflowDiagId} branch=truncate_tool_results ` +
`isCompactionFailure=${isCompactionFailure} hasOversizedToolResults=${hasOversized} ` +
`attempt=${overflowCompactionAttempts} maxAttempts=${MAX_OVERFLOW_COMPACTION_ATTEMPTS}`,
);
}
toolResultTruncationAttempted = true;
log.warn(
`[context-overflow-recovery] Attempting tool result truncation for ${provider}/${modelId} ` +
@@ -598,8 +623,26 @@ export async function runEmbeddedPiAgent(
log.warn(
`[context-overflow-recovery] Tool result truncation did not help: ${truncResult.reason ?? "unknown"}`,
);
} else if (log.isEnabled("debug")) {
log.debug(
`[compaction-diag] decision diagId=${overflowDiagId} branch=give_up ` +
`isCompactionFailure=${isCompactionFailure} hasOversizedToolResults=${hasOversized} ` +
`attempt=${overflowCompactionAttempts} maxAttempts=${MAX_OVERFLOW_COMPACTION_ATTEMPTS}`,
);
}
}
if (
(isCompactionFailure ||
overflowCompactionAttempts >= MAX_OVERFLOW_COMPACTION_ATTEMPTS ||
toolResultTruncationAttempted) &&
log.isEnabled("debug")
) {
log.debug(
`[compaction-diag] decision diagId=${overflowDiagId} branch=give_up ` +
`isCompactionFailure=${isCompactionFailure} hasOversizedToolResults=unknown ` +
`attempt=${overflowCompactionAttempts} maxAttempts=${MAX_OVERFLOW_COMPACTION_ATTEMPTS}`,
);
}
const kind = isCompactionFailure ? "compaction_failure" : "context_overflow";
return {
payloads: [

View File

@@ -31,6 +31,7 @@ import { resolveOpenClawDocsPath } from "../../docs-path.js";
import { isTimeoutError } from "../../failover-error.js";
import { resolveModelAuthMode } from "../../model-auth.js";
import { resolveDefaultModelForAgent } from "../../model-selection.js";
import { createOllamaStreamFn, OLLAMA_NATIVE_BASE_URL } from "../../ollama-stream.js";
import {
isCloudCodeAssistFormatError,
resolveBootstrapMaxChars,
@@ -140,6 +141,69 @@ export function injectHistoryImagesIntoMessages(
return didMutate;
}
function summarizeMessagePayload(msg: AgentMessage): { textChars: number; imageBlocks: number } {
const content = (msg as { content?: unknown }).content;
if (typeof content === "string") {
return { textChars: content.length, imageBlocks: 0 };
}
if (!Array.isArray(content)) {
return { textChars: 0, imageBlocks: 0 };
}
let textChars = 0;
let imageBlocks = 0;
for (const block of content) {
if (!block || typeof block !== "object") {
continue;
}
const typedBlock = block as { type?: unknown; text?: unknown };
if (typedBlock.type === "image") {
imageBlocks++;
continue;
}
if (typeof typedBlock.text === "string") {
textChars += typedBlock.text.length;
}
}
return { textChars, imageBlocks };
}
function summarizeSessionContext(messages: AgentMessage[]): {
roleCounts: string;
totalTextChars: number;
totalImageBlocks: number;
maxMessageTextChars: number;
} {
const roleCounts = new Map<string, number>();
let totalTextChars = 0;
let totalImageBlocks = 0;
let maxMessageTextChars = 0;
for (const msg of messages) {
const role = typeof msg.role === "string" ? msg.role : "unknown";
roleCounts.set(role, (roleCounts.get(role) ?? 0) + 1);
const payload = summarizeMessagePayload(msg);
totalTextChars += payload.textChars;
totalImageBlocks += payload.imageBlocks;
if (payload.textChars > maxMessageTextChars) {
maxMessageTextChars = payload.textChars;
}
}
return {
roleCounts:
[...roleCounts.entries()]
.toSorted((a, b) => a[0].localeCompare(b[0]))
.map(([role, count]) => `${role}:${count}`)
.join(",") || "none",
totalTextChars,
totalImageBlocks,
maxMessageTextChars,
};
}
export async function runEmbeddedAttempt(
params: EmbeddedRunAttemptParams,
): Promise<EmbeddedRunAttemptResult> {
@@ -521,8 +585,21 @@ export async function runEmbeddedAttempt(
workspaceDir: params.workspaceDir,
});
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple;
// Ollama native API: bypass SDK's streamSimple and use direct /api/chat calls
// for reliable streaming + tool calling support (#11828).
if (params.model.api === "ollama") {
// Use the resolved model baseUrl first so custom provider aliases work.
const providerConfig = params.config?.models?.providers?.[params.model.provider];
const modelBaseUrl =
typeof params.model.baseUrl === "string" ? params.model.baseUrl.trim() : "";
const providerBaseUrl =
typeof providerConfig?.baseUrl === "string" ? providerConfig.baseUrl.trim() : "";
const ollamaBaseUrl = modelBaseUrl || providerBaseUrl || OLLAMA_NATIVE_BASE_URL;
activeSession.agent.streamFn = createOllamaStreamFn(ollamaBaseUrl);
} else {
// Force a stable streamFn reference so vitest can reliably mock @mariozechner/pi-ai.
activeSession.agent.streamFn = streamSimple;
}
applyExtraParamsToAgent(
activeSession.agent,
@@ -749,6 +826,7 @@ export async function runEmbeddedAttempt(
{
agentId: hookAgentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},
@@ -825,6 +903,25 @@ export async function runEmbeddedAttempt(
note: `images: prompt=${imageResult.images.length} history=${imageResult.historyImagesByIndex.size}`,
});
// Diagnostic: log context sizes before prompt to help debug early overflow errors.
if (log.isEnabled("debug")) {
const msgCount = activeSession.messages.length;
const systemLen = systemPromptText?.length ?? 0;
const promptLen = effectivePrompt.length;
const sessionSummary = summarizeSessionContext(activeSession.messages);
log.debug(
`[context-diag] pre-prompt: sessionKey=${params.sessionKey ?? params.sessionId} ` +
`messages=${msgCount} roleCounts=${sessionSummary.roleCounts} ` +
`historyTextChars=${sessionSummary.totalTextChars} ` +
`maxMessageTextChars=${sessionSummary.maxMessageTextChars} ` +
`historyImageBlocks=${sessionSummary.totalImageBlocks} ` +
`systemPromptChars=${systemLen} promptChars=${promptLen} ` +
`promptImages=${imageResult.images.length} ` +
`historyImageMessages=${imageResult.historyImagesByIndex.size} ` +
`provider=${params.provider}/${params.modelId} sessionFile=${params.sessionFile}`,
);
}
// Only pass images option if there are actually images to pass
// This avoids potential issues with models that don't expect the images parameter
if (imageResult.images.length > 0) {
@@ -890,6 +987,7 @@ export async function runEmbeddedAttempt(
{
agentId: hookAgentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
workspaceDir: params.workspaceDir,
messageProvider: params.messageProvider ?? undefined,
},

View File

@@ -64,6 +64,10 @@ export function isEmbeddedPiRunStreaming(sessionId: string): boolean {
return handle.isStreaming();
}
export function getActiveEmbeddedRunCount(): number {
return ACTIVE_EMBEDDED_RUNS.size;
}
export function waitForEmbeddedPiRunEnd(sessionId: string, timeoutMs = 15_000): Promise<boolean> {
if (!sessionId || !ACTIVE_EMBEDDED_RUNS.has(sessionId)) {
return Promise.resolve(true);

View File

@@ -1,7 +1,5 @@
import type { ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { ExecToolDefaults } from "../bash-tools.js";
export function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
// pi-agent-core supports "xhigh"; OpenClaw enables it for specific models.
@@ -11,14 +9,6 @@ export function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
return level;
}
export function resolveExecToolDefaults(config?: OpenClawConfig): ExecToolDefaults | undefined {
const tools = config?.tools;
if (!tools?.exec) {
return undefined;
}
return tools.exec;
}
export function describeUnknownError(error: unknown): string {
if (error instanceof Error) {
return error.message;

View File

@@ -1,8 +1,5 @@
import type { AgentEvent } from "@mariozechner/pi-agent-core";
import type {
PluginHookAfterToolCallEvent,
PluginHookBeforeToolCallEvent,
} from "../plugins/types.js";
import type { PluginHookAfterToolCallEvent } from "../plugins/types.js";
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
@@ -61,20 +58,6 @@ export async function handleToolExecutionStart(
// Track start time and args for after_tool_call hook
toolStartData.set(toolCallId, { startTime: Date.now(), args });
// Call before_tool_call hook
const hookRunner = ctx.hookRunner ?? getGlobalHookRunner();
if (hookRunner?.hasHooks?.("before_tool_call")) {
try {
const hookEvent: PluginHookBeforeToolCallEvent = {
toolName,
params: args && typeof args === "object" ? (args as Record<string, unknown>) : {},
};
await hookRunner.runBeforeToolCall(hookEvent, { toolName });
} catch (err) {
ctx.log.debug(`before_tool_call hook failed: tool=${toolName} error=${String(err)}`);
}
}
if (toolName === "read") {
const record = args && typeof args === "object" ? (args as Record<string, unknown>) : {};
const filePath = typeof record.path === "string" ? record.path.trim() : "";

View File

@@ -7,6 +7,8 @@ const hookMocks = vi.hoisted(() => ({
hasHooks: vi.fn(() => false),
runAfterToolCall: vi.fn(async () => {}),
},
isToolWrappedWithBeforeToolCallHook: vi.fn(() => false),
consumeAdjustedParamsForToolCall: vi.fn(() => undefined),
runBeforeToolCallHook: vi.fn(async ({ params }: { params: unknown }) => ({
blocked: false,
params,
@@ -18,6 +20,8 @@ vi.mock("../plugins/hook-runner-global.js", () => ({
}));
vi.mock("./pi-tools.before-tool-call.js", () => ({
consumeAdjustedParamsForToolCall: hookMocks.consumeAdjustedParamsForToolCall,
isToolWrappedWithBeforeToolCallHook: hookMocks.isToolWrappedWithBeforeToolCallHook,
runBeforeToolCallHook: hookMocks.runBeforeToolCallHook,
}));
@@ -26,6 +30,10 @@ describe("pi tool definition adapter after_tool_call", () => {
hookMocks.runner.hasHooks.mockReset();
hookMocks.runner.runAfterToolCall.mockReset();
hookMocks.runner.runAfterToolCall.mockResolvedValue(undefined);
hookMocks.isToolWrappedWithBeforeToolCallHook.mockReset();
hookMocks.isToolWrappedWithBeforeToolCallHook.mockReturnValue(false);
hookMocks.consumeAdjustedParamsForToolCall.mockReset();
hookMocks.consumeAdjustedParamsForToolCall.mockReturnValue(undefined);
hookMocks.runBeforeToolCallHook.mockReset();
hookMocks.runBeforeToolCallHook.mockImplementation(async ({ params }) => ({
blocked: false,
@@ -62,6 +70,38 @@ describe("pi tool definition adapter after_tool_call", () => {
);
});
it("uses wrapped-tool adjusted params for after_tool_call payload", async () => {
hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call");
hookMocks.isToolWrappedWithBeforeToolCallHook.mockReturnValue(true);
hookMocks.consumeAdjustedParamsForToolCall.mockReturnValue({ mode: "safe" });
const tool = {
name: "read",
label: "Read",
description: "reads",
parameters: {},
execute: vi.fn(async () => ({ content: [], details: { ok: true } })),
} satisfies AgentTool<unknown, unknown>;
const defs = toToolDefinitions([tool]);
const result = await defs[0].execute(
"call-ok-wrapped",
{ path: "/tmp/file" },
undefined,
undefined,
);
expect(result.details).toMatchObject({ ok: true });
expect(hookMocks.runBeforeToolCallHook).not.toHaveBeenCalled();
expect(hookMocks.runner.runAfterToolCall).toHaveBeenCalledWith(
{
toolName: "read",
params: { mode: "safe" },
result,
},
{ toolName: "read" },
);
});
it("dispatches after_tool_call once on adapter error with normalized tool name", async () => {
hookMocks.runner.hasHooks.mockImplementation((name: string) => name === "after_tool_call");
const tool = {

View File

@@ -8,7 +8,11 @@ import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
import { logDebug, logError } from "../logger.js";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { isPlainObject } from "../utils.js";
import { runBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
import {
consumeAdjustedParamsForToolCall,
isToolWrappedWithBeforeToolCallHook,
runBeforeToolCallHook,
} from "./pi-tools.before-tool-call.js";
import { normalizeToolName } from "./tool-policy.js";
import { jsonResult } from "./tools/common.js";
@@ -83,6 +87,7 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
return tools.map((tool) => {
const name = tool.name || "tool";
const normalizedName = normalizeToolName(name);
const beforeHookWrapped = isToolWrappedWithBeforeToolCallHook(tool);
return {
name,
label: tool.label ?? name,
@@ -90,18 +95,23 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
parameters: tool.parameters,
execute: async (...args: ToolExecuteArgs): Promise<AgentToolResult<unknown>> => {
const { toolCallId, params, onUpdate, signal } = splitToolExecuteArgs(args);
let executeParams = params;
try {
// Call before_tool_call hook
const hookOutcome = await runBeforeToolCallHook({
toolName: name,
params,
toolCallId,
});
if (hookOutcome.blocked) {
throw new Error(hookOutcome.reason);
if (!beforeHookWrapped) {
const hookOutcome = await runBeforeToolCallHook({
toolName: name,
params,
toolCallId,
});
if (hookOutcome.blocked) {
throw new Error(hookOutcome.reason);
}
executeParams = hookOutcome.params;
}
const adjustedParams = hookOutcome.params;
const result = await tool.execute(toolCallId, adjustedParams, signal, onUpdate);
const result = await tool.execute(toolCallId, executeParams, signal, onUpdate);
const afterParams = beforeHookWrapped
? (consumeAdjustedParamsForToolCall(toolCallId) ?? executeParams)
: executeParams;
// Call after_tool_call hook
const hookRunner = getGlobalHookRunner();
@@ -110,7 +120,7 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
await hookRunner.runAfterToolCall(
{
toolName: name,
params: isPlainObject(adjustedParams) ? adjustedParams : {},
params: isPlainObject(afterParams) ? afterParams : {},
result,
},
{ toolName: name },
@@ -134,6 +144,9 @@ export function toToolDefinitions(tools: AnyAgentTool[]): ToolDefinition[] {
if (name === "AbortError") {
throw err;
}
if (beforeHookWrapped) {
consumeAdjustedParamsForToolCall(toolCallId);
}
const described = describeToolExecutionError(err);
if (described.stack && described.stack !== described.message) {
logDebug(`tools: ${normalizedName} failed stack:\n${described.stack}`);

View File

@@ -535,4 +535,59 @@ describe("Agent-specific tool filtering", () => {
expect(result?.details.status).toBe("completed");
});
it("should apply agent-specific exec host defaults over global defaults", async () => {
const cfg: OpenClawConfig = {
tools: {
exec: {
host: "sandbox",
},
},
agents: {
list: [
{
id: "main",
tools: {
exec: {
host: "gateway",
},
},
},
{
id: "helper",
},
],
},
};
const mainTools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:main:main",
workspaceDir: "/tmp/test-main-exec-defaults",
agentDir: "/tmp/agent-main-exec-defaults",
});
const mainExecTool = mainTools.find((tool) => tool.name === "exec");
expect(mainExecTool).toBeDefined();
await expect(
mainExecTool!.execute("call-main", {
command: "echo done",
host: "sandbox",
}),
).rejects.toThrow("exec host not allowed");
const helperTools = createOpenClawCodingTools({
config: cfg,
sessionKey: "agent:helper:main",
workspaceDir: "/tmp/test-helper-exec-defaults",
agentDir: "/tmp/agent-helper-exec-defaults",
});
const helperExecTool = helperTools.find((tool) => tool.name === "exec");
expect(helperExecTool).toBeDefined();
const helperResult = await helperExecTool!.execute("call-helper", {
command: "echo done",
host: "sandbox",
yieldMs: 10,
});
expect(helperResult?.details.status).toBe("completed");
});
});

View File

@@ -1,6 +1,6 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getGlobalHookRunner } from "../plugins/hook-runner-global.js";
import { toClientToolDefinitions } from "./pi-tool-definition-adapter.js";
import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js";
import { wrapToolWithBeforeToolCallHook } from "./pi-tools.before-tool-call.js";
vi.mock("../plugins/hook-runner-global.js");
@@ -108,6 +108,44 @@ describe("before_tool_call hook integration", () => {
});
});
describe("before_tool_call hook deduplication (#15502)", () => {
let hookRunner: {
hasHooks: ReturnType<typeof vi.fn>;
runBeforeToolCall: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
hookRunner = {
hasHooks: vi.fn(() => true),
runBeforeToolCall: vi.fn(async () => undefined),
};
// oxlint-disable-next-line typescript/no-explicit-any
mockGetGlobalHookRunner.mockReturnValue(hookRunner as any);
});
it("fires hook exactly once when tool goes through wrap + toToolDefinitions", async () => {
const execute = vi.fn().mockResolvedValue({ content: [], details: { ok: true } });
// oxlint-disable-next-line typescript/no-explicit-any
const baseTool = { name: "web_fetch", execute, description: "fetch", parameters: {} } as any;
const wrapped = wrapToolWithBeforeToolCallHook(baseTool, {
agentId: "main",
sessionKey: "main",
});
const [def] = toToolDefinitions([wrapped]);
await def.execute(
"call-dedup",
{ url: "https://example.com" },
undefined,
undefined,
undefined,
);
expect(hookRunner.runBeforeToolCall).toHaveBeenCalledTimes(1);
});
});
describe("before_tool_call hook integration for client tools", () => {
let hookRunner: {
hasHooks: ReturnType<typeof vi.fn>;

View File

@@ -12,6 +12,9 @@ type HookContext = {
type HookOutcome = { blocked: true; reason: string } | { blocked: false; params: unknown };
const log = createSubsystemLogger("agents/tools");
const BEFORE_TOOL_CALL_WRAPPED = Symbol("beforeToolCallWrapped");
const adjustedParamsByToolCallId = new Map<string, unknown>();
const MAX_TRACKED_ADJUSTED_PARAMS = 1024;
export async function runBeforeToolCallHook(args: {
toolName: string;
@@ -71,7 +74,7 @@ export function wrapToolWithBeforeToolCallHook(
return tool;
}
const toolName = tool.name || "tool";
return {
const wrappedTool: AnyAgentTool = {
...tool,
execute: async (toolCallId, params, signal, onUpdate) => {
const outcome = await runBeforeToolCallHook({
@@ -83,12 +86,39 @@ export function wrapToolWithBeforeToolCallHook(
if (outcome.blocked) {
throw new Error(outcome.reason);
}
if (toolCallId) {
adjustedParamsByToolCallId.set(toolCallId, outcome.params);
if (adjustedParamsByToolCallId.size > MAX_TRACKED_ADJUSTED_PARAMS) {
const oldest = adjustedParamsByToolCallId.keys().next().value;
if (oldest) {
adjustedParamsByToolCallId.delete(oldest);
}
}
}
return await execute(toolCallId, outcome.params, signal, onUpdate);
},
};
Object.defineProperty(wrappedTool, BEFORE_TOOL_CALL_WRAPPED, {
value: true,
enumerable: false,
});
return wrappedTool;
}
export function isToolWrappedWithBeforeToolCallHook(tool: AnyAgentTool): boolean {
const taggedTool = tool as unknown as Record<symbol, unknown>;
return taggedTool[BEFORE_TOOL_CALL_WRAPPED] === true;
}
export function consumeAdjustedParamsForToolCall(toolCallId: string): unknown {
const params = adjustedParamsByToolCallId.get(toolCallId);
adjustedParamsByToolCallId.delete(toolCallId);
return params;
}
export const __testing = {
BEFORE_TOOL_CALL_WRAPPED,
adjustedParamsByToolCallId,
runBeforeToolCallHook,
isPlainObject,
};

View File

@@ -13,6 +13,7 @@ import { logWarn } from "../logger.js";
import { getPluginToolMeta } from "../plugins/tools.js";
import { isSubagentSessionKey } from "../routing/session-key.js";
import { resolveGatewayMessageChannel } from "../utils/message-channel.js";
import { resolveAgentConfig } from "./agent-scope.js";
import { createApplyPatchTool } from "./apply-patch.js";
import {
createExecTool,
@@ -86,21 +87,25 @@ function isApplyPatchAllowedForModel(params: {
});
}
function resolveExecConfig(cfg: OpenClawConfig | undefined) {
function resolveExecConfig(params: { cfg?: OpenClawConfig; agentId?: string }) {
const cfg = params.cfg;
const globalExec = cfg?.tools?.exec;
const agentExec =
cfg && params.agentId ? resolveAgentConfig(cfg, params.agentId)?.tools?.exec : undefined;
return {
host: globalExec?.host,
security: globalExec?.security,
ask: globalExec?.ask,
node: globalExec?.node,
pathPrepend: globalExec?.pathPrepend,
safeBins: globalExec?.safeBins,
backgroundMs: globalExec?.backgroundMs,
timeoutSec: globalExec?.timeoutSec,
approvalRunningNoticeMs: globalExec?.approvalRunningNoticeMs,
cleanupMs: globalExec?.cleanupMs,
notifyOnExit: globalExec?.notifyOnExit,
applyPatch: globalExec?.applyPatch,
host: agentExec?.host ?? globalExec?.host,
security: agentExec?.security ?? globalExec?.security,
ask: agentExec?.ask ?? globalExec?.ask,
node: agentExec?.node ?? globalExec?.node,
pathPrepend: agentExec?.pathPrepend ?? globalExec?.pathPrepend,
safeBins: agentExec?.safeBins ?? globalExec?.safeBins,
backgroundMs: agentExec?.backgroundMs ?? globalExec?.backgroundMs,
timeoutSec: agentExec?.timeoutSec ?? globalExec?.timeoutSec,
approvalRunningNoticeMs:
agentExec?.approvalRunningNoticeMs ?? globalExec?.approvalRunningNoticeMs,
cleanupMs: agentExec?.cleanupMs ?? globalExec?.cleanupMs,
notifyOnExit: agentExec?.notifyOnExit ?? globalExec?.notifyOnExit,
applyPatch: agentExec?.applyPatch ?? globalExec?.applyPatch,
};
}
@@ -231,7 +236,7 @@ export function createOpenClawCodingTools(options?: {
sandbox?.tools,
subagentPolicy,
]);
const execConfig = resolveExecConfig(options?.config);
const execConfig = resolveExecConfig({ cfg: options?.config, agentId });
const sandboxRoot = sandbox?.workspaceDir;
const sandboxFsBridge = sandbox?.fsBridge;
const allowWorkspaceWrites = sandbox?.workspaceAccess !== "ro";

View File

@@ -292,7 +292,7 @@ describe("subagent announce formatting", () => {
lastChannel: "whatsapp",
lastTo: "+1555",
queueMode: "collect",
queueDebounceMs: 80,
queueDebounceMs: 0,
},
};
@@ -327,7 +327,7 @@ describe("subagent announce formatting", () => {
}),
]);
await new Promise((r) => setTimeout(r, 120));
await expect.poll(() => agentSpy.mock.calls.length).toBe(2);
expect(agentSpy).toHaveBeenCalledTimes(2);
const accountIds = agentSpy.mock.calls.map(
(call) => (call?.[0] as { params?: { accountId?: string } })?.params?.accountId,
@@ -513,58 +513,4 @@ describe("subagent announce formatting", () => {
expect(call?.params?.channel).toBe("bluebubbles");
expect(call?.params?.to).toBe("bluebubbles:chat_guid:123");
});
it("splits collect-mode announces when accountId differs", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
sessionStore = {
"agent:main:main": {
sessionId: "session-789",
lastChannel: "whatsapp",
lastTo: "+1555",
queueMode: "collect",
queueDebounceMs: 0,
},
};
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-a",
requesterSessionKey: "main",
requesterOrigin: { accountId: "acct-a" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:test",
childRunId: "run-b",
requesterSessionKey: "main",
requesterOrigin: { accountId: "acct-b" },
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1000,
cleanup: "keep",
waitForCompletion: false,
startedAt: 10,
endedAt: 20,
outcome: { status: "ok" },
});
await expect.poll(() => agentSpy.mock.calls.length).toBe(2);
const accountIds = agentSpy.mock.calls.map(
(call) => (call[0] as { params?: Record<string, unknown> }).params?.accountId,
);
expect(accountIds).toContain("acct-a");
expect(accountIds).toContain("acct-b");
expect(agentSpy).toHaveBeenCalledTimes(2);
});
});

View File

@@ -298,6 +298,7 @@ async function readLatestAssistantReplyWithRetry(params: {
initialReply?: string;
maxWaitMs: number;
}): Promise<string | undefined> {
const RETRY_INTERVAL_MS = 100;
let reply = params.initialReply?.trim() ? params.initialReply : undefined;
if (reply) {
return reply;
@@ -305,7 +306,7 @@ async function readLatestAssistantReplyWithRetry(params: {
const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000));
while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 300));
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
const latest = await readLatestAssistantReply({ sessionKey: params.sessionKey });
if (latest?.trim()) {
return latest;

View File

@@ -155,6 +155,14 @@ export const SYNTHETIC_MODEL_CATALOG = [
contextWindow: 198000,
maxTokens: 128000,
},
{
id: "hf:zai-org/GLM-5",
name: "GLM-5",
reasoning: true,
input: ["text", "image"],
contextWindow: 256000,
maxTokens: 128000,
},
{
id: "hf:deepseek-ai/DeepSeek-V3",
name: "DeepSeek V3",

View File

@@ -1,7 +1,7 @@
import { Type } from "@sinclair/typebox";
import type { OpenClawConfig } from "../../config/config.js";
import { loadConfig, resolveConfigSnapshotHash } from "../../config/io.js";
import { loadSessionStore, resolveStorePath } from "../../config/sessions.js";
import { resolveConfigSnapshotHash } from "../../config/io.js";
import { extractDeliveryInfo } from "../../config/sessions.js";
import {
formatDoctorNonInteractiveHint,
type RestartSentinelPayload,
@@ -69,7 +69,7 @@ export function createGatewayTool(opts?: {
label: "Gateway",
name: "gateway",
description:
"Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing.",
"Restart, apply config, or update the gateway in-place (SIGUSR1). Use config.patch for safe partial config updates (merges with existing). Use config.apply only when replacing entire config. Both trigger restart after writing. Always pass a human-readable completion message via the `note` parameter so the system can deliver it to the user after restart.",
parameters: GatewayToolSchema,
execute: async (_toolCallId, args) => {
const params = args as Record<string, unknown>;
@@ -93,34 +93,8 @@ export function createGatewayTool(opts?: {
const note =
typeof params.note === "string" && params.note.trim() ? params.note.trim() : undefined;
// Extract channel + threadId for routing after restart
let deliveryContext: { channel?: string; to?: string; accountId?: string } | undefined;
let threadId: string | undefined;
if (sessionKey) {
const threadMarker = ":thread:";
const threadIndex = sessionKey.lastIndexOf(threadMarker);
const baseSessionKey = threadIndex === -1 ? sessionKey : sessionKey.slice(0, threadIndex);
const threadIdRaw =
threadIndex === -1 ? undefined : sessionKey.slice(threadIndex + threadMarker.length);
threadId = threadIdRaw?.trim() || undefined;
try {
const cfg = loadConfig();
const storePath = resolveStorePath(cfg.session?.store);
const store = loadSessionStore(storePath);
let entry = store[sessionKey];
if (!entry?.deliveryContext && threadIndex !== -1 && baseSessionKey) {
entry = store[baseSessionKey];
}
if (entry?.deliveryContext) {
deliveryContext = {
channel: entry.deliveryContext.channel,
to: entry.deliveryContext.to,
accountId: entry.deliveryContext.accountId,
};
}
} catch {
// ignore: best-effort
}
}
// Supports both :thread: (most channels) and :topic: (Telegram)
const { deliveryContext, threadId } = extractDeliveryInfo(sessionKey);
const payload: RestartSentinelPayload = {
kind: "restart",
status: "ok",

View File

@@ -1,5 +1,32 @@
export type ExtractMode = "markdown" | "text";
let readabilityDepsPromise:
| Promise<{
Readability: typeof import("@mozilla/readability").Readability;
parseHTML: typeof import("linkedom").parseHTML;
}>
| undefined;
async function loadReadabilityDeps(): Promise<{
Readability: typeof import("@mozilla/readability").Readability;
parseHTML: typeof import("linkedom").parseHTML;
}> {
if (!readabilityDepsPromise) {
readabilityDepsPromise = Promise.all([import("@mozilla/readability"), import("linkedom")]).then(
([readability, linkedom]) => ({
Readability: readability.Readability,
parseHTML: linkedom.parseHTML,
}),
);
}
try {
return await readabilityDepsPromise;
} catch (error) {
readabilityDepsPromise = undefined;
throw error;
}
}
function decodeEntities(value: string): string {
return value
.replace(/&nbsp;/gi, " ")
@@ -94,10 +121,7 @@ export async function extractReadableContent(params: {
return rendered;
};
try {
const [{ Readability }, { parseHTML }] = await Promise.all([
import("@mozilla/readability"),
import("linkedom"),
]);
const { Readability, parseHTML } = await loadReadabilityDeps();
const { document } = parseHTML(params.html);
try {
(document as { baseURI?: string }).baseURI = params.url;

View File

@@ -1,9 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import * as logger from "../../logger.js";
import { createWebFetchTool } from "./web-tools.js";
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const baseToolConfig = {
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
} as const;
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
return {
@@ -51,12 +57,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const tool = createWebFetchTool(baseToolConfig);
await tool?.execute?.("call", { url: "https://example.com/page" });
@@ -71,12 +72,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const tool = createWebFetchTool(baseToolConfig);
const result = await tool?.execute?.("call", { url: "https://example.com/cf" });
expect(result?.details).toMatchObject({
@@ -96,12 +92,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const tool = createWebFetchTool(baseToolConfig);
const result = await tool?.execute?.("call", { url: "https://example.com/html" });
expect(result?.details?.extractor).not.toBe("cf-markdown");
@@ -116,12 +107,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const tool = createWebFetchTool(baseToolConfig);
await tool?.execute?.("call", { url: "https://example.com/tokens/private?token=secret" });
@@ -142,12 +128,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const tool = createWebFetchTool(baseToolConfig);
const result = await tool?.execute?.("call", {
url: "https://example.com/text-mode",
@@ -169,12 +150,7 @@ describe("web_fetch Cloudflare Markdown for Agents", () => {
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const tool = createWebFetchTool(baseToolConfig);
await tool?.execute?.("call", { url: "https://example.com/no-tokens" });

View File

@@ -300,6 +300,11 @@ export function buildVeniceModelDefinition(entry: VeniceCatalogEntry): ModelDefi
cost: VENICE_DEFAULT_COST,
contextWindow: entry.contextWindow,
maxTokens: entry.maxTokens,
// Avoid usage-only streaming chunks that can break OpenAI-compatible parsers.
// See: https://github.com/openclaw/openclaw/issues/15819
compat: {
supportsUsageInStreaming: false,
},
};
}
@@ -381,6 +386,10 @@ export async function discoverVeniceModels(): Promise<ModelDefinitionConfig[]> {
cost: VENICE_DEFAULT_COST,
contextWindow: apiModel.model_spec.availableContextTokens || 128000,
maxTokens: 8192,
// Avoid usage-only streaming chunks that can break OpenAI-compatible parsers.
compat: {
supportsUsageInStreaming: false,
},
});
}
}

View File

@@ -1,9 +1,14 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { makeTempWorkspace, writeWorkspaceFile } from "../test-helpers/workspace.js";
import {
DEFAULT_AGENTS_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
DEFAULT_MEMORY_FILENAME,
ensureAgentWorkspace,
loadWorkspaceBootstrapFiles,
resolveDefaultAgentWorkspaceDir,
} from "./workspace.js";
@@ -19,6 +24,21 @@ describe("resolveDefaultAgentWorkspaceDir", () => {
});
});
describe("ensureAgentWorkspace", () => {
it("does not create HEARTBEAT.md during bootstrap file initialization", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-init-");
const result = await ensureAgentWorkspace({ dir: tempDir, ensureBootstrapFiles: true });
await expect(fs.access(path.join(tempDir, DEFAULT_AGENTS_FILENAME))).resolves.toBeUndefined();
await expect(
fs.access(path.join(tempDir, DEFAULT_BOOTSTRAP_FILENAME)),
).resolves.toBeUndefined();
await expect(fs.access(path.join(tempDir, DEFAULT_HEARTBEAT_FILENAME))).rejects.toThrow();
expect("heartbeatPath" in result).toBe(false);
});
});
describe("loadWorkspaceBootstrapFiles", () => {
it("includes MEMORY.md when present", async () => {
const tempDir = await makeTempWorkspace("openclaw-workspace-");

View File

@@ -0,0 +1,53 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { makeTempWorkspace } from "../test-helpers/workspace.js";
import { loadExtraBootstrapFiles } from "./workspace.js";
describe("loadExtraBootstrapFiles", () => {
it("loads recognized bootstrap files from glob patterns", async () => {
const workspaceDir = await makeTempWorkspace("openclaw-extra-bootstrap-glob-");
const packageDir = path.join(workspaceDir, "packages", "core");
await fs.mkdir(packageDir, { recursive: true });
await fs.writeFile(path.join(packageDir, "TOOLS.md"), "tools", "utf-8");
await fs.writeFile(path.join(packageDir, "README.md"), "not bootstrap", "utf-8");
const files = await loadExtraBootstrapFiles(workspaceDir, ["packages/*/*"]);
expect(files).toHaveLength(1);
expect(files[0]?.name).toBe("TOOLS.md");
expect(files[0]?.content).toBe("tools");
});
it("keeps path-traversal attempts outside workspace excluded", async () => {
const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-root-");
const workspaceDir = path.join(rootDir, "workspace");
const outsideDir = path.join(rootDir, "outside");
await fs.mkdir(workspaceDir, { recursive: true });
await fs.mkdir(outsideDir, { recursive: true });
await fs.writeFile(path.join(outsideDir, "AGENTS.md"), "outside", "utf-8");
const files = await loadExtraBootstrapFiles(workspaceDir, ["../outside/AGENTS.md"]);
expect(files).toHaveLength(0);
});
it("supports symlinked workspace roots with realpath checks", async () => {
if (process.platform === "win32") {
return;
}
const rootDir = await makeTempWorkspace("openclaw-extra-bootstrap-symlink-");
const realWorkspace = path.join(rootDir, "real-workspace");
const linkedWorkspace = path.join(rootDir, "linked-workspace");
await fs.mkdir(realWorkspace, { recursive: true });
await fs.writeFile(path.join(realWorkspace, "AGENTS.md"), "linked agents", "utf-8");
await fs.symlink(realWorkspace, linkedWorkspace, "dir");
const files = await loadExtraBootstrapFiles(linkedWorkspace, ["AGENTS.md"]);
expect(files).toHaveLength(1);
expect(files[0]?.name).toBe("AGENTS.md");
expect(files[0]?.content).toBe("linked agents");
});
});

View File

@@ -93,6 +93,19 @@ export type WorkspaceBootstrapFile = {
missing: boolean;
};
/** Set of recognized bootstrap filenames for runtime validation */
const VALID_BOOTSTRAP_NAMES: ReadonlySet<string> = new Set([
DEFAULT_AGENTS_FILENAME,
DEFAULT_SOUL_FILENAME,
DEFAULT_TOOLS_FILENAME,
DEFAULT_IDENTITY_FILENAME,
DEFAULT_USER_FILENAME,
DEFAULT_HEARTBEAT_FILENAME,
DEFAULT_BOOTSTRAP_FILENAME,
DEFAULT_MEMORY_FILENAME,
DEFAULT_MEMORY_ALT_FILENAME,
]);
async function writeFileIfMissing(filePath: string, content: string) {
try {
await fs.writeFile(filePath, content, {
@@ -160,7 +173,6 @@ export async function ensureAgentWorkspace(params?: {
toolsPath?: string;
identityPath?: string;
userPath?: string;
heartbeatPath?: string;
bootstrapPath?: string;
}> {
const rawDir = params?.dir?.trim() ? params.dir.trim() : DEFAULT_AGENT_WORKSPACE_DIR;
@@ -176,11 +188,13 @@ export async function ensureAgentWorkspace(params?: {
const toolsPath = path.join(dir, DEFAULT_TOOLS_FILENAME);
const identityPath = path.join(dir, DEFAULT_IDENTITY_FILENAME);
const userPath = path.join(dir, DEFAULT_USER_FILENAME);
const heartbeatPath = path.join(dir, DEFAULT_HEARTBEAT_FILENAME);
// HEARTBEAT.md is intentionally NOT created from template.
// Per docs: "If the file is missing, the heartbeat still runs and the model decides what to do."
// Creating it from template (which is effectively empty) would cause heartbeat to be skipped.
const bootstrapPath = path.join(dir, DEFAULT_BOOTSTRAP_FILENAME);
const isBrandNewWorkspace = await (async () => {
const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath, heartbeatPath];
const paths = [agentsPath, soulPath, toolsPath, identityPath, userPath];
const existing = await Promise.all(
paths.map(async (p) => {
try {
@@ -199,7 +213,6 @@ export async function ensureAgentWorkspace(params?: {
const toolsTemplate = await loadTemplate(DEFAULT_TOOLS_FILENAME);
const identityTemplate = await loadTemplate(DEFAULT_IDENTITY_FILENAME);
const userTemplate = await loadTemplate(DEFAULT_USER_FILENAME);
const heartbeatTemplate = await loadTemplate(DEFAULT_HEARTBEAT_FILENAME);
const bootstrapTemplate = await loadTemplate(DEFAULT_BOOTSTRAP_FILENAME);
await writeFileIfMissing(agentsPath, agentsTemplate);
@@ -207,7 +220,6 @@ export async function ensureAgentWorkspace(params?: {
await writeFileIfMissing(toolsPath, toolsTemplate);
await writeFileIfMissing(identityPath, identityTemplate);
await writeFileIfMissing(userPath, userTemplate);
await writeFileIfMissing(heartbeatPath, heartbeatTemplate);
if (isBrandNewWorkspace) {
await writeFileIfMissing(bootstrapPath, bootstrapTemplate);
}
@@ -220,7 +232,6 @@ export async function ensureAgentWorkspace(params?: {
toolsPath,
identityPath,
userPath,
heartbeatPath,
bootstrapPath,
};
}
@@ -329,3 +340,71 @@ export function filterBootstrapFilesForSession(
}
return files.filter((file) => SUBAGENT_BOOTSTRAP_ALLOWLIST.has(file.name));
}
export async function loadExtraBootstrapFiles(
dir: string,
extraPatterns: string[],
): Promise<WorkspaceBootstrapFile[]> {
if (!extraPatterns.length) {
return [];
}
const resolvedDir = resolveUserPath(dir);
let realResolvedDir = resolvedDir;
try {
realResolvedDir = await fs.realpath(resolvedDir);
} catch {
// Keep lexical root if realpath fails.
}
// Resolve glob patterns into concrete file paths
const resolvedPaths = new Set<string>();
for (const pattern of extraPatterns) {
if (pattern.includes("*") || pattern.includes("?") || pattern.includes("{")) {
try {
const matches = fs.glob(pattern, { cwd: resolvedDir });
for await (const m of matches) {
resolvedPaths.add(m);
}
} catch {
// glob not available or pattern error — fall back to literal
resolvedPaths.add(pattern);
}
} else {
resolvedPaths.add(pattern);
}
}
const result: WorkspaceBootstrapFile[] = [];
for (const relPath of resolvedPaths) {
const filePath = path.resolve(resolvedDir, relPath);
// Guard against path traversal — resolved path must stay within workspace
if (!filePath.startsWith(resolvedDir + path.sep) && filePath !== resolvedDir) {
continue;
}
try {
// Resolve symlinks and verify the real path is still within workspace
const realFilePath = await fs.realpath(filePath);
if (
!realFilePath.startsWith(realResolvedDir + path.sep) &&
realFilePath !== realResolvedDir
) {
continue;
}
// Only load files whose basename is a recognized bootstrap filename
const baseName = path.basename(relPath);
if (!VALID_BOOTSTRAP_NAMES.has(baseName)) {
continue;
}
const content = await fs.readFile(realFilePath, "utf-8");
result.push({
name: baseName as WorkspaceBootstrapFileName,
path: filePath,
content,
missing: false,
});
} catch {
// Silently skip missing extra files
}
}
return result;
}

View File

@@ -0,0 +1,91 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import type { ReplyDispatcher } from "./reply/reply-dispatcher.js";
import { dispatchInboundMessage, withReplyDispatcher } from "./dispatch.js";
import { buildTestCtx } from "./reply/test-ctx.js";
function createDispatcher(record: string[]): ReplyDispatcher {
return {
sendToolResult: () => true,
sendBlockReply: () => true,
sendFinalReply: () => true,
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
markComplete: () => {
record.push("markComplete");
},
waitForIdle: async () => {
record.push("waitForIdle");
},
};
}
describe("withReplyDispatcher", () => {
it("always marks complete and waits for idle after success", async () => {
const order: string[] = [];
const dispatcher = createDispatcher(order);
const result = await withReplyDispatcher({
dispatcher,
run: async () => {
order.push("run");
return "ok";
},
onSettled: () => {
order.push("onSettled");
},
});
expect(result).toBe("ok");
expect(order).toEqual(["run", "markComplete", "waitForIdle", "onSettled"]);
});
it("still drains dispatcher after run throws", async () => {
const order: string[] = [];
const dispatcher = createDispatcher(order);
const onSettled = vi.fn(() => {
order.push("onSettled");
});
await expect(
withReplyDispatcher({
dispatcher,
run: async () => {
order.push("run");
throw new Error("boom");
},
onSettled,
}),
).rejects.toThrow("boom");
expect(onSettled).toHaveBeenCalledTimes(1);
expect(order).toEqual(["run", "markComplete", "waitForIdle", "onSettled"]);
});
it("dispatchInboundMessage owns dispatcher lifecycle", async () => {
const order: string[] = [];
const dispatcher = {
sendToolResult: () => true,
sendBlockReply: () => true,
sendFinalReply: () => {
order.push("sendFinalReply");
return true;
},
getQueuedCounts: () => ({ tool: 0, block: 0, final: 0 }),
markComplete: () => {
order.push("markComplete");
},
waitForIdle: async () => {
order.push("waitForIdle");
},
} satisfies ReplyDispatcher;
await dispatchInboundMessage({
ctx: buildTestCtx(),
cfg: {} as OpenClawConfig,
dispatcher,
replyResolver: async () => ({ text: "ok" }),
});
expect(order).toEqual(["sendFinalReply", "markComplete", "waitForIdle"]);
});
});

View File

@@ -14,6 +14,24 @@ import {
export type DispatchInboundResult = DispatchFromConfigResult;
export async function withReplyDispatcher<T>(params: {
dispatcher: ReplyDispatcher;
run: () => Promise<T>;
onSettled?: () => void | Promise<void>;
}): Promise<T> {
try {
return await params.run();
} finally {
// Ensure dispatcher reservations are always released on every exit path.
params.dispatcher.markComplete();
try {
await params.dispatcher.waitForIdle();
} finally {
await params.onSettled?.();
}
}
}
export async function dispatchInboundMessage(params: {
ctx: MsgContext | FinalizedMsgContext;
cfg: OpenClawConfig;
@@ -22,12 +40,16 @@ export async function dispatchInboundMessage(params: {
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const finalized = finalizeInboundContext(params.ctx);
return await dispatchReplyFromConfig({
ctx: finalized,
cfg: params.cfg,
return await withReplyDispatcher({
dispatcher: params.dispatcher,
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
run: () =>
dispatchReplyFromConfig({
ctx: finalized,
cfg: params.cfg,
dispatcher: params.dispatcher,
replyOptions: params.replyOptions,
replyResolver: params.replyResolver,
}),
});
}
@@ -41,20 +63,20 @@ export async function dispatchInboundMessageWithBufferedDispatcher(params: {
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping(
params.dispatcherOptions,
);
const result = await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: {
...params.replyOptions,
...replyOptions,
},
});
markDispatchIdle();
return result;
try {
return await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: {
...params.replyOptions,
...replyOptions,
},
});
} finally {
markDispatchIdle();
}
}
export async function dispatchInboundMessageWithDispatcher(params: {
@@ -65,13 +87,11 @@ export async function dispatchInboundMessageWithDispatcher(params: {
replyResolver?: typeof import("./reply.js").getReplyFromConfig;
}): Promise<DispatchInboundResult> {
const dispatcher = createReplyDispatcher(params.dispatcherOptions);
const result = await dispatchInboundMessage({
return await dispatchInboundMessage({
ctx: params.ctx,
cfg: params.cfg,
dispatcher,
replyResolver: params.replyResolver,
replyOptions: params.replyOptions,
});
await dispatcher.waitForIdle();
return result;
}

View File

@@ -107,6 +107,62 @@ describe("stripHeartbeatToken", () => {
didStrip: true,
});
});
it("strips trailing punctuation only when directly after the token", () => {
// Token with trailing dot/exclamation/dashes → should still strip
expect(stripHeartbeatToken(`${HEARTBEAT_TOKEN}.`, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
expect(stripHeartbeatToken(`${HEARTBEAT_TOKEN}!!!`, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
expect(stripHeartbeatToken(`${HEARTBEAT_TOKEN}---`, { mode: "heartbeat" })).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
});
it("strips a sentence-ending token and keeps trailing punctuation", () => {
// Token appears at sentence end with trailing punctuation.
expect(
stripHeartbeatToken(`I should not respond ${HEARTBEAT_TOKEN}.`, {
mode: "message",
}),
).toEqual({
shouldSkip: false,
text: `I should not respond.`,
didStrip: true,
});
});
it("strips sentence-ending token with emphasis punctuation in heartbeat mode", () => {
expect(
stripHeartbeatToken(
`There is nothing todo, so i should respond with ${HEARTBEAT_TOKEN} !!!`,
{
mode: "heartbeat",
},
),
).toEqual({
shouldSkip: true,
text: "",
didStrip: true,
});
});
it("preserves trailing punctuation on text before the token", () => {
// Token at end, preceding text has its own punctuation — only the token is stripped
expect(stripHeartbeatToken(`All clear. ${HEARTBEAT_TOKEN}`, { mode: "message" })).toEqual({
shouldSkip: false,
text: "All clear.",
didStrip: true,
});
});
});
describe("isHeartbeatContentEffectivelyEmpty", () => {

View File

@@ -1,3 +1,4 @@
import { escapeRegExp } from "../utils.js";
import { HEARTBEAT_TOKEN } from "./tokens.js";
// Default heartbeat prompt (used when config.agents.defaults.heartbeat.prompt is unset).
@@ -65,6 +66,9 @@ function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
}
const token = HEARTBEAT_TOKEN;
const tokenAtEndWithOptionalTrailingPunctuation = new RegExp(
`${escapeRegExp(token)}[^\\w]{0,4}$`,
);
if (!text.includes(token)) {
return { text, didStrip: false };
}
@@ -81,9 +85,19 @@ function stripTokenAtEdges(raw: string): { text: string; didStrip: boolean } {
changed = true;
continue;
}
if (next.endsWith(token)) {
const before = next.slice(0, Math.max(0, next.length - token.length));
text = before.trimEnd();
// Strip the token when it appears at the end of the text.
// Also strip up to 4 trailing non-word characters the model may have appended
// (e.g. ".", "!!!", "---"). Keep trailing punctuation only when real
// sentence text exists before the token.
if (tokenAtEndWithOptionalTrailingPunctuation.test(next)) {
const idx = next.lastIndexOf(token);
const before = next.slice(0, idx).trimEnd();
if (!before) {
text = "";
} else {
const after = next.slice(idx + token.length).trimStart();
text = `${before}${after}`.trimEnd();
}
didStrip = true;
changed = true;
}

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { getReplyFromConfig } from "./reply.js";
@@ -22,11 +23,74 @@ vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
}));
type HomeEnvSnapshot = {
HOME: string | undefined;
USERPROFILE: string | undefined;
HOMEDRIVE: string | undefined;
HOMEPATH: string | undefined;
OPENCLAW_STATE_DIR: string | undefined;
};
function snapshotHomeEnv(): HomeEnvSnapshot {
return {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
};
}
function restoreHomeEnv(snapshot: HomeEnvSnapshot) {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
let fixtureRoot = "";
let caseId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(fn, { prefix: "openclaw-stream-" });
const home = path.join(fixtureRoot, `case-${++caseId}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
const envSnapshot = snapshotHomeEnv();
process.env.HOME = home;
process.env.USERPROFILE = home;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
if (process.platform === "win32") {
const match = home.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}
try {
return await fn(home);
} finally {
restoreHomeEnv(envSnapshot);
}
}
describe("block streaming", () => {
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-stream-"));
});
afterAll(async () => {
await fs.rm(fixtureRoot, {
recursive: true,
force: true,
maxRetries: 10,
retryDelay: 50,
});
});
beforeEach(() => {
piEmbeddedMock.abortEmbeddedPiRun.mockReset().mockReturnValue(false);
piEmbeddedMock.queueEmbeddedPiMessage.mockReset().mockReturnValue(false);
@@ -39,78 +103,20 @@ describe("block streaming", () => {
]);
});
async function waitForCalls(fn: () => number, calls: number) {
const deadline = Date.now() + 5000;
while (fn() < calls) {
if (Date.now() > deadline) {
throw new Error(`Expected ${calls} call(s), got ${fn()}`);
}
await new Promise((resolve) => setTimeout(resolve, 5));
}
}
it("waits for block replies before returning final payloads", async () => {
it("handles ordering, timeout fallback, and telegram streamMode block", async () => {
await withTempHome(async (home) => {
let releaseTyping: (() => void) | undefined;
const typingGate = new Promise<void>((resolve) => {
releaseTyping = resolve;
});
const onReplyStart = vi.fn(() => typingGate);
const onBlockReply = vi.fn().mockResolvedValue(undefined);
const impl = async (params: RunEmbeddedPiAgentParams) => {
void params.onBlockReply?.({ text: "hello" });
return {
payloads: [{ text: "hello" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
};
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(impl);
const replyPromise = getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-123",
Provider: "discord",
},
{
onReplyStart,
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
await waitForCalls(() => onReplyStart.mock.calls.length, 1);
releaseTyping?.();
const res = await replyPromise;
expect(res).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
});
it("preserves block reply ordering when typing start is slow", async () => {
await withTempHome(async (home) => {
let releaseTyping: (() => void) | undefined;
const typingGate = new Promise<void>((resolve) => {
releaseTyping = resolve;
let resolveOnReplyStart: (() => void) | undefined;
const onReplyStartCalled = new Promise<void>((resolve) => {
resolveOnReplyStart = resolve;
});
const onReplyStart = vi.fn(() => {
resolveOnReplyStart?.();
return typingGate;
});
const onReplyStart = vi.fn(() => typingGate);
const seen: string[] = [];
const onBlockReply = vi.fn(async (payload) => {
seen.push(payload.text ?? "");
@@ -134,7 +140,7 @@ describe("block streaming", () => {
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-125",
MessageSid: "msg-123",
Provider: "telegram",
},
{
@@ -154,64 +160,15 @@ describe("block streaming", () => {
},
);
await waitForCalls(() => onReplyStart.mock.calls.length, 1);
await onReplyStartCalled;
releaseTyping?.();
const res = await replyPromise;
expect(res).toBeUndefined();
expect(seen).toEqual(["first\n\nsecond"]);
});
});
it("drops final payloads when block replies streamed", async () => {
await withTempHome(async (home) => {
const onBlockReply = vi.fn().mockResolvedValue(undefined);
const impl = async (params: RunEmbeddedPiAgentParams) => {
void params.onBlockReply?.({ text: "chunk-1" });
return {
payloads: [{ text: "chunk-1\nchunk-2" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
};
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(impl);
const res = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-124",
Provider: "discord",
},
{
onBlockReply,
disableBlockStreaming: false,
},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
expect(res).toBeUndefined();
expect(onBlockReply).toHaveBeenCalledTimes(1);
});
});
it("falls back to final payloads when block reply send times out", async () => {
await withTempHome(async (home) => {
let sawAbort = false;
const onBlockReply = vi.fn((_, context) => {
const onBlockReplyTimeout = vi.fn((_, context) => {
return new Promise<void>((resolve) => {
context?.abortSignal?.addEventListener(
"abort",
@@ -224,7 +181,7 @@ describe("block streaming", () => {
});
});
const impl = async (params: RunEmbeddedPiAgentParams) => {
const timeoutImpl = async (params: RunEmbeddedPiAgentParams) => {
void params.onBlockReply?.({ text: "streamed" });
return {
payloads: [{ text: "final" }],
@@ -234,9 +191,9 @@ describe("block streaming", () => {
},
};
};
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(impl);
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(timeoutImpl);
const replyPromise = getReplyFromConfig(
const timeoutReplyPromise = getReplyFromConfig(
{
Body: "ping",
From: "+1004",
@@ -245,8 +202,8 @@ describe("block streaming", () => {
Provider: "telegram",
},
{
onBlockReply,
blockReplyTimeoutMs: 10,
onBlockReply: onBlockReplyTimeout,
blockReplyTimeoutMs: 1,
disableBlockStreaming: false,
},
{
@@ -261,35 +218,29 @@ describe("block streaming", () => {
},
);
const res = await replyPromise;
expect(res).toMatchObject({ text: "final" });
const timeoutRes = await timeoutReplyPromise;
expect(timeoutRes).toMatchObject({ text: "final" });
expect(sawAbort).toBe(true);
});
});
it("does not enable block streaming for telegram streamMode block", async () => {
await withTempHome(async (home) => {
const onBlockReply = vi.fn().mockResolvedValue(undefined);
const impl = async () => ({
const onBlockReplyStreamMode = vi.fn().mockResolvedValue(undefined);
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(async () => ({
payloads: [{ text: "final" }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
piEmbeddedMock.runEmbeddedPiAgent.mockImplementation(impl);
}));
const res = await getReplyFromConfig(
const resStreamMode = await getReplyFromConfig(
{
Body: "ping",
From: "+1004",
To: "+2000",
MessageSid: "msg-126",
MessageSid: "msg-127",
Provider: "telegram",
},
{
onBlockReply,
onBlockReply: onBlockReplyStreamMode,
},
{
agents: {
@@ -303,8 +254,8 @@ describe("block streaming", () => {
},
);
expect(res?.text).toBe("final");
expect(onBlockReply).not.toHaveBeenCalled();
expect(resStreamMode?.text).toBe("final");
expect(onBlockReplyStreamMode).not.toHaveBeenCalled();
});
});
});

View File

@@ -1,149 +0,0 @@
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { pollUntil } from "../../test/helpers/poll.js";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import {
isEmbeddedPiRunActive,
isEmbeddedPiRunStreaming,
runEmbeddedPiAgent,
} from "../agents/pi-embedded.js";
import { getReplyFromConfig } from "./reply.js";
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
function makeResult(text: string) {
return {
payloads: [{ text }],
meta: {
durationMs: 5,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
};
}
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
return await fn(home);
},
{ prefix: "openclaw-queue-" },
);
}
function makeCfg(home: string, queue?: Record<string, unknown>) {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
messages: queue ? { queue } : undefined,
};
}
describe("queue followups", () => {
afterEach(() => {
vi.useRealTimers();
});
it("collects queued messages and drains after run completes", async () => {
vi.useFakeTimers();
await withTempHome(async (home) => {
const prompts: string[] = [];
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
prompts.push(params.prompt);
if (params.prompt.includes("[Queued messages while agent was busy]")) {
return makeResult("followup");
}
return makeResult("main");
});
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(true);
const cfg = makeCfg(home, {
mode: "collect",
debounceMs: 200,
cap: 10,
drop: "summarize",
});
const first = await getReplyFromConfig(
{ Body: "first", From: "+1001", To: "+2000", MessageSid: "m-1" },
{},
cfg,
);
expect(first).toBeUndefined();
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
const second = await getReplyFromConfig(
{ Body: "second", From: "+1001", To: "+2000" },
{},
cfg,
);
const secondText = Array.isArray(second) ? second[0]?.text : second?.text;
expect(secondText).toBe("main");
await vi.advanceTimersByTimeAsync(500);
await Promise.resolve();
expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2);
const queuedPrompt = prompts.find((p) =>
p.includes("[Queued messages while agent was busy]"),
);
expect(queuedPrompt).toBeTruthy();
// Message id hints are no longer exposed to the model prompt.
expect(queuedPrompt).toContain("Queued #1");
expect(queuedPrompt).toContain("first");
expect(queuedPrompt).not.toContain("[message_id:");
});
});
it("summarizes dropped followups when cap is exceeded", async () => {
await withTempHome(async (home) => {
const prompts: string[] = [];
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (params) => {
prompts.push(params.prompt);
return makeResult("ok");
});
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(true);
vi.mocked(isEmbeddedPiRunStreaming).mockReturnValue(false);
const cfg = makeCfg(home, {
mode: "followup",
debounceMs: 0,
cap: 1,
drop: "summarize",
});
await getReplyFromConfig({ Body: "one", From: "+1002", To: "+2000" }, {}, cfg);
await getReplyFromConfig({ Body: "two", From: "+1002", To: "+2000" }, {}, cfg);
vi.mocked(isEmbeddedPiRunActive).mockReturnValue(false);
await getReplyFromConfig({ Body: "three", From: "+1002", To: "+2000" }, {}, cfg);
await pollUntil(
async () => (prompts.some((p) => p.includes("[Queue overflow]")) ? true : null),
{ timeoutMs: 2000 },
);
expect(prompts.some((p) => p.includes("[Queue overflow]"))).toBe(true);
});
});
});

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { loadModelCatalog } from "../agents/model-catalog.js";
import { runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { saveSessionStore } from "../config/sessions.js";
@@ -19,22 +19,75 @@ vi.mock("../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(),
}));
type HomeEnvSnapshot = {
HOME: string | undefined;
USERPROFILE: string | undefined;
HOMEDRIVE: string | undefined;
HOMEPATH: string | undefined;
OPENCLAW_STATE_DIR: string | undefined;
OPENCLAW_AGENT_DIR: string | undefined;
PI_CODING_AGENT_DIR: string | undefined;
};
function snapshotHomeEnv(): HomeEnvSnapshot {
return {
HOME: process.env.HOME,
USERPROFILE: process.env.USERPROFILE,
HOMEDRIVE: process.env.HOMEDRIVE,
HOMEPATH: process.env.HOMEPATH,
OPENCLAW_STATE_DIR: process.env.OPENCLAW_STATE_DIR,
OPENCLAW_AGENT_DIR: process.env.OPENCLAW_AGENT_DIR,
PI_CODING_AGENT_DIR: process.env.PI_CODING_AGENT_DIR,
};
}
function restoreHomeEnv(snapshot: HomeEnvSnapshot) {
for (const [key, value] of Object.entries(snapshot)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
let fixtureRoot = "";
let caseId = 0;
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(
async (home) => {
return await fn(home);
},
{
env: {
OPENCLAW_AGENT_DIR: (home) => path.join(home, ".openclaw", "agent"),
PI_CODING_AGENT_DIR: (home) => path.join(home, ".openclaw", "agent"),
},
prefix: "openclaw-rawbody-",
},
);
const home = path.join(fixtureRoot, `case-${++caseId}`);
await fs.mkdir(path.join(home, ".openclaw", "agents", "main", "sessions"), { recursive: true });
const envSnapshot = snapshotHomeEnv();
process.env.HOME = home;
process.env.USERPROFILE = home;
process.env.OPENCLAW_STATE_DIR = path.join(home, ".openclaw");
process.env.OPENCLAW_AGENT_DIR = path.join(home, ".openclaw", "agent");
process.env.PI_CODING_AGENT_DIR = path.join(home, ".openclaw", "agent");
if (process.platform === "win32") {
const match = home.match(/^([A-Za-z]:)(.*)$/);
if (match) {
process.env.HOMEDRIVE = match[1];
process.env.HOMEPATH = match[2] || "\\";
}
}
try {
return await fn(home);
} finally {
restoreHomeEnv(envSnapshot);
}
}
describe("RawBody directive parsing", () => {
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-rawbody-"));
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
beforeEach(() => {
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(loadModelCatalog).mockResolvedValue([
@@ -46,151 +99,7 @@ describe("RawBody directive parsing", () => {
vi.clearAllMocks();
});
it("/model, /think, /verbose directives detected from RawBody even when Body has structural wrapper", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const groupMessageCtx = {
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp ...] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp ...] Jake: /think:high\\n[from: Jake McInteer (+6421807830)]`,
RawBody: "/think:high",
From: "+1222",
To: "+1222",
ChatType: "group",
CommandAuthorized: true,
};
const res = await getReplyFromConfig(
groupMessageCtx,
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Thinking level set to high.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("/model status detected from RawBody", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const groupMessageCtx = {
Body: `[Context]\nJake: /model status\n[from: Jake]`,
RawBody: "/model status",
From: "+1222",
To: "+1222",
ChatType: "group",
CommandAuthorized: true,
};
const res = await getReplyFromConfig(
groupMessageCtx,
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
models: {
"anthropic/claude-opus-4-5": {},
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("anthropic/claude-opus-4-5");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("CommandBody is honored when RawBody is missing", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const groupMessageCtx = {
Body: `[Context]\nJake: /verbose on\n[from: Jake]`,
CommandBody: "/verbose on",
From: "+1222",
To: "+1222",
ChatType: "group",
CommandAuthorized: true,
};
const res = await getReplyFromConfig(
groupMessageCtx,
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Verbose logging enabled.");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("Integration: WhatsApp group message with structural wrapper and RawBody command", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockReset();
const groupMessageCtx = {
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp ...] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp ...] Jake: /status\\n[from: Jake McInteer (+6421807830)]`,
RawBody: "/status",
ChatType: "group",
From: "+1222",
To: "+1222",
SessionKey: "agent:main:whatsapp:group:g1",
Provider: "whatsapp",
Surface: "whatsapp",
SenderE164: "+1222",
CommandAuthorized: true,
};
const res = await getReplyFromConfig(
groupMessageCtx,
{},
{
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: path.join(home, "openclaw"),
},
},
channels: { whatsapp: { allowFrom: ["+1222"] } },
session: { store: path.join(home, "sessions.json") },
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toContain("Session: agent:main:whatsapp:group:g1");
expect(text).toContain("anthropic/claude-opus-4-5");
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
});
});
it("preserves history when RawBody is provided for command parsing", async () => {
it("handles directives, history, and non-default agent session files", async () => {
await withTempHome(async (home) => {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
@@ -238,11 +147,6 @@ describe("RawBody directive parsing", () => {
expect(prompt).toContain('"body": "hello"');
expect(prompt).toContain("status please");
expect(prompt).not.toContain("/think:high");
});
});
it("reuses non-default agent session files without throwing path validation errors", async () => {
await withTempHome(async (home) => {
const agentId = "worker1";
const sessionId = "sess-worker-1";
const sessionKey = `agent:${agentId}:telegram:12345`;
@@ -259,6 +163,7 @@ describe("RawBody directive parsing", () => {
},
});
vi.mocked(runEmbeddedPiAgent).mockReset();
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
@@ -267,7 +172,7 @@ describe("RawBody directive parsing", () => {
},
});
const res = await getReplyFromConfig(
const resWorker = await getReplyFromConfig(
{
Body: "hello",
From: "telegram:12345",
@@ -288,8 +193,8 @@ describe("RawBody directive parsing", () => {
},
);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
const textWorker = Array.isArray(resWorker) ? resWorker[0]?.text : resWorker?.text;
expect(textWorker).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
expect(vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.sessionFile).toBe(sessionFile);
});

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import type {
CommandHandler,
CommandHandlerResult,
@@ -5,6 +6,7 @@ import type {
} from "./commands-types.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { resolveSendPolicy } from "../../sessions/send-policy.js";
import { shouldHandleTextCommands } from "../commands-registry.js";
import { handleAllowlistCommand } from "./commands-allowlist.js";
@@ -104,6 +106,48 @@ export async function handleCommands(params: HandleCommandsParams): Promise<Comm
});
}
}
// Fire before_reset plugin hook — extract memories before session history is lost
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("before_reset")) {
const prevEntry = params.previousSessionEntry;
const sessionFile = prevEntry?.sessionFile;
// Fire-and-forget: read old session messages and run hook
void (async () => {
try {
const messages: unknown[] = [];
if (sessionFile) {
const content = await fs.readFile(sessionFile, "utf-8");
for (const line of content.split("\n")) {
if (!line.trim()) {
continue;
}
try {
const entry = JSON.parse(line);
if (entry.type === "message" && entry.message) {
messages.push(entry.message);
}
} catch {
// skip malformed lines
}
}
} else {
logVerbose("before_reset: no session file available, firing hook with empty messages");
}
await hookRunner.runBeforeReset(
{ sessionFile, messages, reason: commandAction },
{
agentId: params.sessionKey?.split(":")[0] ?? "main",
sessionKey: params.sessionKey,
sessionId: prevEntry?.sessionId,
workspaceDir: params.workspaceDir,
},
);
} catch (err: unknown) {
logVerbose(`before_reset hook failed: ${String(err)}`);
}
})();
}
}
const allowTextCommands = shouldHandleTextCommands({

View File

@@ -64,6 +64,7 @@ function createDispatcher(): ReplyDispatcher {
sendFinalReply: vi.fn(() => true),
waitForIdle: vi.fn(async () => {}),
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
markComplete: vi.fn(),
};
}

View File

@@ -278,7 +278,6 @@ export async function dispatchReplyFromConfig(params: {
} else {
queuedFinal = dispatcher.sendFinalReply(payload);
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
recordProcessed("completed", { reason: "fast_abort" });
@@ -443,8 +442,6 @@ export async function dispatchReplyFromConfig(params: {
}
}
await dispatcher.waitForIdle();
const counts = dispatcher.getQueuedCounts();
counts.final += routedFinalCount;
recordProcessed("completed");

View File

@@ -0,0 +1,58 @@
/**
* Global registry for tracking active reply dispatchers.
* Used to ensure gateway restart waits for all replies to complete.
*/
type TrackedDispatcher = {
readonly id: string;
readonly pending: () => number;
readonly waitForIdle: () => Promise<void>;
};
const activeDispatchers = new Set<TrackedDispatcher>();
let nextId = 0;
/**
* Register a reply dispatcher for global tracking.
* Returns an unregister function to call when the dispatcher is no longer needed.
*/
export function registerDispatcher(dispatcher: {
readonly pending: () => number;
readonly waitForIdle: () => Promise<void>;
}): { id: string; unregister: () => void } {
const id = `dispatcher-${++nextId}`;
const tracked: TrackedDispatcher = {
id,
pending: dispatcher.pending,
waitForIdle: dispatcher.waitForIdle,
};
activeDispatchers.add(tracked);
const unregister = () => {
activeDispatchers.delete(tracked);
};
return { id, unregister };
}
/**
* Get the total number of pending replies across all dispatchers.
*/
export function getTotalPendingReplies(): number {
let total = 0;
for (const dispatcher of activeDispatchers) {
total += dispatcher.pending();
}
return total;
}
/**
* Clear all registered dispatchers (for testing).
* WARNING: Only use this in test cleanup!
*/
export function clearAllDispatchers(): void {
if (!process.env.VITEST && process.env.NODE_ENV !== "test") {
throw new Error("clearAllDispatchers() is only available in test environments");
}
activeDispatchers.clear();
}

View File

@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runPreparedReply } from "./get-reply-run.js";
vi.mock("../../agents/auth-profiles/session-override.js", () => ({
resolveSessionAuthProfileOverride: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: vi.fn().mockReturnValue("session:session-key"),
}));
vi.mock("../../config/sessions.js", () => ({
resolveGroupSessionKey: vi.fn().mockReturnValue(undefined),
resolveSessionFilePath: vi.fn().mockReturnValue("/tmp/session.jsonl"),
resolveSessionFilePathOptions: vi.fn().mockReturnValue({}),
updateSessionStore: vi.fn(),
}));
vi.mock("../../globals.js", () => ({
logVerbose: vi.fn(),
}));
vi.mock("../../process/command-queue.js", () => ({
clearCommandLane: vi.fn().mockReturnValue(0),
getQueueSize: vi.fn().mockReturnValue(0),
}));
vi.mock("../../routing/session-key.js", () => ({
normalizeMainKey: vi.fn().mockReturnValue("main"),
}));
vi.mock("../../utils/provider-utils.js", () => ({
isReasoningTagProvider: vi.fn().mockReturnValue(false),
}));
vi.mock("../command-detection.js", () => ({
hasControlCommand: vi.fn().mockReturnValue(false),
}));
vi.mock("./agent-runner.js", () => ({
runReplyAgent: vi.fn().mockResolvedValue({ text: "ok" }),
}));
vi.mock("./body.js", () => ({
applySessionHints: vi.fn().mockImplementation(async ({ baseBody }) => baseBody),
}));
vi.mock("./groups.js", () => ({
buildGroupIntro: vi.fn().mockReturnValue(""),
}));
vi.mock("./inbound-meta.js", () => ({
buildInboundMetaSystemPrompt: vi.fn().mockReturnValue(""),
buildInboundUserContextPrefix: vi.fn().mockReturnValue(""),
}));
vi.mock("./queue.js", () => ({
resolveQueueSettings: vi.fn().mockReturnValue({ mode: "followup" }),
}));
vi.mock("./route-reply.js", () => ({
routeReply: vi.fn(),
}));
vi.mock("./session-updates.js", () => ({
ensureSkillSnapshot: vi.fn().mockImplementation(async ({ sessionEntry, systemSent }) => ({
sessionEntry,
systemSent,
skillsSnapshot: undefined,
})),
prependSystemEvents: vi.fn().mockImplementation(async ({ prefixedBodyBase }) => prefixedBodyBase),
}));
vi.mock("./typing-mode.js", () => ({
resolveTypingMode: vi.fn().mockReturnValue("off"),
}));
import { runReplyAgent } from "./agent-runner.js";
function baseParams(
overrides: Partial<Parameters<typeof runPreparedReply>[0]> = {},
): Parameters<typeof runPreparedReply>[0] {
return {
ctx: {
Body: "",
RawBody: "",
CommandBody: "",
ThreadHistoryBody: "Earlier message in this thread",
OriginatingChannel: "slack",
OriginatingTo: "C123",
ChatType: "group",
},
sessionCtx: {
Body: "",
BodyStripped: "",
ThreadHistoryBody: "Earlier message in this thread",
MediaPath: "/tmp/input.png",
Provider: "slack",
ChatType: "group",
OriginatingChannel: "slack",
OriginatingTo: "C123",
},
cfg: { session: {}, channels: {}, agents: { defaults: {} } },
agentId: "default",
agentDir: "/tmp/agent",
agentCfg: {},
sessionCfg: {},
commandAuthorized: true,
command: {
isAuthorizedSender: true,
abortKey: "session-key",
ownerList: [],
senderIsOwner: false,
} as never,
commandSource: "",
allowTextCommands: true,
directives: {
hasThinkDirective: false,
thinkLevel: undefined,
} as never,
defaultActivation: "always",
resolvedThinkLevel: "high",
resolvedVerboseLevel: "off",
resolvedReasoningLevel: "off",
resolvedElevatedLevel: "off",
elevatedEnabled: false,
elevatedAllowed: false,
blockStreamingEnabled: false,
resolvedBlockStreamingBreak: "message_end",
modelState: {
resolveDefaultThinkingLevel: async () => "medium",
} as never,
provider: "anthropic",
model: "claude-opus-4-1",
typing: {
onReplyStart: vi.fn().mockResolvedValue(undefined),
cleanup: vi.fn(),
} as never,
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-1",
timeoutMs: 30_000,
isNewSession: true,
resetTriggered: false,
systemSent: true,
sessionKey: "session-key",
workspaceDir: "/tmp/workspace",
abortedLastRun: false,
...overrides,
};
}
describe("runPreparedReply media-only handling", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("allows media-only prompts and preserves thread context in queued followups", async () => {
const result = await runPreparedReply(baseParams());
expect(result).toEqual({ text: "ok" });
const call = vi.mocked(runReplyAgent).mock.calls[0]?.[0];
expect(call).toBeTruthy();
expect(call?.followupRun.prompt).toContain("[Thread history - for context]");
expect(call?.followupRun.prompt).toContain("Earlier message in this thread");
expect(call?.followupRun.prompt).toContain("[User sent media without caption]");
});
it("returns the empty-body reply when there is no text and no media", async () => {
const result = await runPreparedReply(
baseParams({
ctx: {
Body: "",
RawBody: "",
CommandBody: "",
},
sessionCtx: {
Body: "",
BodyStripped: "",
Provider: "slack",
},
}),
);
expect(result).toEqual({
text: "I didn't receive any text in your message. Please resend or add a caption.",
});
expect(vi.mocked(runReplyAgent)).not.toHaveBeenCalled();
});
});

View File

@@ -221,7 +221,10 @@ export async function runPreparedReply(
? baseBodyFinal
: [inboundUserContext, baseBodyFinal].filter(Boolean).join("\n\n");
const baseBodyTrimmed = baseBodyForPrompt.trim();
if (!baseBodyTrimmed) {
const hasMediaAttachment = Boolean(
sessionCtx.MediaPath || (sessionCtx.MediaPaths && sessionCtx.MediaPaths.length > 0),
);
if (!baseBodyTrimmed && !hasMediaAttachment) {
await typing.onReplyStart();
logVerbose("Inbound body empty after normalization; skipping agent run");
typing.cleanup();
@@ -229,8 +232,13 @@ export async function runPreparedReply(
text: "I didn't receive any text in your message. Please resend or add a caption.",
};
}
// When the user sends media without text, provide a minimal body so the agent
// run proceeds and the image/document is injected by the embedded runner.
const effectiveBaseBody = baseBodyTrimmed
? baseBodyForPrompt
: "[User sent media without caption]";
let prefixedBodyBase = await applySessionHints({
baseBody: baseBodyForPrompt,
baseBody: effectiveBaseBody,
abortedLastRun,
sessionEntry,
sessionStore,
@@ -337,7 +345,7 @@ export async function runPreparedReply(
sessionEntry,
resolveSessionFilePathOptions({ agentId, storePath }),
);
const queueBodyBase = [threadContextNote, baseBodyForPrompt].filter(Boolean).join("\n\n");
const queueBodyBase = [threadContextNote, effectiveBaseBody].filter(Boolean).join("\n\n");
const queuedBody = mediaNote
? [mediaNote, mediaReplyHint, queueBodyBase].filter(Boolean).join("\n").trim()
: queueBodyBase;

View File

@@ -3,6 +3,7 @@ import type { GetReplyOptions, ReplyPayload } from "../types.js";
import type { ResponsePrefixContext } from "./response-prefix-template.js";
import type { TypingController } from "./typing.js";
import { sleep } from "../../utils.js";
import { registerDispatcher } from "./dispatcher-registry.js";
import { normalizeReplyPayload, type NormalizeReplySkipReason } from "./normalize-reply.js";
export type ReplyDispatchKind = "tool" | "block" | "final";
@@ -74,6 +75,7 @@ export type ReplyDispatcher = {
sendFinalReply: (payload: ReplyPayload) => boolean;
waitForIdle: () => Promise<void>;
getQueuedCounts: () => Record<ReplyDispatchKind, number>;
markComplete: () => void;
};
type NormalizeReplyPayloadInternalOptions = Pick<
@@ -101,7 +103,10 @@ function normalizeReplyPayloadInternal(
export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDispatcher {
let sendChain: Promise<void> = Promise.resolve();
// Track in-flight deliveries so we can emit a reliable "idle" signal.
let pending = 0;
// Start with pending=1 as a "reservation" to prevent premature gateway restart.
// This is decremented when markComplete() is called to signal no more replies will come.
let pending = 1;
let completeCalled = false;
// Track whether we've sent a block reply (for human delay - skip delay on first block).
let sentFirstBlock = false;
// Serialize outbound replies to preserve tool/block/final order.
@@ -111,6 +116,12 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
final: 0,
};
// Register this dispatcher globally for gateway restart coordination.
const { unregister } = registerDispatcher({
pending: () => pending,
waitForIdle: () => sendChain,
});
const enqueue = (kind: ReplyDispatchKind, payload: ReplyPayload) => {
const normalized = normalizeReplyPayloadInternal(payload, {
responsePrefix: options.responsePrefix,
@@ -140,6 +151,8 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
await sleep(delayMs);
}
}
// Safe: deliver is called inside an async .then() callback, so even a synchronous
// throw becomes a rejection that flows through .catch()/.finally(), ensuring cleanup.
await options.deliver(normalized, { kind });
})
.catch((err) => {
@@ -147,19 +160,49 @@ export function createReplyDispatcher(options: ReplyDispatcherOptions): ReplyDis
})
.finally(() => {
pending -= 1;
// Clear reservation if:
// 1. pending is now 1 (just the reservation left)
// 2. markComplete has been called
// 3. No more replies will be enqueued
if (pending === 1 && completeCalled) {
pending -= 1; // Clear the reservation
}
if (pending === 0) {
// Unregister from global tracking when idle.
unregister();
options.onIdle?.();
}
});
return true;
};
const markComplete = () => {
if (completeCalled) {
return;
}
completeCalled = true;
// If no replies were enqueued (pending is still 1 = just the reservation),
// schedule clearing the reservation after current microtasks complete.
// This gives any in-flight enqueue() calls a chance to increment pending.
void Promise.resolve().then(() => {
if (pending === 1 && completeCalled) {
// Still just the reservation, no replies were enqueued
pending -= 1;
if (pending === 0) {
unregister();
options.onIdle?.();
}
}
});
};
return {
sendToolResult: (payload) => enqueue("tool", payload),
sendBlockReply: (payload) => enqueue("block", payload),
sendFinalReply: (payload) => enqueue("final", payload),
waitForIdle: () => sendChain,
getQueuedCounts: () => ({ ...queuedCounts }),
markComplete,
};
}

View File

@@ -100,6 +100,8 @@ describe("createReplyDispatcher", () => {
dispatcher.sendFinalReply({ text: "two" });
await dispatcher.waitForIdle();
dispatcher.markComplete();
await Promise.resolve();
expect(onIdle).toHaveBeenCalledTimes(1);
});

View File

@@ -3,7 +3,11 @@ import { createSubsystemLogger } from "../logging/subsystem.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { ensureBrowserControlAuth } from "./control-auth.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import {
type BrowserServerState,
createBrowserRouteContext,
listKnownProfileNames,
} from "./server-context.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
@@ -16,6 +20,7 @@ export function getBrowserControlState(): BrowserServerState | null {
export function createBrowserControlContext() {
return createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
}
@@ -71,10 +76,11 @@ export async function stopBrowserControlService(): Promise<void> {
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
try {
for (const name of Object.keys(current.resolved.profiles)) {
for (const name of listKnownProfileNames(current)) {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {

View File

@@ -0,0 +1,9 @@
let pwAiLoaded = false;
export function markPwAiLoaded(): void {
pwAiLoaded = true;
}
export function isPwAiLoaded(): boolean {
return pwAiLoaded;
}

View File

@@ -1,3 +1,7 @@
import { markPwAiLoaded } from "./pw-ai-state.js";
markPwAiLoaded();
export {
type BrowserConsoleMessage,
closePageByTargetIdViaPlaywright,

View File

@@ -28,10 +28,7 @@ const sessionMocks = vi.hoisted(() => ({
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeEach(() => {
@@ -53,7 +50,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
@@ -70,7 +66,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -86,7 +81,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -102,7 +96,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -118,7 +111,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -136,7 +128,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { click };
currentPage = {};
const mod = await importModule();
await expect(
mod.clickViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",

View File

@@ -28,10 +28,7 @@ const sessionMocks = vi.hoisted(() => ({
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeEach(() => {
@@ -75,7 +72,6 @@ describe("pw-tools-core", () => {
keyboard: { press: vi.fn(async () => {}) },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: ["/tmp/1"],
@@ -101,7 +97,6 @@ describe("pw-tools-core", () => {
waitForEvent,
};
const mod = await importModule();
await mod.armDialogViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
accept: true,
@@ -145,7 +140,6 @@ describe("pw-tools-core", () => {
getByText: vi.fn(() => ({ first: () => ({ waitFor: vi.fn() }) })),
};
const mod = await importModule();
await mod.waitForViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
selector: "#main",

View File

@@ -28,10 +28,7 @@ const sessionMocks = vi.hoisted(() => ({
}));
vi.mock("./pw-session.js", () => sessionMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeEach(() => {
@@ -57,7 +54,6 @@ describe("pw-tools-core", () => {
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
@@ -78,7 +74,6 @@ describe("pw-tools-core", () => {
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
const res = await mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
@@ -99,8 +94,6 @@ describe("pw-tools-core", () => {
screenshot: vi.fn(async () => Buffer.from("P")),
};
const mod = await importModule();
await expect(
mod.takeScreenshotViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -127,7 +120,6 @@ describe("pw-tools-core", () => {
keyboard: { press: vi.fn(async () => {}) },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
@@ -151,7 +143,6 @@ describe("pw-tools-core", () => {
keyboard: { press },
};
const mod = await importModule();
await mod.armFileUploadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
paths: [],

View File

@@ -33,10 +33,7 @@ const tmpDirMocks = vi.hoisted(() => ({
resolvePreferredOpenClawTmpDir: vi.fn(() => "/tmp/openclaw"),
}));
vi.mock("../infra/tmp-openclaw-dir.js", () => tmpDirMocks);
async function importModule() {
return await import("./pw-tools-core.js");
}
const mod = await import("./pw-tools-core.js");
describe("pw-tools-core", () => {
beforeEach(() => {
@@ -75,7 +72,6 @@ describe("pw-tools-core", () => {
currentPage = { on, off };
const mod = await importModule();
const targetPath = path.resolve("/tmp/file.bin");
const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -113,7 +109,6 @@ describe("pw-tools-core", () => {
currentPage = { on, off };
const mod = await importModule();
const targetPath = path.resolve("/tmp/report.pdf");
const p = mod.downloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
@@ -152,7 +147,6 @@ describe("pw-tools-core", () => {
tmpDirMocks.resolvePreferredOpenClawTmpDir.mockReturnValue("/tmp/openclaw-preferred");
currentPage = { on, off };
const mod = await importModule();
const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
@@ -194,7 +188,6 @@ describe("pw-tools-core", () => {
text: async () => '{"ok":true,"value":123}',
};
const mod = await importModule();
const p = mod.responseBodyViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
@@ -218,7 +211,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { scrollIntoViewIfNeeded };
currentPage = {};
const mod = await importModule();
await mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",
targetId: "T1",
@@ -232,7 +224,6 @@ describe("pw-tools-core", () => {
currentRefLocator = { scrollIntoViewIfNeeded: vi.fn(async () => {}) };
currentPage = {};
const mod = await importModule();
await expect(
mod.scrollIntoViewViaPlaywright({
cdpUrl: "http://127.0.0.1:18792",

View File

@@ -1,14 +1,17 @@
import crypto from "node:crypto";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import { normalizeBrowserScreenshot } from "./screenshot.js";
describe("browser screenshot normalization", () => {
it("shrinks oversized images to <=2000x2000 and <=5MB", async () => {
const width = 2300;
const height = 2300;
const raw = crypto.randomBytes(width * height * 3);
const bigPng = await sharp(raw, { raw: { width, height, channels: 3 } })
const bigPng = await sharp({
create: {
width: 2100,
height: 2100,
channels: 3,
background: { r: 12, g: 34, b: 56 },
},
})
.png({ compressionLevel: 0 })
.toBuffer();

View File

@@ -0,0 +1,214 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
let cfgProfiles: Record<string, { cdpPort?: number; cdpUrl?: string; color?: string }> = {};
// Simulate module-level cache behavior
let cachedConfig: ReturnType<typeof buildConfig> | null = null;
function buildConfig() {
return {
browser: {
enabled: true,
color: "#FF4500",
headless: true,
defaultProfile: "openclaw",
profiles: { ...cfgProfiles },
},
};
}
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
createConfigIO: () => ({
loadConfig: () => {
// Always return fresh config for createConfigIO to simulate fresh disk read
return buildConfig();
},
}),
loadConfig: () => {
// simulate stale loadConfig that doesn't see updates unless cache cleared
if (!cachedConfig) {
cachedConfig = buildConfig();
}
return cachedConfig;
},
clearConfigCache: vi.fn(() => {
// Clear the simulated cache
cachedConfig = null;
}),
writeConfigFile: vi.fn(async () => {}),
};
});
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => false),
isChromeReachable: vi.fn(async () => false),
launchOpenClawChrome: vi.fn(async () => {
throw new Error("launch disabled");
}),
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw"),
stopOpenClawChrome: vi.fn(async () => {}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: vi.fn(async () => ({ nodes: [] })),
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => `${cdpUrl}${path}`),
}));
vi.mock("./pw-ai.js", () => ({
closePlaywrightBrowserConnection: vi.fn(async () => {}),
}));
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
describe("server-context hot-reload profiles", () => {
beforeEach(() => {
vi.resetModules();
cfgProfiles = {
openclaw: { cdpPort: 18800, color: "#FF4500" },
};
cachedConfig = null; // Clear simulated cache
});
it("forProfile hot-reloads newly added profiles from config", async () => {
// Start with only openclaw profile
const { createBrowserRouteContext } = await import("./server-context.js");
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
// 1. Prime the cache by calling loadConfig() first
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
// Verify cache is primed (without desktop)
expect(cfg.browser.profiles.desktop).toBeUndefined();
const state = {
server: null,
port: 18791,
resolved,
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
// Initially, "desktop" profile should not exist
expect(() => ctx.forProfile("desktop")).toThrow(/not found/);
// 2. Simulate adding a new profile to config (like user editing openclaw.json)
cfgProfiles.desktop = { cdpUrl: "http://127.0.0.1:9222", color: "#0066CC" };
// 3. Verify without clearConfigCache, loadConfig() still returns stale cached value
const staleCfg = loadConfig();
expect(staleCfg.browser.profiles.desktop).toBeUndefined(); // Cache is stale!
// 4. Now forProfile should hot-reload (calls createConfigIO().loadConfig() internally)
// It should NOT clear the global cache
const profileCtx = ctx.forProfile("desktop");
expect(profileCtx.profile.name).toBe("desktop");
expect(profileCtx.profile.cdpUrl).toBe("http://127.0.0.1:9222");
// 5. Verify the new profile was merged into the cached state
expect(state.resolved.profiles.desktop).toBeDefined();
// 6. Verify GLOBAL cache was NOT cleared - subsequent simple loadConfig() still sees STALE value
// This confirms the fix: we read fresh config for the specific profile lookup without flushing the global cache
const stillStaleCfg = loadConfig();
expect(stillStaleCfg.browser.profiles.desktop).toBeUndefined();
// Verify clearConfigCache was not called
const { clearConfigCache } = await import("../config/config.js");
expect(clearConfigCache).not.toHaveBeenCalled();
});
it("forProfile still throws for profiles that don't exist in fresh config", async () => {
const { createBrowserRouteContext } = await import("./server-context.js");
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const state = {
server: null,
port: 18791,
resolved,
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
// Profile that doesn't exist anywhere should still throw
expect(() => ctx.forProfile("nonexistent")).toThrow(/not found/);
});
it("forProfile refreshes existing profile config after loadConfig cache updates", async () => {
const { createBrowserRouteContext } = await import("./server-context.js");
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const state = {
server: null,
port: 18791,
resolved,
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
const before = ctx.forProfile("openclaw");
expect(before.profile.cdpPort).toBe(18800);
cfgProfiles.openclaw = { cdpPort: 19999, color: "#FF4500" };
cachedConfig = null;
const after = ctx.forProfile("openclaw");
expect(after.profile.cdpPort).toBe(19999);
expect(state.resolved.profiles.openclaw?.cdpPort).toBe(19999);
});
it("listProfiles refreshes config before enumerating profiles", async () => {
const { createBrowserRouteContext } = await import("./server-context.js");
const { resolveBrowserConfig } = await import("./config.js");
const { loadConfig } = await import("../config/config.js");
const cfg = loadConfig();
const resolved = resolveBrowserConfig(cfg.browser, cfg);
const state = {
server: null,
port: 18791,
resolved,
profiles: new Map(),
};
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
cfgProfiles.desktop = { cdpPort: 19999, color: "#0066CC" };
cachedConfig = null;
const profiles = await ctx.listProfiles();
expect(profiles.some((p) => p.name === "desktop")).toBe(true);
});
});

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import type { BrowserServerState } from "./server-context.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { listKnownProfileNames } from "./server-context.js";
describe("browser server-context listKnownProfileNames", () => {
it("includes configured and runtime-only profile names", () => {
const resolved = resolveBrowserConfig({
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: 18800, color: "#FF4500" },
},
});
const openclaw = resolveProfile(resolved, "openclaw");
if (!openclaw) {
throw new Error("expected openclaw profile");
}
const state: BrowserServerState = {
server: null as unknown as BrowserServerState["server"],
port: 18791,
resolved,
profiles: new Map([
[
"stale-removed",
{
profile: { ...openclaw, name: "stale-removed" },
running: null,
},
],
]),
};
expect(listKnownProfileNames(state).toSorted()).toEqual([
"chrome",
"openclaw",
"stale-removed",
]);
});
});

View File

@@ -2,6 +2,7 @@ import fs from "node:fs";
import type { ResolvedBrowserProfile } from "./config.js";
import type { PwAiModule } from "./pw-ai-module.js";
import type {
BrowserServerState,
BrowserRouteContext,
BrowserTab,
ContextOptions,
@@ -9,6 +10,7 @@ import type {
ProfileRuntimeState,
ProfileStatus,
} from "./server-context.types.js";
import { createConfigIO, loadConfig } from "../config/config.js";
import { appendCdpPath, createTargetViaCdp, getHeadersWithAuth, normalizeCdpWsUrl } from "./cdp.js";
import {
isChromeCdpReady,
@@ -17,7 +19,7 @@ import {
resolveOpenClawUserDataDir,
stopOpenClawChrome,
} from "./chrome.js";
import { resolveProfile } from "./config.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import {
ensureChromeExtensionRelayServer,
stopChromeExtensionRelayServer,
@@ -35,6 +37,14 @@ export type {
ProfileStatus,
} from "./server-context.types.js";
export function listKnownProfileNames(state: BrowserServerState): string[] {
const names = new Set(Object.keys(state.resolved.profiles));
for (const name of state.profiles.keys()) {
names.add(name);
}
return [...names];
}
/**
* Normalize a CDP WebSocket URL to use the correct base URL.
*/
@@ -559,6 +569,8 @@ function createProfileContext(
}
export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteContext {
const refreshConfigFromDisk = opts.refreshConfigFromDisk === true;
const state = () => {
const current = opts.getState();
if (!current) {
@@ -567,10 +579,53 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
return current;
};
const applyResolvedConfig = (
current: BrowserServerState,
freshResolved: BrowserServerState["resolved"],
) => {
current.resolved = freshResolved;
for (const [name, runtime] of current.profiles) {
const nextProfile = resolveProfile(freshResolved, name);
if (nextProfile) {
runtime.profile = nextProfile;
continue;
}
if (!runtime.running) {
current.profiles.delete(name);
}
}
};
const refreshResolvedConfig = (current: BrowserServerState) => {
if (!refreshConfigFromDisk) {
return;
}
const cfg = loadConfig();
const freshResolved = resolveBrowserConfig(cfg.browser, cfg);
applyResolvedConfig(current, freshResolved);
};
const refreshResolvedConfigFresh = (current: BrowserServerState) => {
if (!refreshConfigFromDisk) {
return;
}
const freshCfg = createConfigIO().loadConfig();
const freshResolved = resolveBrowserConfig(freshCfg.browser, freshCfg);
applyResolvedConfig(current, freshResolved);
};
const forProfile = (profileName?: string): ProfileContext => {
const current = state();
refreshResolvedConfig(current);
const name = profileName ?? current.resolved.defaultProfile;
const profile = resolveProfile(current.resolved, name);
let profile = resolveProfile(current.resolved, name);
// Hot-reload: try fresh config if profile not found
if (!profile) {
refreshResolvedConfigFresh(current);
profile = resolveProfile(current.resolved, name);
}
if (!profile) {
const available = Object.keys(current.resolved.profiles).join(", ");
throw new Error(`Profile "${name}" not found. Available profiles: ${available || "(none)"}`);
@@ -580,6 +635,7 @@ export function createBrowserRouteContext(opts: ContextOptions): BrowserRouteCon
const listProfiles = async (): Promise<ProfileStatus[]> => {
const current = state();
refreshResolvedConfig(current);
const result: ProfileStatus[] = [];
for (const name of Object.keys(current.resolved.profiles)) {

View File

@@ -72,4 +72,5 @@ export type ProfileStatus = {
export type ContextOptions = {
getState: () => BrowserServerState | null;
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
refreshConfigFromDisk?: boolean;
};

View File

@@ -156,6 +156,9 @@ vi.mock("./screenshot.js", () => ({
})),
}));
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js");
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
@@ -274,12 +277,10 @@ describe("browser control server", () => {
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
const startServerAndBase = async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());

View File

@@ -154,6 +154,9 @@ vi.mock("./screenshot.js", () => ({
})),
}));
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js");
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
@@ -271,12 +274,10 @@ describe("browser control server", () => {
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
const startServerAndBase = async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());

View File

@@ -1,511 +0,0 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/openclaw",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
}),
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw"),
stopOpenClawChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) {
return port;
}
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) {
return { targetId: createTargetId };
}
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) {
fn.mockClear();
}
for (const fn of Object.values(cdpMocks)) {
fn.mockClear();
}
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("covers additional endpoint branches", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const tabsWhenStopped = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
running: boolean;
tabs: unknown[];
};
expect(tabsWhenStopped.running).toBe(false);
expect(Array.isArray(tabsWhenStopped.tabs)).toBe(true);
const focusStopped = await realFetch(`${base}/tabs/focus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: "abcd" }),
});
expect(focusStopped.status).toBe(409);
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const focusMissing = await realFetch(`${base}/tabs/focus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: "zzz" }),
});
expect(focusMissing.status).toBe(404);
const delAmbiguous = await realFetch(`${base}/tabs/abc`, {
method: "DELETE",
});
expect(delAmbiguous.status).toBe(409);
const snapAmbiguous = await realFetch(`${base}/snapshot?format=aria&targetId=abc`);
expect(snapAmbiguous.status).toBe(409);
});
});
describe("backward compatibility (profile parameter)", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
for (const fn of Object.values(pwMocks)) {
fn.mockClear();
}
for (const fn of Object.values(cdpMocks)) {
fn.mockClear();
}
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("GET / without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const status = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
profile?: string;
};
expect(status.running).toBe(false);
// Should use default profile (openclaw)
expect(status.profile).toBe("openclaw");
});
it("POST /start without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json())) as {
ok: boolean;
profile?: string;
};
expect(result.ok).toBe(true);
expect(result.profile).toBe("openclaw");
});
it("POST /stop without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/stop`, { method: "POST" }).then((r) => r.json())) as {
ok: boolean;
profile?: string;
};
expect(result.ok).toBe(true);
expect(result.profile).toBe("openclaw");
});
it("GET /tabs without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
running: boolean;
tabs: unknown[];
};
expect(result.running).toBe(true);
expect(Array.isArray(result.tabs)).toBe(true);
});
it("POST /tabs/open without profile uses default profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(result.targetId).toBe("newtab1");
});
it("GET /profiles returns list of profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = (await realFetch(`${base}/profiles`).then((r) => r.json())) as {
profiles: Array<{ name: string }>;
};
expect(Array.isArray(result.profiles)).toBe(true);
// Should at least have the default openclaw profile
expect(result.profiles.some((p) => p.name === "openclaw")).toBe(true);
});
it("GET /tabs?profile=openclaw returns tabs for specified profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs?profile=openclaw`).then((r) => r.json())) as {
running: boolean;
tabs: unknown[];
};
expect(result.running).toBe(true);
expect(Array.isArray(result.tabs)).toBe(true);
});
it("POST /tabs/open?profile=openclaw opens tab in specified profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" });
const result = (await realFetch(`${base}/tabs/open?profile=openclaw`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(result.targetId).toBe("newtab1");
});
it("GET /tabs?profile=unknown returns 404", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/tabs?profile=unknown`);
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
});

View File

@@ -63,6 +63,9 @@ vi.mock("./server-context.js", async (importOriginal) => {
};
});
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js");
async function getFreePort(): Promise<number> {
const probe = createServer();
await new Promise<void>((resolve, reject) => {
@@ -95,12 +98,10 @@ describe("browser control evaluate gating", () => {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("blocks act:evaluate but still allows cookies/storage reads", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;

View File

@@ -153,6 +153,9 @@ vi.mock("./screenshot.js", () => ({
})),
}));
const { startBrowserControlServerFromConfig, stopBrowserControlServer } =
await import("./server.js");
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
@@ -270,12 +273,10 @@ describe("browser control server", () => {
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("POST /tabs/open?profile=unknown returns 404", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
@@ -307,9 +308,6 @@ describe("profile CRUD endpoints", () => {
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
vi.stubGlobal(
"fetch",
vi.fn(async (url: string) => {
@@ -330,129 +328,83 @@ describe("profile CRUD endpoints", () => {
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("POST /profiles/create returns 400 for missing name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
it("validates profile create/delete endpoints", async () => {
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
const createMissingName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("name is required");
});
expect(createMissingName.status).toBe(400);
const createMissingNameBody = (await createMissingName.json()) as { error: string };
expect(createMissingNameBody.error).toContain("name is required");
it("POST /profiles/create returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
const createInvalidName = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "Invalid Name!" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
});
expect(createInvalidName.status).toBe(400);
const createInvalidNameBody = (await createInvalidName.json()) as { error: string };
expect(createInvalidNameBody.error).toContain("invalid profile name");
it("POST /profiles/create returns 409 for duplicate name", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// "openclaw" already exists as the default profile
const result = await realFetch(`${base}/profiles/create`, {
const createDuplicate = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "openclaw" }),
});
expect(result.status).toBe(409);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("already exists");
});
expect(createDuplicate.status).toBe(409);
const createDuplicateBody = (await createDuplicate.json()) as { error: string };
expect(createDuplicateBody.error).toContain("already exists");
it("POST /profiles/create accepts cdpUrl for remote profiles", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
const createRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "remote", cdpUrl: "http://10.0.0.42:9222" }),
});
expect(result.status).toBe(200);
const body = (await result.json()) as {
expect(createRemote.status).toBe(200);
const createRemoteBody = (await createRemote.json()) as {
profile?: string;
cdpUrl?: string;
isRemote?: boolean;
};
expect(body.profile).toBe("remote");
expect(body.cdpUrl).toBe("http://10.0.0.42:9222");
expect(body.isRemote).toBe(true);
});
expect(createRemoteBody.profile).toBe("remote");
expect(createRemoteBody.cdpUrl).toBe("http://10.0.0.42:9222");
expect(createRemoteBody.isRemote).toBe(true);
it("POST /profiles/create returns 400 for invalid cdpUrl", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/create`, {
const createBadRemote = await realFetch(`${base}/profiles/create`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: "badremote", cdpUrl: "ws://bad" }),
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cdpUrl");
});
expect(createBadRemote.status).toBe(400);
const createBadRemoteBody = (await createBadRemote.json()) as { error: string };
expect(createBadRemoteBody.error).toContain("cdpUrl");
it("DELETE /profiles/:name returns 404 for non-existent profile", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/nonexistent`, {
const deleteMissing = await realFetch(`${base}/profiles/nonexistent`, {
method: "DELETE",
});
expect(result.status).toBe(404);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("not found");
});
expect(deleteMissing.status).toBe(404);
const deleteMissingBody = (await deleteMissing.json()) as { error: string };
expect(deleteMissingBody.error).toContain("not found");
it("DELETE /profiles/:name returns 400 for default profile deletion", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
// openclaw is the default profile
const result = await realFetch(`${base}/profiles/openclaw`, {
const deleteDefault = await realFetch(`${base}/profiles/openclaw`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("cannot delete the default profile");
});
expect(deleteDefault.status).toBe(400);
const deleteDefaultBody = (await deleteDefault.json()) as { error: string };
expect(deleteDefaultBody.error).toContain("cannot delete the default profile");
it("DELETE /profiles/:name returns 400 for invalid name format", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const result = await realFetch(`${base}/profiles/Invalid-Name!`, {
const deleteInvalid = await realFetch(`${base}/profiles/Invalid-Name!`, {
method: "DELETE",
});
expect(result.status).toBe(400);
const body = (await result.json()) as { error: string };
expect(body.error).toContain("invalid profile name");
expect(deleteInvalid.status).toBe(400);
const deleteInvalidBody = (await deleteInvalid.json()) as { error: string };
expect(deleteInvalidBody.error).toContain("invalid profile name");
});
});

View File

@@ -1,329 +0,0 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
let testPort = 0;
let _cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
createTargetViaCdp: vi.fn(async () => {
throw new Error("cdp disabled");
}),
snapshotAria: vi.fn(async () => ({
nodes: [{ ref: "1", role: "link", name: "x", depth: 0 }],
})),
}));
const pwMocks = vi.hoisted(() => ({
armDialogViaPlaywright: vi.fn(async () => {}),
armFileUploadViaPlaywright: vi.fn(async () => {}),
clickViaPlaywright: vi.fn(async () => {}),
closePageViaPlaywright: vi.fn(async () => {}),
closePlaywrightBrowserConnection: vi.fn(async () => {}),
downloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
dragViaPlaywright: vi.fn(async () => {}),
evaluateViaPlaywright: vi.fn(async () => "ok"),
fillFormViaPlaywright: vi.fn(async () => {}),
getConsoleMessagesViaPlaywright: vi.fn(async () => []),
hoverViaPlaywright: vi.fn(async () => {}),
scrollIntoViewViaPlaywright: vi.fn(async () => {}),
navigateViaPlaywright: vi.fn(async () => ({ url: "https://example.com" })),
pdfViaPlaywright: vi.fn(async () => ({ buffer: Buffer.from("pdf") })),
pressKeyViaPlaywright: vi.fn(async () => {}),
responseBodyViaPlaywright: vi.fn(async () => ({
url: "https://example.com/api/data",
status: 200,
headers: { "content-type": "application/json" },
body: '{"ok":true}',
})),
resizeViewportViaPlaywright: vi.fn(async () => {}),
selectOptionViaPlaywright: vi.fn(async () => {}),
setInputFilesViaPlaywright: vi.fn(async () => {}),
snapshotAiViaPlaywright: vi.fn(async () => ({ snapshot: "ok" })),
takeScreenshotViaPlaywright: vi.fn(async () => ({
buffer: Buffer.from("png"),
})),
typeViaPlaywright: vi.fn(async () => {}),
waitForDownloadViaPlaywright: vi.fn(async () => ({
url: "https://example.com/report.pdf",
suggestedFilename: "report.pdf",
path: "/tmp/report.pdf",
})),
waitForViaPlaywright: vi.fn(async () => {}),
}));
function makeProc(pid = 123) {
const handlers = new Map<string, Array<(...args: unknown[]) => void>>();
return {
pid,
killed: false,
exitCode: null as number | null,
on: (event: string, cb: (...args: unknown[]) => void) => {
handlers.set(event, [...(handlers.get(event) ?? []), cb]);
return undefined;
},
emitExit: () => {
for (const cb of handlers.get("exit") ?? []) {
cb(0);
}
},
kill: () => {
return true;
},
};
}
const proc = makeProc();
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => ({
browser: {
enabled: true,
color: "#FF4500",
attachOnly: cfgAttachOnly,
headless: true,
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
}),
writeConfigFile: vi.fn(async () => {}),
};
});
const launchCalls = vi.hoisted(() => [] as Array<{ port: number }>);
vi.mock("./chrome.js", () => ({
isChromeCdpReady: vi.fn(async () => reachable),
isChromeReachable: vi.fn(async () => reachable),
launchOpenClawChrome: vi.fn(async (_resolved: unknown, profile: { cdpPort: number }) => {
launchCalls.push({ port: profile.cdpPort });
reachable = true;
return {
pid: 123,
exe: { kind: "chrome", path: "/fake/chrome" },
userDataDir: "/tmp/openclaw",
cdpPort: profile.cdpPort,
startedAt: Date.now(),
proc,
};
}),
resolveOpenClawUserDataDir: vi.fn(() => "/tmp/openclaw"),
stopOpenClawChrome: vi.fn(async () => {
reachable = false;
}),
}));
vi.mock("./cdp.js", () => ({
createTargetViaCdp: cdpMocks.createTargetViaCdp,
normalizeCdpWsUrl: vi.fn((wsUrl: string) => wsUrl),
snapshotAria: cdpMocks.snapshotAria,
getHeadersWithAuth: vi.fn(() => ({})),
appendCdpPath: vi.fn((cdpUrl: string, path: string) => {
const base = cdpUrl.replace(/\/$/, "");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${base}${suffix}`;
}),
}));
vi.mock("./pw-ai.js", () => pwMocks);
vi.mock("../media/store.js", () => ({
ensureMediaDir: vi.fn(async () => {}),
saveMediaBuffer: vi.fn(async () => ({ path: "/tmp/fake.png" })),
}));
vi.mock("./screenshot.js", () => ({
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES: 128,
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE: 64,
normalizeBrowserScreenshot: vi.fn(async (buf: Buffer) => ({
buffer: buf,
contentType: "image/png",
})),
}));
async function getFreePort(): Promise<number> {
while (true) {
const port = await new Promise<number>((resolve, reject) => {
const s = createServer();
s.once("error", reject);
s.listen(0, "127.0.0.1", () => {
const assigned = (s.address() as AddressInfo).port;
s.close((err) => (err ? reject(err) : resolve(assigned)));
});
});
if (port < 65535) {
return port;
}
}
}
function makeResponse(
body: unknown,
init?: { ok?: boolean; status?: number; text?: string },
): Response {
const ok = init?.ok ?? true;
const status = init?.status ?? 200;
const text = init?.text ?? "";
return {
ok,
status,
json: async () => body,
text: async () => text,
} as unknown as Response;
}
describe("browser control server", () => {
beforeEach(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) {
return { targetId: createTargetId };
}
throw new Error("cdp disabled");
});
for (const fn of Object.values(pwMocks)) {
fn.mockClear();
}
for (const fn of Object.values(cdpMocks)) {
fn.mockClear();
}
testPort = await getFreePort();
_cdpBaseUrl = `http://127.0.0.1:${testPort + 1}`;
prevGatewayPort = process.env.OPENCLAW_GATEWAY_PORT;
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/json/list")) {
if (!reachable) {
return makeResponse([]);
}
return makeResponse([
{
id: "abcd1234",
title: "Tab",
url: "https://example.com",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abcd1234",
type: "page",
},
{
id: "abce9999",
title: "Other",
url: "https://other",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/abce9999",
type: "page",
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (prevGatewayPort === undefined) {
delete process.env.OPENCLAW_GATEWAY_PORT;
} else {
process.env.OPENCLAW_GATEWAY_PORT = prevGatewayPort;
}
const { stopBrowserControlServer } = await import("./server.js");
await stopBrowserControlServer();
});
it("serves status + starts browser when requested", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
const started = await startBrowserControlServerFromConfig();
expect(started?.port).toBe(testPort);
const base = `http://127.0.0.1:${testPort}`;
const s1 = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
pid: number | null;
};
expect(s1.running).toBe(false);
expect(s1.pid).toBe(null);
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const s2 = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
pid: number | null;
chosenBrowser: string | null;
};
expect(s2.running).toBe(true);
expect(s2.pid).toBe(123);
expect(s2.chosenBrowser).toBe("chrome");
expect(launchCalls.length).toBeGreaterThan(0);
});
it("handles tabs: list, open, focus conflict on ambiguous prefix", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const tabs = (await realFetch(`${base}/tabs`).then((r) => r.json())) as {
running: boolean;
tabs: Array<{ targetId: string }>;
};
expect(tabs.running).toBe(true);
expect(tabs.tabs.length).toBeGreaterThan(0);
const opened = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json());
expect(opened).toMatchObject({ targetId: "newtab1" });
const focus = await realFetch(`${base}/tabs/focus`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetId: "abc" }),
});
expect(focus.status).toBe(409);
});
});

View File

@@ -1,12 +1,11 @@
import { type AddressInfo, createServer } from "node:net";
import { fetch as realFetch } from "undici";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
let testPort = 0;
let cdpBaseUrl = "";
let reachable = false;
let cfgAttachOnly = false;
let createTargetId: string | null = null;
let prevGatewayPort: string | undefined;
const cdpMocks = vi.hoisted(() => ({
@@ -185,15 +184,12 @@ function makeResponse(
}
describe("browser control server", () => {
beforeEach(async () => {
beforeAll(async () => {
reachable = false;
cfgAttachOnly = false;
createTargetId = null;
launchCalls.length = 0;
cdpMocks.createTargetViaCdp.mockImplementation(async () => {
if (createTargetId) {
return { targetId: createTargetId };
}
throw new Error("cdp disabled");
});
@@ -210,7 +206,6 @@ describe("browser control server", () => {
process.env.OPENCLAW_GATEWAY_PORT = String(testPort - 2);
// Minimal CDP JSON endpoints used by the server.
let putNewCalls = 0;
vi.stubGlobal(
"fetch",
vi.fn(async (url: string, init?: RequestInit) => {
@@ -236,33 +231,13 @@ describe("browser control server", () => {
},
]);
}
if (u.includes("/json/new?")) {
if (init?.method === "PUT") {
putNewCalls += 1;
if (putNewCalls === 1) {
return makeResponse({}, { ok: false, status: 405, text: "" });
}
}
return makeResponse({
id: "newtab1",
title: "",
url: "about:blank",
webSocketDebuggerUrl: "ws://127.0.0.1/devtools/page/newtab1",
type: "page",
});
}
if (u.includes("/json/activate/")) {
return makeResponse("ok");
}
if (u.includes("/json/close/")) {
return makeResponse("ok");
}
void init;
return makeResponse({}, { ok: false, status: 500, text: "unexpected" });
}),
);
});
afterEach(async () => {
afterAll(async () => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
if (prevGatewayPort === undefined) {
@@ -274,190 +249,52 @@ describe("browser control server", () => {
await stopBrowserControlServer();
});
it("skips default maxChars when explicitly set to zero", async () => {
it("covers primary control routes, validation, and profile compatibility", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const started = await startBrowserControlServerFromConfig();
expect(started?.port).toBe(testPort);
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const statusBeforeStart = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
pid: number | null;
};
expect(statusBeforeStart.running).toBe(false);
expect(statusBeforeStart.pid).toBe(null);
expect(statusBeforeStart.profile).toBe("openclaw");
const startedPayload = (await realFetch(`${base}/start`, { method: "POST" }).then((r) =>
r.json(),
)) as { ok: boolean; profile?: string };
expect(startedPayload.ok).toBe(true);
expect(startedPayload.profile).toBe("openclaw");
const statusAfterStart = (await realFetch(`${base}/`).then((r) => r.json())) as {
running: boolean;
pid: number | null;
chosenBrowser: string | null;
};
expect(statusAfterStart.running).toBe(true);
expect(statusAfterStart.pid).toBe(123);
expect(statusAfterStart.chosenBrowser).toBe("chrome");
expect(launchCalls.length).toBeGreaterThan(0);
const snapAi = (await realFetch(`${base}/snapshot?format=ai&maxChars=0`).then((r) =>
r.json(),
)) as { ok: boolean; format?: string };
expect(snapAi.ok).toBe(true);
expect(snapAi.format).toBe("ai");
const [call] = pwMocks.snapshotAiViaPlaywright.mock.calls.at(-1) ?? [];
expect(call).toEqual({
cdpUrl: cdpBaseUrl,
targetId: "abcd1234",
});
});
it("validates agent inputs (agent routes)", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
const navMissing = await realFetch(`${base}/navigate`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(navMissing.status).toBe(400);
const actMissing = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(actMissing.status).toBe(400);
const clickMissingRef = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click" }),
});
expect(clickMissingRef.status).toBe(400);
const scrollMissingRef = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "scrollIntoView" }),
});
expect(scrollMissingRef.status).toBe(400);
const scrollSelectorUnsupported = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "scrollIntoView", selector: "button.save" }),
});
expect(scrollSelectorUnsupported.status).toBe(400);
const clickBadButton = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", ref: "1", button: "nope" }),
});
expect(clickBadButton.status).toBe(400);
const clickBadModifiers = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "click", ref: "1", modifiers: ["Nope"] }),
});
expect(clickBadModifiers.status).toBe(400);
const typeBadText = await realFetch(`${base}/act`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ kind: "type", ref: "1", text: 123 }),
});
expect(typeBadText.status).toBe(400);
const uploadMissingPaths = await realFetch(`${base}/hooks/file-chooser`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(uploadMissingPaths.status).toBe(400);
const dialogMissingAccept = await realFetch(`${base}/hooks/dialog`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(dialogMissingAccept.status).toBe(400);
const snapDefault = (await realFetch(`${base}/snapshot?format=wat`).then((r) => r.json())) as {
const stopped = (await realFetch(`${base}/stop`, { method: "POST" }).then((r) => r.json())) as {
ok: boolean;
format?: string;
profile?: string;
};
expect(snapDefault.ok).toBe(true);
expect(snapDefault.format).toBe("ai");
const screenshotBadCombo = await realFetch(`${base}/screenshot`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fullPage: true, element: "body" }),
});
expect(screenshotBadCombo.status).toBe(400);
});
it("covers common error branches", async () => {
cfgAttachOnly = true;
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
const missing = await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
});
expect(missing.status).toBe(400);
reachable = false;
const started = (await realFetch(`${base}/start`, {
method: "POST",
}).then((r) => r.json())) as { error?: string };
expect(started.error ?? "").toMatch(/attachOnly/i);
});
it("allows attachOnly servers to ensure reachability via callback", async () => {
cfgAttachOnly = true;
reachable = false;
const { startBrowserBridgeServer } = await import("./bridge-server.js");
const ensured = vi.fn(async () => {
reachable = true;
});
const bridge = await startBrowserBridgeServer({
resolved: {
enabled: true,
controlPort: 0,
cdpProtocol: "http",
cdpHost: "127.0.0.1",
cdpIsLoopback: true,
color: "#FF4500",
headless: true,
noSandbox: false,
attachOnly: true,
defaultProfile: "openclaw",
profiles: {
openclaw: { cdpPort: testPort + 1, color: "#FF4500" },
},
},
onEnsureAttachTarget: ensured,
});
const started = (await realFetch(`${bridge.baseUrl}/start`, {
method: "POST",
}).then((r) => r.json())) as { ok?: boolean; error?: string };
expect(started.error).toBeUndefined();
expect(started.ok).toBe(true);
const status = (await realFetch(`${bridge.baseUrl}/`).then((r) => r.json())) as {
running?: boolean;
};
expect(status.running).toBe(true);
expect(ensured).toHaveBeenCalledTimes(1);
await new Promise<void>((resolve) => bridge.server.close(() => resolve()));
});
it("opens tabs via CDP createTarget path", async () => {
const { startBrowserControlServerFromConfig } = await import("./server.js");
await startBrowserControlServerFromConfig();
const base = `http://127.0.0.1:${testPort}`;
await realFetch(`${base}/start`, { method: "POST" }).then((r) => r.json());
createTargetId = "abcd1234";
const opened = (await realFetch(`${base}/tabs/open`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url: "https://example.com" }),
}).then((r) => r.json())) as { targetId?: string };
expect(opened.targetId).toBe("abcd1234");
expect(stopped.ok).toBe(true);
expect(stopped.profile).toBe("openclaw");
});
});

View File

@@ -7,8 +7,13 @@ import { safeEqualSecret } from "../security/secret-equal.js";
import { resolveBrowserConfig, resolveProfile } from "./config.js";
import { ensureBrowserControlAuth, resolveBrowserControlAuth } from "./control-auth.js";
import { ensureChromeExtensionRelayServer } from "./extension-relay.js";
import { isPwAiLoaded } from "./pw-ai-state.js";
import { registerBrowserRoutes } from "./routes/index.js";
import { type BrowserServerState, createBrowserRouteContext } from "./server-context.js";
import {
type BrowserServerState,
createBrowserRouteContext,
listKnownProfileNames,
} from "./server-context.js";
let state: BrowserServerState | null = null;
const log = createSubsystemLogger("browser");
@@ -124,6 +129,7 @@ export async function startBrowserControlServerFromConfig(): Promise<BrowserServ
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
registerBrowserRoutes(app as unknown as BrowserRouteRegistrar, ctx);
@@ -172,12 +178,13 @@ export async function stopBrowserControlServer(): Promise<void> {
const ctx = createBrowserRouteContext({
getState: () => state,
refreshConfigFromDisk: true,
});
try {
const current = state;
if (current) {
for (const name of Object.keys(current.resolved.profiles)) {
for (const name of listKnownProfileNames(current)) {
try {
await ctx.forProfile(name).stopRunningBrowser();
} catch {
@@ -196,11 +203,13 @@ export async function stopBrowserControlServer(): Promise<void> {
}
state = null;
// Optional: Playwright is not always available (e.g. embedded gateway builds).
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
// Optional: avoid importing heavy Playwright bridge when this process never used it.
if (isPwAiLoaded()) {
try {
const mod = await import("./pw-ai.js");
await mod.closePlaywrightBrowserConnection();
} catch {
// ignore
}
}
}

View File

@@ -3,7 +3,7 @@ import fs from "node:fs/promises";
import { createServer } from "node:http";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { WebSocket } from "ws";
import { rawDataToString } from "../infra/ws.js";
import { defaultRuntime } from "../runtime.js";
@@ -11,6 +11,27 @@ import { A2UI_PATH, CANVAS_HOST_PATH, CANVAS_WS_PATH, injectCanvasLiveReload } f
import { createCanvasHostHandler, startCanvasHost } from "./server.js";
describe("canvas host", () => {
const quietRuntime = {
...defaultRuntime,
log: (..._args: Parameters<typeof console.log>) => {},
};
let fixtureRoot = "";
let fixtureCount = 0;
const createCaseDir = async () => {
const dir = path.join(fixtureRoot, `case-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
};
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-fixtures-"));
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
it("injects live reload script", () => {
const out = injectCanvasLiveReload("<html><body>Hello</body></html>");
expect(out).toContain(CANVAS_WS_PATH);
@@ -20,10 +41,10 @@ describe("canvas host", () => {
});
it("creates a default index.html when missing", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
const dir = await createCaseDir();
const server = await startCanvasHost({
runtime: defaultRuntime,
runtime: quietRuntime,
rootDir: dir,
port: 0,
listenHost: "127.0.0.1",
@@ -39,16 +60,15 @@ describe("canvas host", () => {
expect(html).toContain(CANVAS_WS_PATH);
} finally {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
});
it("skips live reload injection when disabled", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
const dir = await createCaseDir();
await fs.writeFile(path.join(dir, "index.html"), "<html><body>no-reload</body></html>", "utf8");
const server = await startCanvasHost({
runtime: defaultRuntime,
runtime: quietRuntime,
rootDir: dir,
port: 0,
listenHost: "127.0.0.1",
@@ -67,16 +87,15 @@ describe("canvas host", () => {
expect(wsRes.status).toBe(404);
} finally {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
});
it("serves canvas content from the mounted base path", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
it("serves canvas content from the mounted base path and reuses handlers without double close", async () => {
const dir = await createCaseDir();
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
const handler = await createCanvasHostHandler({
runtime: defaultRuntime,
runtime: quietRuntime,
rootDir: dir,
basePath: CANVAS_HOST_PATH,
allowInTests: true,
@@ -112,30 +131,16 @@ describe("canvas host", () => {
const miss = await fetch(`http://127.0.0.1:${port}/`);
expect(miss.status).toBe(404);
} finally {
await handler.close();
await new Promise<void>((resolve, reject) =>
server.close((err) => (err ? reject(err) : resolve())),
);
await fs.rm(dir, { recursive: true, force: true });
}
});
it("reuses a handler without closing it twice", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
await fs.writeFile(path.join(dir, "index.html"), "<html><body>v1</body></html>", "utf8");
const handler = await createCanvasHostHandler({
runtime: defaultRuntime,
rootDir: dir,
basePath: CANVAS_HOST_PATH,
allowInTests: true,
});
const originalClose = handler.close;
const closeSpy = vi.fn(async () => originalClose());
handler.close = closeSpy;
const server = await startCanvasHost({
runtime: defaultRuntime,
const hosted = await startCanvasHost({
runtime: quietRuntime,
handler,
ownsHandler: false,
port: 0,
@@ -144,22 +149,21 @@ describe("canvas host", () => {
});
try {
expect(server.port).toBeGreaterThan(0);
expect(hosted.port).toBeGreaterThan(0);
} finally {
await server.close();
await hosted.close();
expect(closeSpy).not.toHaveBeenCalled();
await originalClose();
await fs.rm(dir, { recursive: true, force: true });
}
});
it("serves HTML with injection and broadcasts reload on file changes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
const dir = await createCaseDir();
const index = path.join(dir, "index.html");
await fs.writeFile(index, "<html><body>v1</body></html>", "utf8");
const server = await startCanvasHost({
runtime: defaultRuntime,
runtime: quietRuntime,
rootDir: dir,
port: 0,
listenHost: "127.0.0.1",
@@ -194,21 +198,22 @@ describe("canvas host", () => {
});
});
await new Promise((resolve) => setTimeout(resolve, 100));
await fs.writeFile(index, "<html><body>v2</body></html>", "utf8");
expect(await msg).toBe("reload");
ws.close();
} finally {
await server.close();
await fs.rm(dir, { recursive: true, force: true });
}
}, 20_000);
it("serves the gateway-hosted A2UI scaffold", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
it("serves A2UI scaffold and blocks traversal/symlink escapes", async () => {
const dir = await createCaseDir();
const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui");
const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js");
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
const linkPath = path.join(a2uiRoot, linkName);
let createdBundle = false;
let createdLink = false;
try {
await fs.stat(bundlePath);
@@ -217,8 +222,11 @@ describe("canvas host", () => {
createdBundle = true;
}
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
createdLink = true;
const server = await startCanvasHost({
runtime: defaultRuntime,
runtime: quietRuntime,
rootDir: dir,
port: 0,
listenHost: "127.0.0.1",
@@ -238,80 +246,14 @@ describe("canvas host", () => {
const js = await bundleRes.text();
expect(bundleRes.status).toBe(200);
expect(js).toContain("openclawA2UI");
} finally {
await server.close();
if (createdBundle) {
await fs.rm(bundlePath, { force: true });
}
await fs.rm(dir, { recursive: true, force: true });
}
});
it("rejects traversal-style A2UI asset requests", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui");
const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js");
let createdBundle = false;
try {
await fs.stat(bundlePath);
} catch {
await fs.writeFile(bundlePath, "window.openclawA2UI = {};", "utf8");
createdBundle = true;
}
const server = await startCanvasHost({
runtime: defaultRuntime,
rootDir: dir,
port: 0,
listenHost: "127.0.0.1",
allowInTests: true,
});
try {
const res = await fetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`);
expect(res.status).toBe(404);
expect(await res.text()).toBe("not found");
} finally {
await server.close();
if (createdBundle) {
await fs.rm(bundlePath, { force: true });
}
await fs.rm(dir, { recursive: true, force: true });
}
});
it("rejects A2UI symlink escapes", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canvas-"));
const a2uiRoot = path.resolve(process.cwd(), "src/canvas-host/a2ui");
const bundlePath = path.join(a2uiRoot, "a2ui.bundle.js");
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
const linkPath = path.join(a2uiRoot, linkName);
let createdBundle = false;
let createdLink = false;
try {
await fs.stat(bundlePath);
} catch {
await fs.writeFile(bundlePath, "window.openclawA2UI = {};", "utf8");
createdBundle = true;
}
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
createdLink = true;
const server = await startCanvasHost({
runtime: defaultRuntime,
rootDir: dir,
port: 0,
listenHost: "127.0.0.1",
allowInTests: true,
});
try {
const res = await fetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`);
expect(res.status).toBe(404);
expect(await res.text()).toBe("not found");
const traversalRes = await fetch(
`http://127.0.0.1:${server.port}${A2UI_PATH}/%2e%2e%2fpackage.json`,
);
expect(traversalRes.status).toBe(404);
expect(await traversalRes.text()).toBe("not found");
const symlinkRes = await fetch(`http://127.0.0.1:${server.port}${A2UI_PATH}/${linkName}`);
expect(symlinkRes.status).toBe(404);
expect(await symlinkRes.text()).toBe("not found");
} finally {
await server.close();
if (createdLink) {
@@ -320,7 +262,6 @@ describe("canvas host", () => {
if (createdBundle) {
await fs.rm(bundlePath, { force: true });
}
await fs.rm(dir, { recursive: true, force: true });
}
});
});

View File

@@ -264,6 +264,10 @@ export async function createCanvasHostHandler(
const rootReal = await prepareCanvasRoot(rootDir);
const liveReload = opts.liveReload !== false;
const testMode = opts.allowInTests === true;
const reloadDebounceMs = testMode ? 12 : 75;
const writeStabilityThresholdMs = testMode ? 12 : 75;
const writePollIntervalMs = testMode ? 5 : 10;
const wss = liveReload ? new WebSocketServer({ noServer: true }) : null;
const sockets = new Set<WebSocket>();
if (wss) {
@@ -293,7 +297,7 @@ export async function createCanvasHostHandler(
debounce = setTimeout(() => {
debounce = null;
broadcastReload();
}, 75);
}, reloadDebounceMs);
debounce.unref?.();
};
@@ -301,8 +305,11 @@ export async function createCanvasHostHandler(
const watcher = liveReload
? chokidar.watch(rootReal, {
ignoreInitial: true,
awaitWriteFinish: { stabilityThreshold: 75, pollInterval: 10 },
usePolling: opts.allowInTests === true,
awaitWriteFinish: {
stabilityThreshold: writeStabilityThresholdMs,
pollInterval: writePollIntervalMs,
},
usePolling: testMode,
ignored: [
/(^|[\\/])\../, // dotfiles
/(^|[\\/])node_modules([\\/]|$)/,

View File

@@ -21,20 +21,12 @@ vi.mock("../../../discord/send.js", async () => {
};
});
const loadHandleDiscordMessageAction = async () => {
const mod = await import("./discord/handle-action.js");
return mod.handleDiscordMessageAction;
};
const loadDiscordMessageActions = async () => {
const mod = await import("./discord.js");
return mod.discordMessageActions;
};
const { handleDiscordMessageAction } = await import("./discord/handle-action.js");
const { discordMessageActions } = await import("./discord.js");
describe("discord message actions", () => {
it("lists channel and upload actions by default", async () => {
const cfg = { channels: { discord: { token: "d0" } } } as OpenClawConfig;
const discordMessageActions = await loadDiscordMessageActions();
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).toContain("emoji-upload");
@@ -46,7 +38,6 @@ describe("discord message actions", () => {
const cfg = {
channels: { discord: { token: "d0", actions: { channels: false } } },
} as OpenClawConfig;
const discordMessageActions = await loadDiscordMessageActions();
const actions = discordMessageActions.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("channel-create");
@@ -56,7 +47,6 @@ describe("discord message actions", () => {
describe("handleDiscordMessageAction", () => {
it("forwards context accountId for send", async () => {
sendMessageDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "send",
@@ -79,7 +69,6 @@ describe("handleDiscordMessageAction", () => {
it("falls back to params accountId when context missing", async () => {
sendPollDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "poll",
@@ -106,7 +95,6 @@ describe("handleDiscordMessageAction", () => {
it("forwards accountId for thread replies", async () => {
sendMessageDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "thread-reply",
@@ -129,7 +117,6 @@ describe("handleDiscordMessageAction", () => {
it("accepts threadId for thread replies (tool compatibility)", async () => {
sendMessageDiscord.mockClear();
const handleDiscordMessageAction = await loadHandleDiscordMessageAction();
await handleDiscordMessageAction({
action: "thread-reply",

View File

@@ -0,0 +1,124 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("../../../slack/send.js", () => ({
sendMessageSlack: vi.fn().mockResolvedValue({ ts: "1234.5678", channel: "C123" }),
}));
vi.mock("../../../plugins/hook-runner-global.js", () => ({
getGlobalHookRunner: vi.fn(),
}));
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { sendMessageSlack } from "../../../slack/send.js";
import { slackOutbound } from "./slack.js";
describe("slack outbound hook wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("calls send without hooks when no hooks registered", async () => {
vi.mocked(getGlobalHookRunner).mockReturnValue(null);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222",
accountId: "default",
});
});
it("calls message_sending hook before sending", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue(undefined),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(mockRunner.hasHooks).toHaveBeenCalledWith("message_sending");
expect(mockRunner.runMessageSending).toHaveBeenCalledWith(
{ to: "C123", content: "hello", metadata: { threadTs: "1111.2222", channelId: "C123" } },
{ channelId: "slack", accountId: "default" },
);
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "hello", {
threadTs: "1111.2222",
accountId: "default",
});
});
it("cancels send when hook returns cancel:true", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue({ cancel: true }),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
const result = await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(sendMessageSlack).not.toHaveBeenCalled();
expect(result.channel).toBe("slack");
});
it("modifies text when hook returns content", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(true),
runMessageSending: vi.fn().mockResolvedValue({ content: "modified" }),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await slackOutbound.sendText({
to: "C123",
text: "original",
accountId: "default",
replyToId: "1111.2222",
});
expect(sendMessageSlack).toHaveBeenCalledWith("C123", "modified", {
threadTs: "1111.2222",
accountId: "default",
});
});
it("skips hooks when runner has no message_sending hooks", async () => {
const mockRunner = {
hasHooks: vi.fn().mockReturnValue(false),
runMessageSending: vi.fn(),
};
// oxlint-disable-next-line typescript/no-explicit-any
vi.mocked(getGlobalHookRunner).mockReturnValue(mockRunner as any);
await slackOutbound.sendText({
to: "C123",
text: "hello",
accountId: "default",
replyToId: "1111.2222",
});
expect(mockRunner.runMessageSending).not.toHaveBeenCalled();
expect(sendMessageSlack).toHaveBeenCalled();
});
});

View File

@@ -1,4 +1,5 @@
import type { ChannelOutboundAdapter } from "../types.js";
import { getGlobalHookRunner } from "../../../plugins/hook-runner-global.js";
import { sendMessageSlack } from "../../../slack/send.js";
export const slackOutbound: ChannelOutboundAdapter = {
@@ -9,7 +10,29 @@ export const slackOutbound: ChannelOutboundAdapter = {
const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
const result = await send(to, text, {
let finalText = text;
// Run message_sending hooks (e.g. thread-ownership can cancel the send).
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_sending")) {
const hookResult = await hookRunner.runMessageSending(
{ to, content: text, metadata: { threadTs, channelId: to } },
{ channelId: "slack", accountId: accountId ?? undefined },
);
if (hookResult?.cancel) {
return {
channel: "slack",
messageId: "cancelled-by-hook",
channelId: to,
meta: { cancelled: true },
};
}
if (hookResult?.content) {
finalText = hookResult.content;
}
}
const result = await send(to, finalText, {
threadTs,
accountId: accountId ?? undefined,
});
@@ -19,7 +42,29 @@ export const slackOutbound: ChannelOutboundAdapter = {
const send = deps?.sendSlack ?? sendMessageSlack;
// Use threadId fallback so routed tool notifications stay in the Slack thread.
const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined);
const result = await send(to, text, {
let finalText = text;
// Run message_sending hooks (e.g. thread-ownership can cancel the send).
const hookRunner = getGlobalHookRunner();
if (hookRunner?.hasHooks("message_sending")) {
const hookResult = await hookRunner.runMessageSending(
{ to, content: text, metadata: { threadTs, channelId: to, mediaUrl } },
{ channelId: "slack", accountId: accountId ?? undefined },
);
if (hookResult?.cancel) {
return {
channel: "slack",
messageId: "cancelled-by-hook",
channelId: to,
meta: { cancelled: true },
};
}
if (hookResult?.content) {
finalText = hookResult.content;
}
}
const result = await send(to, finalText, {
mediaUrl,
threadTs,
accountId: accountId ?? undefined,

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