mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-23 01:58:12 +00:00
merge main into fix/config-schema-key-14998
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -21,7 +21,7 @@ Compaction **persists** in the session’s 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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
? {
|
||||
|
||||
180
extensions/thread-ownership/index.test.ts
Normal file
180
extensions/thread-ownership/index.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
133
extensions/thread-ownership/index.ts
Normal file
133
extensions/thread-ownership/index.ts
Normal 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`);
|
||||
}
|
||||
});
|
||||
}
|
||||
28
extensions/thread-ownership/openclaw.plugin.json
Normal file
28
extensions/thread-ownership/openclaw.plugin.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
23
scripts/pr
23
scripts/pr
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
191
scripts/recover-orphaned-processes.sh
Executable file
191
scripts/recover-orphaned-processes.sh
Executable 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
|
||||
@@ -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"}`);
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ export async function resolveBootstrapFilesForRun(params: {
|
||||
await loadWorkspaceBootstrapFiles(params.workspaceDir),
|
||||
sessionKey,
|
||||
);
|
||||
|
||||
return applyBootstrapHookOverrides({
|
||||
files: bootstrapFiles,
|
||||
workspaceDir: params.workspaceDir,
|
||||
|
||||
240
src/agents/model-forward-compat.ts
Normal file
240
src/agents/model-forward-compat.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
290
src/agents/ollama-stream.test.ts
Normal file
290
src/agents/ollama-stream.test.ts
Normal 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
419
src/agents/ollama-stream.ts
Normal 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;
|
||||
};
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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-")) {
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() : "";
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(/ /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;
|
||||
|
||||
@@ -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" });
|
||||
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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-");
|
||||
|
||||
53
src/agents/workspace.load-extra-bootstrap-files.test.ts
Normal file
53
src/agents/workspace.load-extra-bootstrap-files.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
91
src/auto-reply/dispatch.test.ts
Normal file
91
src/auto-reply/dispatch.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
58
src/auto-reply/reply/dispatcher-registry.ts
Normal file
58
src/auto-reply/reply/dispatcher-registry.ts
Normal 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();
|
||||
}
|
||||
192
src/auto-reply/reply/get-reply-run.media-only.test.ts
Normal file
192
src/auto-reply/reply/get-reply-run.media-only.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -100,6 +100,8 @@ describe("createReplyDispatcher", () => {
|
||||
dispatcher.sendFinalReply({ text: "two" });
|
||||
|
||||
await dispatcher.waitForIdle();
|
||||
dispatcher.markComplete();
|
||||
await Promise.resolve();
|
||||
expect(onIdle).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
9
src/browser/pw-ai-state.ts
Normal file
9
src/browser/pw-ai-state.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
let pwAiLoaded = false;
|
||||
|
||||
export function markPwAiLoaded(): void {
|
||||
pwAiLoaded = true;
|
||||
}
|
||||
|
||||
export function isPwAiLoaded(): boolean {
|
||||
return pwAiLoaded;
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
import { markPwAiLoaded } from "./pw-ai-state.js";
|
||||
|
||||
markPwAiLoaded();
|
||||
|
||||
export {
|
||||
type BrowserConsoleMessage,
|
||||
closePageByTargetIdViaPlaywright,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
214
src/browser/server-context.hot-reload-profiles.test.ts
Normal file
214
src/browser/server-context.hot-reload-profiles.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
40
src/browser/server-context.list-known-profile-names.test.ts
Normal file
40
src/browser/server-context.list-known-profile-names.test.ts
Normal 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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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)) {
|
||||
|
||||
@@ -72,4 +72,5 @@ export type ProfileStatus = {
|
||||
export type ContextOptions = {
|
||||
getState: () => BrowserServerState | null;
|
||||
onEnsureAttachTarget?: (profile: ResolvedBrowserProfile) => Promise<void>;
|
||||
refreshConfigFromDisk?: boolean;
|
||||
};
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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([\\/]|$)/,
|
||||
|
||||
@@ -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",
|
||||
|
||||
124
src/channels/plugins/outbound/slack.test.ts
Normal file
124
src/channels/plugins/outbound/slack.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in New Issue
Block a user