Compare commits
18
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
929666a8c8 | ||
|
|
cd409e5667 | ||
|
|
d0cb4e092f | ||
|
|
f5a881c99d | ||
|
|
66377fc030 | ||
|
|
d8d295b0b3 | ||
|
|
ef36e24522 | ||
|
|
d43d4fcced | ||
|
|
d42b69df74 | ||
|
|
1ec1f6dcbf | ||
|
|
e96b939732 | ||
|
|
e479c870fd | ||
|
|
f2db894685 | ||
|
|
0d5dec4c66 | ||
|
|
2ee71e4154 | ||
|
|
509215e935 | ||
|
|
f726656d1e | ||
|
|
30d3e1da21 |
No files matched your search
@@ -4,6 +4,7 @@
|
||||
|
||||
- **BREAKING:** Microsoft Teams is now a plugin; install `@clawdbot/msteams` via `clawdbot plugins install @clawdbot/msteams`.
|
||||
- CLI: set process titles to `clawdbot-<command>` for clearer process listings.
|
||||
- CLI/macOS: sync remote SSH target/identity to config and let `gateway status` auto-infer SSH targets (ssh-config aware).
|
||||
- Heartbeat: tighten prompt guidance + suppress duplicate alerts for 24h. (#980) — thanks @voidserf.
|
||||
- Sessions/Security: add `session.dmScope` for multi-user DM isolation and audit warnings. (#948) — thanks @Alphonse-arianee.
|
||||
- Plugins: add provider auth registry + `clawdbot models auth login` for plugin-driven OAuth/API key flows.
|
||||
@@ -17,8 +18,10 @@
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Agents: avoid false positives when logging unsupported Google tool schema keywords.
|
||||
- Agents: skip Gemini history downgrades for google-antigravity to preserve tool calls. (#894) — thanks @mukhtharcm.
|
||||
- Status: restore usage summary line for current provider when no OAuth profiles exist.
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
- Messages: debounce rapid inbound messages across channels with per-connector overrides. (#971) — thanks @juanpablodlc.
|
||||
- Messages: allow media-only sends (CLI/tool) and show Telegram voice recording status for voice notes. (#957) — thanks @rdev.
|
||||
@@ -26,6 +29,7 @@
|
||||
- Fix: guard model fallback against undefined provider/model values. (#954) — thanks @roshanasingh4.
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- CLI: add `--json` output for `clawdbot daemon` lifecycle/install commands.
|
||||
- Memory: make `node-llama-cpp` an optional dependency (avoid Node 25 install failures) and improve local-embeddings fallback/errors.
|
||||
- Browser: add `snapshot refs=aria` (Playwright aria-ref ids) for self-resolving refs across `snapshot` → `act`.
|
||||
@@ -34,11 +38,13 @@
|
||||
- Browser: prefer stable Chrome for auto-detect, with Brave/Edge fallbacks and updated docs. (#983) — thanks @cpojer.
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
- Telegram: add bidirectional reaction support with configurable notifications and agent guidance. (#964) — thanks @bohdanpodvirnyi.
|
||||
- Telegram: allow custom commands in the bot menu (merged with native; conflicts ignored). (#860) — thanks @nachoiacovino.
|
||||
- Telegram: skip `message_thread_id=1` for General topic sends while keeping typing indicators. (#848) — thanks @azade-c.
|
||||
- Discord: allow allowlisted guilds without channel lists to receive messages when `groupPolicy="allowlist"`. — thanks @thewilloftheshadow.
|
||||
- Discord: allow emoji/sticker uploads + channel actions in config defaults. (#870) — thanks @JDIVE.
|
||||
- Fix: sanitize user-facing error text + strip `<final>` tags across reply pipelines. (#975) — thanks @ThomsenDrake.
|
||||
- Fix: normalize pairing CLI aliases, allow extension channels, and harden Zalo webhook payload parsing. (#991) — thanks @longmaba.
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
@@ -140,6 +146,7 @@
|
||||
### Fixes
|
||||
- Packaging: include `dist/memory/**` in the npm tarball (fixes `ERR_MODULE_NOT_FOUND` for `dist/memory/index.js`).
|
||||
- Agents: persist sub-agent registry across gateway restarts and resume announce flow safely. (#831) — thanks @roshanasingh4.
|
||||
- Agents: strip invalid Gemini thought signatures from OpenRouter history to avoid 400s. (#841, #845) — thanks @MatthieuBizien.
|
||||
|
||||
## 2026.1.12-1
|
||||
|
||||
|
||||
@@ -341,6 +341,15 @@ final class AppState {
|
||||
return host
|
||||
}
|
||||
|
||||
private static func sanitizeSSHTarget(_ value: String) -> String {
|
||||
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("ssh ") {
|
||||
return trimmed.replacingOccurrences(of: "ssh ", with: "")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private func startConfigWatcher() {
|
||||
let configUrl = ClawdbotConfigFile.url()
|
||||
self.configWatcher = ConfigFileWatcher(url: configUrl) { [weak self] in
|
||||
@@ -406,6 +415,7 @@ final class AppState {
|
||||
|
||||
let connectionMode = self.connectionMode
|
||||
let remoteTarget = self.remoteTarget
|
||||
let remoteIdentity = self.remoteIdentity
|
||||
let desiredMode: String? = switch connectionMode {
|
||||
case .local:
|
||||
"local"
|
||||
@@ -435,15 +445,46 @@ final class AppState {
|
||||
changed = true
|
||||
}
|
||||
|
||||
if connectionMode == .remote, let host = remoteHost {
|
||||
if connectionMode == .remote {
|
||||
var remote = gateway["remote"] as? [String: Any] ?? [:]
|
||||
let existingUrl = (remote["url"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
var remoteChanged = false
|
||||
|
||||
if let host = remoteHost {
|
||||
let existingUrl = (remote["url"] as? String)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
let parsedExisting = existingUrl.isEmpty ? nil : URL(string: existingUrl)
|
||||
let scheme = parsedExisting?.scheme?.isEmpty == false ? parsedExisting?.scheme : "ws"
|
||||
let port = parsedExisting?.port ?? 18789
|
||||
let desiredUrl = "\(scheme ?? "ws")://\(host):\(port)"
|
||||
if existingUrl != desiredUrl {
|
||||
remote["url"] = desiredUrl
|
||||
remoteChanged = true
|
||||
}
|
||||
}
|
||||
|
||||
let sanitizedTarget = Self.sanitizeSSHTarget(remoteTarget)
|
||||
if !sanitizedTarget.isEmpty {
|
||||
if (remote["sshTarget"] as? String) != sanitizedTarget {
|
||||
remote["sshTarget"] = sanitizedTarget
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshTarget"] != nil {
|
||||
remote.removeValue(forKey: "sshTarget")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
let trimmedIdentity = remoteIdentity.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmedIdentity.isEmpty {
|
||||
if (remote["sshIdentity"] as? String) != trimmedIdentity {
|
||||
remote["sshIdentity"] = trimmedIdentity
|
||||
remoteChanged = true
|
||||
}
|
||||
} else if remote["sshIdentity"] != nil {
|
||||
remote.removeValue(forKey: "sshIdentity")
|
||||
remoteChanged = true
|
||||
}
|
||||
|
||||
if remoteChanged {
|
||||
gateway["remote"] = remote
|
||||
changed = true
|
||||
}
|
||||
|
||||
@@ -188,9 +188,15 @@ extension CronJobEditor {
|
||||
}
|
||||
}
|
||||
|
||||
func applyDeleteAfterRun(to root: inout [String: Any]) {
|
||||
if self.scheduleKind == .at {
|
||||
root["deleteAfterRun"] = self.deleteAfterRun
|
||||
func applyDeleteAfterRun(
|
||||
to root: inout [String: Any],
|
||||
scheduleKind: ScheduleKind? = nil,
|
||||
deleteAfterRun: Bool? = nil
|
||||
) {
|
||||
let resolvedSchedule = scheduleKind ?? self.scheduleKind
|
||||
let resolvedDelete = deleteAfterRun ?? self.deleteAfterRun
|
||||
if resolvedSchedule == .at {
|
||||
root["deleteAfterRun"] = resolvedDelete
|
||||
} else if self.job?.deleteAfterRun != nil {
|
||||
root["deleteAfterRun"] = false
|
||||
}
|
||||
|
||||
@@ -87,6 +87,14 @@ final class GatewayProcessManager {
|
||||
self.status = .stopped
|
||||
return
|
||||
}
|
||||
// Many surfaces can call `setActive(true)` in quick succession (startup, Canvas, health checks).
|
||||
// Avoid spawning multiple concurrent "start" tasks that can thrash launchd and flap the port.
|
||||
switch self.status {
|
||||
case .starting, .running, .attachedExisting:
|
||||
return
|
||||
case .stopped, .failed:
|
||||
break
|
||||
}
|
||||
self.status = .starting
|
||||
self.logger.debug("gateway start requested")
|
||||
|
||||
|
||||
@@ -164,6 +164,7 @@ final class MacNodeModeCoordinator {
|
||||
]
|
||||
|
||||
if SystemRunPolicy.load() != .never {
|
||||
commands.append(ClawdbotSystemCommand.which.rawValue)
|
||||
commands.append(ClawdbotSystemCommand.run.rawValue)
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,8 @@ actor MacNodeRuntime {
|
||||
return try await self.handleScreenRecordInvoke(req)
|
||||
case ClawdbotSystemCommand.run.rawValue:
|
||||
return try await self.handleSystemRun(req)
|
||||
case ClawdbotSystemCommand.which.rawValue:
|
||||
return try await self.handleSystemWhich(req)
|
||||
case ClawdbotSystemCommand.notify.rawValue:
|
||||
return try await self.handleSystemNotify(req)
|
||||
default:
|
||||
@@ -494,6 +496,33 @@ actor MacNodeRuntime {
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemWhich(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(ClawdbotSystemWhichParams.self, from: req.paramsJSON)
|
||||
let bins = params.bins
|
||||
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
||||
.filter { !$0.isEmpty }
|
||||
guard !bins.isEmpty else {
|
||||
return Self.errorResponse(req, code: .invalidRequest, message: "INVALID_REQUEST: bins required")
|
||||
}
|
||||
|
||||
let searchPaths = CommandResolver.preferredPaths()
|
||||
var matches: [String] = []
|
||||
var paths: [String: String] = [:]
|
||||
for bin in bins {
|
||||
if let path = CommandResolver.findExecutable(named: bin, searchPaths: searchPaths) {
|
||||
matches.append(bin)
|
||||
paths[bin] = path
|
||||
}
|
||||
}
|
||||
|
||||
struct WhichPayload: Encodable {
|
||||
let bins: [String]
|
||||
let paths: [String: String]
|
||||
}
|
||||
let payload = try Self.encodePayload(WhichPayload(bins: matches, paths: paths))
|
||||
return BridgeInvokeResponse(id: req.id, ok: true, payloadJSON: payload)
|
||||
}
|
||||
|
||||
private func handleSystemNotify(_ req: BridgeInvokeRequest) async throws -> BridgeInvokeResponse {
|
||||
let params = try Self.decodeParams(ClawdbotSystemNotifyParams.self, from: req.paramsJSON)
|
||||
let title = params.title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
@@ -351,10 +351,11 @@ actor PortGuardian {
|
||||
if port == GatewayEnvironment.gatewayPort() { return cmd.contains("ssh") }
|
||||
return false
|
||||
case .local:
|
||||
if !cmd.contains("clawdbot") { return false }
|
||||
// The gateway daemon may listen as `clawdbot` or as its runtime (`node`, `bun`, etc).
|
||||
if full.contains("gateway-daemon") { return true }
|
||||
// If args are unavailable, treat a clawdbot listener as expected.
|
||||
return full == cmd
|
||||
if cmd.contains("clawdbot"), full == cmd { return true }
|
||||
return false
|
||||
case .unconfigured:
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ClawdbotProtocol
|
||||
import SwiftUI
|
||||
import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@@ -38,7 +38,7 @@ struct CronJobEditorSmokeTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 120,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "Cron"),
|
||||
@@ -70,22 +70,16 @@ struct CronJobEditorSmokeTests {
|
||||
}
|
||||
|
||||
@Test func cronJobEditorIncludesDeleteAfterRunForAtSchedule() throws {
|
||||
var view = CronJobEditor(
|
||||
let view = CronJobEditor(
|
||||
job: nil,
|
||||
isSaving: .constant(false),
|
||||
error: .constant(nil),
|
||||
onCancel: {},
|
||||
onSave: { _ in })
|
||||
view.name = "One-shot"
|
||||
view.sessionTarget = .main
|
||||
view.payloadKind = .systemEvent
|
||||
view.systemEventText = "hello"
|
||||
view.scheduleKind = .at
|
||||
view.atDate = Date(timeIntervalSince1970: 1_700_000_000)
|
||||
view.deleteAfterRun = true
|
||||
|
||||
let payload = try view.buildPayload()
|
||||
let raw = payload["deleteAfterRun"]?.value as? Bool
|
||||
var root: [String: Any] = [:]
|
||||
view.applyDeleteAfterRun(to: &root, scheduleKind: .at, deleteAfterRun: true)
|
||||
let raw = root["deleteAfterRun"] as? Bool
|
||||
#expect(raw == true)
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,7 @@ struct CronModelsTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 15,
|
||||
deliver: true,
|
||||
provider: "whatsapp",
|
||||
channel: "whatsapp",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: false)
|
||||
let data = try JSONEncoder().encode(payload)
|
||||
|
||||
@@ -11,7 +11,8 @@ import Testing
|
||||
stateversion: StateVersion(presence: 1, health: 1),
|
||||
uptimems: 123,
|
||||
configpath: nil,
|
||||
statedir: nil)
|
||||
statedir: nil,
|
||||
sessiondefaults: nil)
|
||||
|
||||
let hello = HelloOk(
|
||||
type: "hello",
|
||||
|
||||
@@ -21,6 +21,15 @@ struct MacNodeRuntimeTests {
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func handleInvokeRejectsEmptySystemWhich() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = ClawdbotSystemWhichParams(bins: [])
|
||||
let json = try String(data: JSONEncoder().encode(params), encoding: .utf8)
|
||||
let response = await runtime.handleInvoke(
|
||||
BridgeInvokeRequest(id: "req-2b", command: ClawdbotSystemCommand.which.rawValue, paramsJSON: json))
|
||||
#expect(response.ok == false)
|
||||
}
|
||||
|
||||
@Test func handleInvokeRejectsEmptyNotification() async throws {
|
||||
let runtime = MacNodeRuntime()
|
||||
let params = ClawdbotSystemNotifyParams(title: "", body: "")
|
||||
@@ -65,6 +74,10 @@ struct MacNodeRuntimeTests {
|
||||
{
|
||||
CLLocation(latitude: 0, longitude: 0)
|
||||
}
|
||||
|
||||
func confirmSystemRun(command: String, cwd: String?) async -> SystemRunDecision {
|
||||
.allowOnce
|
||||
}
|
||||
}
|
||||
|
||||
let services = await MainActor.run { FakeMainActorServices() }
|
||||
|
||||
@@ -16,13 +16,13 @@ struct OnboardingViewSmokeTests {
|
||||
}
|
||||
|
||||
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
||||
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||
#expect(!order.contains(7))
|
||||
#expect(order.contains(3))
|
||||
}
|
||||
|
||||
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
||||
let order = OnboardingView.pageOrder(for: .local, needsBootstrap: false)
|
||||
let order = OnboardingView.pageOrder(for: .local, showOnboardingChat: false)
|
||||
#expect(!order.contains(8))
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ struct SettingsViewSmokeTests {
|
||||
thinking: "low",
|
||||
timeoutSeconds: 30,
|
||||
deliver: true,
|
||||
provider: "sms",
|
||||
channel: "sms",
|
||||
to: "+15551234567",
|
||||
bestEffortDeliver: true),
|
||||
isolation: CronIsolation(postToMainPrefix: "[cron] "),
|
||||
|
||||
@@ -17,6 +17,6 @@ import Testing
|
||||
#expect(opts.thinking == "low")
|
||||
#expect(opts.deliver == true)
|
||||
#expect(opts.to == nil)
|
||||
#expect(opts.provider == .last)
|
||||
#expect(opts.channel == .last)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import Foundation
|
||||
|
||||
public enum ClawdbotSystemCommand: String, Codable, Sendable {
|
||||
case run = "system.run"
|
||||
case which = "system.which"
|
||||
case notify = "system.notify"
|
||||
}
|
||||
|
||||
@@ -39,6 +40,14 @@ public struct ClawdbotSystemRunParams: Codable, Sendable, Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ClawdbotSystemWhichParams: Codable, Sendable, Equatable {
|
||||
public var bins: [String]
|
||||
|
||||
public init(bins: [String]) {
|
||||
self.bins = bins
|
||||
}
|
||||
}
|
||||
|
||||
public struct ClawdbotSystemNotifyParams: Codable, Sendable, Equatable {
|
||||
public var title: String
|
||||
public var body: String
|
||||
|
||||
@@ -101,6 +101,29 @@ group messages, so use admin if you need full visibility.
|
||||
- Raw HTML from models is escaped to avoid Telegram parse errors.
|
||||
- If Telegram rejects the HTML payload, Clawdbot retries the same message as plain text.
|
||||
|
||||
## Commands (native + custom)
|
||||
Clawdbot registers native commands (like `/status`, `/reset`, `/model`) with Telegram’s bot menu on startup.
|
||||
You can add custom commands to the menu via config:
|
||||
|
||||
```json5
|
||||
{
|
||||
channels: {
|
||||
telegram: {
|
||||
customCommands: [
|
||||
{ command: "backup", description: "Git backup" },
|
||||
{ command: "generate", description: "Create an image" }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Notes:
|
||||
- Custom commands are **menu entries only**; Clawdbot does not implement them unless you handle them elsewhere.
|
||||
- Command names are normalized (leading `/` stripped, lowercased) and must match `a-z`, `0-9`, `_` (1–32 chars).
|
||||
- Custom commands **cannot override native commands**. Conflicts are ignored and logged.
|
||||
- If `commands.native` is disabled, only custom commands are registered (or cleared if none).
|
||||
|
||||
## Limits
|
||||
- Outbound text is chunked to `channels.telegram.textChunkLimit` (default 4000).
|
||||
- Media downloads/uploads are capped by `channels.telegram.mediaMaxMb` (default 5).
|
||||
|
||||
@@ -871,6 +871,7 @@ Notes:
|
||||
- `commands.text: false` disables parsing chat messages for commands.
|
||||
- `commands.native: "auto"` (default) turns on native commands for Discord/Telegram and leaves Slack off; unsupported channels stay text-only.
|
||||
- Set `commands.native: true|false` to force all, or override per channel with `channels.discord.commands.native`, `channels.telegram.commands.native`, `channels.slack.commands.native` (bool or `"auto"`). `false` clears previously registered commands on Discord/Telegram at startup; Slack commands are managed in the Slack app.
|
||||
- `channels.telegram.customCommands` adds extra Telegram bot menu entries. Names are normalized; conflicts with native commands are ignored.
|
||||
- `commands.bash: true` enables `! <cmd>` to run host shell commands (`/bash <cmd>` also works as an alias). Requires `tools.elevated.enabled` and allowlisting the sender in `tools.elevated.allowFrom.<channel>`.
|
||||
- `commands.bashForegroundMs` controls how long bash waits before backgrounding. While a bash job is running, new `! <cmd>` requests are rejected (one at a time).
|
||||
- `commands.config: true` enables `/config` (reads/writes `clawdbot.json`).
|
||||
@@ -929,6 +930,10 @@ Set `channels.telegram.configWrites: false` to block Telegram-initiated config w
|
||||
}
|
||||
}
|
||||
},
|
||||
customCommands: [
|
||||
{ command: "backup", description: "Git backup" },
|
||||
{ command: "generate", description: "Create an image" }
|
||||
],
|
||||
historyLimit: 50, // include last N group messages as context (0 disables)
|
||||
replyToMode: "first", // off | first | all
|
||||
streamMode: "partial", // off | partial | block (draft streaming; separate from block streaming)
|
||||
|
||||
+25
-7
@@ -206,6 +206,31 @@ if [[ "$NO_SIGN" -ne 1 && -f "${LAUNCHAGENT_DISABLE_MARKER}" ]]; then
|
||||
run_step "clear launchagent disable marker" /bin/rm -f "${LAUNCHAGENT_DISABLE_MARKER}"
|
||||
fi
|
||||
|
||||
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (before the app launches).
|
||||
# This reduces noisy "could not connect" errors during app startup.
|
||||
if [ "$NO_SIGN" -eq 1 ]; then
|
||||
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
||||
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
||||
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
|
||||
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
|
||||
fi
|
||||
GATEWAY_PORT="$(
|
||||
node -e '
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
try {
|
||||
const raw = fs.readFileSync(path.join(process.env.HOME, ".clawdbot", "clawdbot.json"), "utf8");
|
||||
const cfg = JSON.parse(raw);
|
||||
const port = cfg && cfg.gateway && typeof cfg.gateway.port === "number" ? cfg.gateway.port : 18789;
|
||||
process.stdout.write(String(port));
|
||||
} catch {
|
||||
process.stdout.write("18789");
|
||||
}
|
||||
'
|
||||
)"
|
||||
run_step "verify gateway port ${GATEWAY_PORT} (unsigned)" bash -lc "lsof -iTCP:${GATEWAY_PORT} -sTCP:LISTEN | head -n 5 || true"
|
||||
fi
|
||||
|
||||
# 4) Launch the installed app in the foreground so the menu bar extra appears.
|
||||
# LaunchServices can inherit a huge environment from this shell (secrets, prompt vars, etc.).
|
||||
# That can cause launchd spawn failures and is undesirable for a GUI app anyway.
|
||||
@@ -226,13 +251,6 @@ else
|
||||
fail "App exited immediately. Check ${LOG_PATH} or Console.app (User Reports)."
|
||||
fi
|
||||
|
||||
# When unsigned, ensure the gateway LaunchAgent targets the repo CLI (after the app launches).
|
||||
if [ "$NO_SIGN" -eq 1 ]; then
|
||||
run_step "install gateway launch agent (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon install --force --runtime node"
|
||||
run_step "restart gateway daemon (unsigned)" bash -lc "cd '${ROOT_DIR}' && node dist/entry.js daemon restart"
|
||||
if [[ "${GATEWAY_WAIT_SECONDS}" -gt 0 ]]; then
|
||||
run_step "wait for gateway (unsigned)" sleep "${GATEWAY_WAIT_SECONDS}"
|
||||
fi
|
||||
run_step "verify gateway port 18789 (unsigned)" bash -lc "lsof -iTCP:18789 -sTCP:LISTEN | head -n 5 || true"
|
||||
run_step "show gateway launch agent args (unsigned)" bash -lc "/usr/bin/plutil -p '${HOME}/Library/LaunchAgents/com.clawdbot.gateway.plist' | head -n 40 || true"
|
||||
fi
|
||||
@@ -9,26 +9,65 @@ import type { EmbeddedContextFile } from "./types.js";
|
||||
|
||||
type ContentBlockWithSignature = {
|
||||
thought_signature?: unknown;
|
||||
thoughtSignature?: unknown;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
type ThoughtSignatureSanitizeOptions = {
|
||||
allowBase64Only?: boolean;
|
||||
includeCamelCase?: boolean;
|
||||
};
|
||||
|
||||
function isBase64Signature(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return false;
|
||||
const compact = trimmed.replace(/\s+/g, "");
|
||||
if (!/^[A-Za-z0-9+/=_-]+$/.test(compact)) return false;
|
||||
const isUrl = compact.includes("-") || compact.includes("_");
|
||||
try {
|
||||
const buf = Buffer.from(compact, isUrl ? "base64url" : "base64");
|
||||
if (buf.length === 0) return false;
|
||||
const encoded = buf.toString(isUrl ? "base64url" : "base64");
|
||||
const normalize = (input: string) => input.replace(/=+$/g, "");
|
||||
return normalize(encoded) === normalize(compact);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips Claude-style thought_signature fields from content blocks.
|
||||
*
|
||||
* Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids
|
||||
* like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures.
|
||||
*/
|
||||
export function stripThoughtSignatures<T>(content: T): T {
|
||||
export function stripThoughtSignatures<T>(
|
||||
content: T,
|
||||
options?: ThoughtSignatureSanitizeOptions,
|
||||
): T {
|
||||
if (!Array.isArray(content)) return content;
|
||||
const allowBase64Only = options?.allowBase64Only ?? false;
|
||||
const includeCamelCase = options?.includeCamelCase ?? false;
|
||||
const shouldStripSignature = (value: unknown): boolean => {
|
||||
if (!allowBase64Only) {
|
||||
return typeof value === "string" && value.startsWith("msg_");
|
||||
}
|
||||
return typeof value !== "string" || !isBase64Signature(value);
|
||||
};
|
||||
return content.map((block) => {
|
||||
if (!block || typeof block !== "object") return block;
|
||||
const rec = block as ContentBlockWithSignature;
|
||||
const signature = rec.thought_signature;
|
||||
if (typeof signature !== "string" || !signature.startsWith("msg_")) {
|
||||
const stripSnake = shouldStripSignature(rec.thought_signature);
|
||||
const stripCamel = includeCamelCase
|
||||
? shouldStripSignature(rec.thoughtSignature)
|
||||
: false;
|
||||
if (!stripSnake && !stripCamel) {
|
||||
return block;
|
||||
}
|
||||
const { thought_signature: _signature, ...rest } = rec;
|
||||
return rest;
|
||||
const next = { ...rec };
|
||||
if (stripSnake) delete next.thought_signature;
|
||||
if (stripCamel) delete next.thoughtSignature;
|
||||
return next;
|
||||
}) as T;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ export { sanitizeGoogleTurnOrdering };
|
||||
type GeminiToolCallBlock = {
|
||||
type?: unknown;
|
||||
thought_signature?: unknown;
|
||||
thoughtSignature?: unknown;
|
||||
id?: unknown;
|
||||
toolCallId?: unknown;
|
||||
name?: unknown;
|
||||
@@ -118,7 +119,8 @@ export function downgradeGeminiHistory(messages: AgentMessage[]): AgentMessage[]
|
||||
const blockRecord = block as GeminiToolCallBlock;
|
||||
const type = blockRecord.type;
|
||||
if (type === "toolCall" || type === "functionCall" || type === "toolUse") {
|
||||
const hasSignature = Boolean(blockRecord.thought_signature);
|
||||
const signature = blockRecord.thought_signature ?? blockRecord.thoughtSignature;
|
||||
const hasSignature = Boolean(signature);
|
||||
if (!hasSignature) {
|
||||
const id =
|
||||
typeof blockRecord.id === "string"
|
||||
|
||||
@@ -34,6 +34,10 @@ export async function sanitizeSessionMessagesImages(
|
||||
sanitizeToolCallIds?: boolean;
|
||||
enforceToolCallLast?: boolean;
|
||||
preserveSignatures?: boolean;
|
||||
sanitizeThoughtSignatures?: {
|
||||
allowBase64Only?: boolean;
|
||||
includeCamelCase?: boolean;
|
||||
};
|
||||
},
|
||||
): Promise<AgentMessage[]> {
|
||||
// We sanitize historical session messages because Anthropic can reject a request
|
||||
@@ -82,7 +86,7 @@ export async function sanitizeSessionMessagesImages(
|
||||
if (Array.isArray(content)) {
|
||||
const strippedContent = options?.preserveSignatures
|
||||
? content // Keep signatures for Antigravity Claude
|
||||
: stripThoughtSignatures(content); // Strip for Gemini
|
||||
: stripThoughtSignatures(content, options?.sanitizeThoughtSignatures); // Strip for Gemini
|
||||
|
||||
const filteredContent = strippedContent.filter((block) => {
|
||||
if (!block || typeof block !== "object") return true;
|
||||
|
||||
@@ -145,6 +145,65 @@ describe("sanitizeSessionHistory (google thinking)", () => {
|
||||
expect(assistant.content?.[1]?.text).toBe("internal note");
|
||||
});
|
||||
|
||||
it("strips non-base64 thought signatures for OpenRouter Gemini", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
{
|
||||
role: "user",
|
||||
content: "hi",
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "hello", thought_signature: "msg_abc123" },
|
||||
{ type: "thinking", thinking: "ok", thought_signature: "c2ln" },
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_1",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/foo" },
|
||||
thoughtSignature: "{\"id\":1}",
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/bar" },
|
||||
thoughtSignature: "c2ln",
|
||||
},
|
||||
],
|
||||
},
|
||||
] satisfies AgentMessage[];
|
||||
|
||||
const out = await sanitizeSessionHistory({
|
||||
messages: input,
|
||||
modelApi: "openrouter",
|
||||
provider: "openrouter",
|
||||
modelId: "google/gemini-1.5-pro",
|
||||
sessionManager,
|
||||
sessionId: "session:openrouter-gemini",
|
||||
});
|
||||
|
||||
const assistant = out.find((msg) => (msg as { role?: string }).role === "assistant") as {
|
||||
content?: Array<{ type?: string; thought_signature?: string; thoughtSignature?: string }>;
|
||||
};
|
||||
expect(assistant.content).toEqual([
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "ok" },
|
||||
{
|
||||
type: "text",
|
||||
text: "[Tool Call: read (ID: call_1)]\nArguments: {\n \"path\": \"/tmp/foo\"\n}",
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_2",
|
||||
name: "read",
|
||||
arguments: { path: "/tmp/bar" },
|
||||
thoughtSignature: "c2ln",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("downgrades only unsigned thinking blocks when mixed with signed ones", async () => {
|
||||
const sessionManager = SessionManager.inMemory();
|
||||
const input = [
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { AgentMessage } from "@mariozechner/pi-agent-core";
|
||||
import type { SessionManager } from "@mariozechner/pi-coding-agent";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as helpers from "./pi-embedded-helpers.js";
|
||||
import { sanitizeSessionHistory } from "./pi-embedded-runner/google.js";
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("./pi-embedded-helpers.js", async () => {
|
||||
const actual = await vi.importActual("./pi-embedded-helpers.js");
|
||||
return {
|
||||
...actual,
|
||||
isGoogleModelApi: vi.fn(),
|
||||
downgradeGeminiHistory: vi.fn(),
|
||||
sanitizeSessionMessagesImages: vi
|
||||
.fn()
|
||||
.mockImplementation(async (msgs) => msgs),
|
||||
};
|
||||
});
|
||||
|
||||
// We don't mock session-transcript-repair.js as it is a pure function and complicates mocking.
|
||||
// We rely on the real implementation which should pass through our simple messages.
|
||||
|
||||
describe("sanitizeSessionHistory", () => {
|
||||
const mockSessionManager = {
|
||||
getEntries: vi.fn().mockReturnValue([]),
|
||||
appendCustomEntry: vi.fn(),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
const mockMessages: AgentMessage[] = [{ role: "user", content: "hello" }];
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks();
|
||||
vi.mocked(helpers.sanitizeSessionMessagesImages).mockImplementation(
|
||||
async (msgs) => msgs,
|
||||
);
|
||||
// Default mock implementation
|
||||
vi.mocked(helpers.downgradeGeminiHistory).mockImplementation((msgs) => {
|
||||
if (!msgs) return [];
|
||||
return [...msgs, { role: "system", content: "downgraded" }];
|
||||
});
|
||||
});
|
||||
|
||||
it("should downgrade history for Google models if provider is not google-antigravity", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "google-gemini",
|
||||
provider: "google-vertex",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
||||
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
||||
// Check if the result contains the downgraded message
|
||||
expect(result).toContainEqual({ role: "system", content: "downgraded" });
|
||||
});
|
||||
|
||||
it("should NOT downgrade history for google-antigravity provider", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
||||
|
||||
const result = await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "google-gemini",
|
||||
provider: "google-antigravity",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
||||
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
||||
// Result should not contain the downgraded message
|
||||
expect(result).not.toContainEqual({
|
||||
role: "system",
|
||||
content: "downgraded",
|
||||
});
|
||||
});
|
||||
|
||||
it("should NOT downgrade history for non-Google models", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(false);
|
||||
|
||||
const _result = await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("anthropic-messages");
|
||||
expect(helpers.downgradeGeminiHistory).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should downgrade history if provider is undefined but model is Google", async () => {
|
||||
vi.mocked(helpers.isGoogleModelApi).mockReturnValue(true);
|
||||
|
||||
const _result = await sanitizeSessionHistory({
|
||||
messages: mockMessages,
|
||||
modelApi: "google-gemini",
|
||||
provider: undefined,
|
||||
sessionManager: mockSessionManager,
|
||||
sessionId: "test-session",
|
||||
});
|
||||
|
||||
expect(helpers.isGoogleModelApi).toHaveBeenCalledWith("google-gemini");
|
||||
expect(helpers.downgradeGeminiHistory).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -308,6 +308,7 @@ export async function compactEmbeddedPiSession(params: {
|
||||
messages: session.messages,
|
||||
modelApi: model.api,
|
||||
modelId,
|
||||
provider,
|
||||
sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
@@ -185,22 +185,34 @@ export async function sanitizeSessionHistory(params: {
|
||||
messages: AgentMessage[];
|
||||
modelApi?: string | null;
|
||||
modelId?: string;
|
||||
provider?: string;
|
||||
sessionManager: SessionManager;
|
||||
sessionId: string;
|
||||
}): Promise<AgentMessage[]> {
|
||||
const isAntigravityClaudeModel = isAntigravityClaude(params.modelApi, params.modelId);
|
||||
const provider = (params.provider ?? "").toLowerCase();
|
||||
const modelId = (params.modelId ?? "").toLowerCase();
|
||||
const isOpenRouterGemini =
|
||||
(provider === "openrouter" || provider === "opencode") && modelId.includes("gemini");
|
||||
const isGeminiLike = isGoogleModelApi(params.modelApi) || isOpenRouterGemini;
|
||||
const sanitizedImages = await sanitizeSessionMessagesImages(params.messages, "session:history", {
|
||||
sanitizeToolCallIds: shouldSanitizeToolCallIds(params.modelApi),
|
||||
enforceToolCallLast: params.modelApi === "anthropic-messages",
|
||||
preserveSignatures: params.modelApi === "google-antigravity" && isAntigravityClaudeModel,
|
||||
sanitizeThoughtSignatures: isOpenRouterGemini
|
||||
? { allowBase64Only: true, includeCamelCase: true }
|
||||
: undefined,
|
||||
});
|
||||
const repairedTools = sanitizeToolUseResultPairing(sanitizedImages);
|
||||
const shouldDowngradeGemini = isGoogleModelApi(params.modelApi) && !isAntigravityClaudeModel;
|
||||
const isAntigravityProvider =
|
||||
provider === "google-antigravity" || params.modelApi === "google-antigravity";
|
||||
const shouldDowngradeThinking = isGeminiLike && !isAntigravityClaudeModel;
|
||||
// Gemini rejects unsigned thinking blocks; downgrade them before send to avoid INVALID_ARGUMENT.
|
||||
const downgradedThinking = shouldDowngradeGemini
|
||||
const downgradedThinking = shouldDowngradeThinking
|
||||
? downgradeGeminiThinkingBlocks(repairedTools)
|
||||
: repairedTools;
|
||||
const downgraded = shouldDowngradeGemini
|
||||
const shouldDowngradeHistory = shouldDowngradeThinking && !isAntigravityProvider;
|
||||
const downgraded = shouldDowngradeHistory
|
||||
? downgradeGeminiHistory(downgradedThinking)
|
||||
: downgradedThinking;
|
||||
|
||||
|
||||
@@ -323,6 +323,7 @@ export async function runEmbeddedAttempt(
|
||||
messages: activeSession.messages,
|
||||
modelApi: params.model.api,
|
||||
modelId: params.modelId,
|
||||
provider: params.provider,
|
||||
sessionManager,
|
||||
sessionId: params.sessionId,
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ export function handleAgentStart(ctx: EmbeddedPiSubscribeContext) {
|
||||
startedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
ctx.params.onAgentEvent?.({
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "lifecycle",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
@@ -24,7 +24,7 @@ export function handleAutoCompactionStart(ctx: EmbeddedPiSubscribeContext) {
|
||||
ctx.state.compactionInFlight = true;
|
||||
ctx.ensureCompactionPromise();
|
||||
ctx.log.debug(`embedded run compaction start: runId=${ctx.params.runId}`);
|
||||
ctx.params.onAgentEvent?.({
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "start" },
|
||||
});
|
||||
@@ -43,7 +43,7 @@ export function handleAutoCompactionEnd(
|
||||
} else {
|
||||
ctx.maybeResolveCompactionWait();
|
||||
}
|
||||
ctx.params.onAgentEvent?.({
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: { phase: "end", willRetry },
|
||||
});
|
||||
@@ -59,7 +59,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext) {
|
||||
endedAt: Date.now(),
|
||||
},
|
||||
});
|
||||
ctx.params.onAgentEvent?.({
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "lifecycle",
|
||||
data: { phase: "end" },
|
||||
});
|
||||
|
||||
@@ -118,7 +118,7 @@ export function handleMessageUpdate(
|
||||
mediaUrls: mediaUrls?.length ? mediaUrls : undefined,
|
||||
},
|
||||
});
|
||||
ctx.params.onAgentEvent?.({
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "assistant",
|
||||
data: {
|
||||
text: cleanedText,
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "./pi-embedded-subscribe.tools.js";
|
||||
import { inferToolMetaFromArgs } from "./pi-embedded-utils.js";
|
||||
|
||||
export function handleToolExecutionStart(
|
||||
export async function handleToolExecutionStart(
|
||||
ctx: EmbeddedPiSubscribeContext,
|
||||
evt: AgentEvent & { toolName: string; toolCallId: string; args: unknown },
|
||||
) {
|
||||
@@ -53,7 +53,8 @@ export function handleToolExecutionStart(
|
||||
args: args as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
ctx.params.onAgentEvent?.({
|
||||
// Await onAgentEvent to ensure typing indicator starts before tool summaries are emitted.
|
||||
await ctx.params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: { phase: "start", name: toolName, toolCallId },
|
||||
});
|
||||
@@ -108,7 +109,7 @@ export function handleToolExecutionUpdate(
|
||||
partialResult: sanitized,
|
||||
},
|
||||
});
|
||||
ctx.params.onAgentEvent?.({
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "update",
|
||||
@@ -170,7 +171,7 @@ export function handleToolExecutionEnd(
|
||||
result: sanitizedResult,
|
||||
},
|
||||
});
|
||||
ctx.params.onAgentEvent?.({
|
||||
void ctx.params.onAgentEvent?.({
|
||||
stream: "tool",
|
||||
data: {
|
||||
phase: "result",
|
||||
|
||||
@@ -32,7 +32,11 @@ export function createEmbeddedPiSessionEventHandler(ctx: EmbeddedPiSubscribeCont
|
||||
handleMessageEnd(ctx, evt as never);
|
||||
return;
|
||||
case "tool_execution_start":
|
||||
handleToolExecutionStart(ctx, evt as never);
|
||||
// Async handler - awaits typing indicator before emitting tool summaries.
|
||||
// Catch rejections to avoid unhandled promise rejection crashes.
|
||||
handleToolExecutionStart(ctx, evt as never).catch((err) => {
|
||||
ctx.log.debug(`tool_execution_start handler failed: ${String(err)}`);
|
||||
});
|
||||
return;
|
||||
case "tool_execution_update":
|
||||
handleToolExecutionUpdate(ctx, evt as never);
|
||||
|
||||
+8
-2
@@ -13,7 +13,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
|
||||
] as const;
|
||||
|
||||
it("includes canvas action metadata in tool summaries", () => {
|
||||
it("includes canvas action metadata in tool summaries", async () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
@@ -38,6 +38,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
args: { action: "a2ui_push", jsonlPath: "/tmp/a2ui.jsonl" },
|
||||
});
|
||||
|
||||
// Wait for async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
const payload = onToolResult.mock.calls[0][0];
|
||||
expect(payload.text).toContain("🖼️");
|
||||
@@ -72,7 +75,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
expect(onToolResult).not.toHaveBeenCalled();
|
||||
});
|
||||
it("emits tool summaries when shouldEmitToolResult overrides verbose", () => {
|
||||
it("emits tool summaries when shouldEmitToolResult overrides verbose", async () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
@@ -98,6 +101,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
args: { path: "/tmp/c.txt" },
|
||||
});
|
||||
|
||||
// Wait for async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
+8
-2
@@ -14,7 +14,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
{ tag: "antthinking", open: "<antthinking>", close: "</antthinking>" },
|
||||
] as const;
|
||||
|
||||
it("suppresses message_end block replies when the message tool already sent", () => {
|
||||
it("suppresses message_end block replies when the message tool already sent", async () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
@@ -41,6 +41,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
args: { action: "send", to: "+1555", message: messageText },
|
||||
});
|
||||
|
||||
// Wait for async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
handler?.({
|
||||
type: "tool_execution_end",
|
||||
toolName: "message",
|
||||
@@ -58,7 +61,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
it("does not suppress message_end replies when message tool reports error", () => {
|
||||
it("does not suppress message_end replies when message tool reports error", async () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
@@ -85,6 +88,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
args: { action: "send", to: "+1555", message: messageText },
|
||||
});
|
||||
|
||||
// Wait for async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
handler?.({
|
||||
type: "tool_execution_end",
|
||||
toolName: "message",
|
||||
|
||||
+8
-2
@@ -54,7 +54,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
await waitPromise;
|
||||
expect(resolved).toBe(true);
|
||||
});
|
||||
it("emits tool summaries at tool start when verbose is on", () => {
|
||||
it("emits tool summaries at tool start when verbose is on", async () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
@@ -79,6 +79,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
args: { path: "/tmp/a.txt" },
|
||||
});
|
||||
|
||||
// Wait for async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
const payload = onToolResult.mock.calls[0][0];
|
||||
expect(payload.text).toContain("/tmp/a.txt");
|
||||
@@ -93,7 +96,7 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
it("includes browser action metadata in tool summaries", () => {
|
||||
it("includes browser action metadata in tool summaries", async () => {
|
||||
let handler: ((evt: unknown) => void) | undefined;
|
||||
const session: StubSession = {
|
||||
subscribe: (fn) => {
|
||||
@@ -118,6 +121,9 @@ describe("subscribeEmbeddedPiSession", () => {
|
||||
args: { action: "snapshot", targetUrl: "https://example.com" },
|
||||
});
|
||||
|
||||
// Wait for async handler to complete
|
||||
await Promise.resolve();
|
||||
|
||||
expect(onToolResult).toHaveBeenCalledTimes(1);
|
||||
const payload = onToolResult.mock.calls[0][0];
|
||||
expect(payload.text).toContain("🌐");
|
||||
|
||||
@@ -22,7 +22,10 @@ export type SubscribeEmbeddedPiSessionParams = {
|
||||
blockReplyChunking?: BlockReplyChunking;
|
||||
onPartialReply?: (payload: { text?: string; mediaUrls?: string[] }) => void | Promise<void>;
|
||||
onAssistantMessageStart?: () => void | Promise<void>;
|
||||
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
|
||||
onAgentEvent?: (evt: {
|
||||
stream: string;
|
||||
data: Record<string, unknown>;
|
||||
}) => void | Promise<void>;
|
||||
enforceFinalTag?: boolean;
|
||||
};
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import type { VerboseLevel } from "../thinking.js";
|
||||
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { GetReplyOptions, ReplyPayload } from "../types.js";
|
||||
import { buildThreadingToolContext, resolveEnforceFinalTag } from "./agent-runner-utils.js";
|
||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import type { FollowupRun } from "./queue.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import { applyReplyTagsToPayload, isRenderablePayload } from "./reply-payloads.js";
|
||||
@@ -40,6 +40,8 @@ export type AgentRunLoopResult =
|
||||
fallbackModel?: string;
|
||||
didLogHeartbeatStrip: boolean;
|
||||
autoCompactionCompleted: boolean;
|
||||
/** Payload keys sent directly (not via pipeline) during tool flush. */
|
||||
directlySentBlockKeys?: Set<string>;
|
||||
}
|
||||
| { kind: "final"; payload: ReplyPayload };
|
||||
|
||||
@@ -70,6 +72,8 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}): Promise<AgentRunLoopResult> {
|
||||
let didLogHeartbeatStrip = false;
|
||||
let autoCompactionCompleted = false;
|
||||
// Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates.
|
||||
const directlySentBlockKeys = new Set<string>();
|
||||
|
||||
const runId = crypto.randomUUID();
|
||||
if (params.sessionKey) {
|
||||
@@ -244,12 +248,13 @@ export async function runAgentTurnWithFallback(params: {
|
||||
});
|
||||
}
|
||||
: undefined,
|
||||
onAgentEvent: (evt) => {
|
||||
// Trigger typing when tools start executing
|
||||
onAgentEvent: async (evt) => {
|
||||
// Trigger typing when tools start executing.
|
||||
// Must await to ensure typing indicator starts before tool summaries are emitted.
|
||||
if (evt.stream === "tool") {
|
||||
const phase = typeof evt.data.phase === "string" ? evt.data.phase : "";
|
||||
if (phase === "start" || phase === "update") {
|
||||
void params.typingSignals.signalToolStart();
|
||||
await params.typingSignals.signalToolStart();
|
||||
}
|
||||
}
|
||||
// Track auto-compaction completion
|
||||
@@ -261,57 +266,67 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
}
|
||||
},
|
||||
onBlockReply:
|
||||
params.blockStreamingEnabled && params.opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (skip && !hasPayloadMedia) return;
|
||||
const taggedPayload = applyReplyTagsToPayload(
|
||||
{
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
mediaUrl: payload.mediaUrls?.[0],
|
||||
},
|
||||
params.sessionCtx.MessageSid,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const cleaned = parsed.text || undefined;
|
||||
const hasRenderableMedia =
|
||||
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (
|
||||
!cleaned &&
|
||||
!hasRenderableMedia &&
|
||||
!payload.audioAsVoice &&
|
||||
!parsed.audioAsVoice
|
||||
)
|
||||
return;
|
||||
if (parsed.isSilent && !hasRenderableMedia) return;
|
||||
// Always pass onBlockReply so flushBlockReplyBuffer works before tool execution,
|
||||
// even when regular block streaming is disabled. The handler sends directly
|
||||
// via opts.onBlockReply when the pipeline isn't available.
|
||||
onBlockReply: params.opts?.onBlockReply
|
||||
? async (payload) => {
|
||||
const { text, skip } = normalizeStreamingText(payload);
|
||||
const hasPayloadMedia = (payload.mediaUrls?.length ?? 0) > 0;
|
||||
if (skip && !hasPayloadMedia) return;
|
||||
const taggedPayload = applyReplyTagsToPayload(
|
||||
{
|
||||
text,
|
||||
mediaUrls: payload.mediaUrls,
|
||||
mediaUrl: payload.mediaUrls?.[0],
|
||||
},
|
||||
params.sessionCtx.MessageSid,
|
||||
);
|
||||
// Let through payloads with audioAsVoice flag even if empty (need to track it)
|
||||
if (!isRenderablePayload(taggedPayload) && !payload.audioAsVoice) return;
|
||||
const parsed = parseReplyDirectives(taggedPayload.text ?? "", {
|
||||
currentMessageId: params.sessionCtx.MessageSid,
|
||||
silentToken: SILENT_REPLY_TOKEN,
|
||||
});
|
||||
const cleaned = parsed.text || undefined;
|
||||
const hasRenderableMedia =
|
||||
Boolean(taggedPayload.mediaUrl) || (taggedPayload.mediaUrls?.length ?? 0) > 0;
|
||||
// Skip empty payloads unless they have audioAsVoice flag (need to track it)
|
||||
if (
|
||||
!cleaned &&
|
||||
!hasRenderableMedia &&
|
||||
!payload.audioAsVoice &&
|
||||
!parsed.audioAsVoice
|
||||
)
|
||||
return;
|
||||
if (parsed.isSilent && !hasRenderableMedia) return;
|
||||
|
||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
const blockPayload: ReplyPayload = params.applyReplyToMode({
|
||||
...taggedPayload,
|
||||
text: cleaned,
|
||||
audioAsVoice: Boolean(parsed.audioAsVoice || payload.audioAsVoice),
|
||||
replyToId: taggedPayload.replyToId ?? parsed.replyToId,
|
||||
replyToTag: taggedPayload.replyToTag || parsed.replyToTag,
|
||||
replyToCurrent: taggedPayload.replyToCurrent || parsed.replyToCurrent,
|
||||
});
|
||||
|
||||
void params.typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
void params.typingSignals
|
||||
.signalTextDelta(cleaned ?? taggedPayload.text)
|
||||
.catch((err) => {
|
||||
logVerbose(`block reply typing signal failed: ${String(err)}`);
|
||||
});
|
||||
|
||||
params.blockReplyPipeline?.enqueue(blockPayload);
|
||||
// Use pipeline if available (block streaming enabled), otherwise send directly
|
||||
if (params.blockStreamingEnabled && params.blockReplyPipeline) {
|
||||
params.blockReplyPipeline.enqueue(blockPayload);
|
||||
} else {
|
||||
// Send directly when flushing before tool execution (no streaming).
|
||||
// Track sent key to avoid duplicate in final payloads.
|
||||
directlySentBlockKeys.add(createBlockReplyPayloadKey(blockPayload));
|
||||
await params.opts?.onBlockReply?.(blockPayload);
|
||||
}
|
||||
: undefined,
|
||||
}
|
||||
: undefined,
|
||||
onBlockReplyFlush:
|
||||
params.blockStreamingEnabled && blockReplyPipeline
|
||||
? async () => {
|
||||
@@ -447,5 +462,6 @@ export async function runAgentTurnWithFallback(params: {
|
||||
fallbackModel,
|
||||
didLogHeartbeatStrip,
|
||||
autoCompactionCompleted,
|
||||
directlySentBlockKeys: directlySentBlockKeys.size > 0 ? directlySentBlockKeys : undefined,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import type { OriginatingChannelType } from "../templating.js";
|
||||
import { SILENT_REPLY_TOKEN } from "../tokens.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import { formatBunFetchSocketError, isBunFetchSocketError } from "./agent-runner-utils.js";
|
||||
import type { BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { createBlockReplyPayloadKey, type BlockReplyPipeline } from "./block-reply-pipeline.js";
|
||||
import { parseReplyDirectives } from "./reply-directives.js";
|
||||
import {
|
||||
applyReplyThreading,
|
||||
@@ -20,6 +20,8 @@ export function buildReplyPayloads(params: {
|
||||
didLogHeartbeatStrip: boolean;
|
||||
blockStreamingEnabled: boolean;
|
||||
blockReplyPipeline: BlockReplyPipeline | null;
|
||||
/** Payload keys sent directly (not via pipeline) during tool flush. */
|
||||
directlySentBlockKeys?: Set<string>;
|
||||
replyToMode: ReplyToMode;
|
||||
replyToChannel?: OriginatingChannelType;
|
||||
currentMessageId?: string;
|
||||
@@ -98,11 +100,16 @@ export function buildReplyPayloads(params: {
|
||||
payloads: replyTaggedPayloads,
|
||||
sentTexts: messagingToolSentTexts,
|
||||
});
|
||||
// Filter out payloads already sent via pipeline or directly during tool flush.
|
||||
const filteredPayloads = shouldDropFinalPayloads
|
||||
? []
|
||||
: params.blockStreamingEnabled
|
||||
? dedupedPayloads.filter((payload) => !params.blockReplyPipeline?.hasSentPayload(payload))
|
||||
: dedupedPayloads;
|
||||
: params.directlySentBlockKeys?.size
|
||||
? dedupedPayloads.filter(
|
||||
(payload) => !params.directlySentBlockKeys!.has(createBlockReplyPayloadKey(payload)),
|
||||
)
|
||||
: dedupedPayloads;
|
||||
const replyPayloads = suppressMessagingToolReplies ? [] : filteredPayloads;
|
||||
|
||||
return {
|
||||
|
||||
@@ -272,7 +272,7 @@ export async function runReplyAgent(params: {
|
||||
return finalizeWithFollowup(runOutcome.payload, queueKey, runFollowupTurn);
|
||||
}
|
||||
|
||||
const { runResult, fallbackProvider, fallbackModel } = runOutcome;
|
||||
const { runResult, fallbackProvider, fallbackModel, directlySentBlockKeys } = runOutcome;
|
||||
let { didLogHeartbeatStrip, autoCompactionCompleted } = runOutcome;
|
||||
|
||||
if (
|
||||
@@ -314,6 +314,7 @@ export async function runReplyAgent(params: {
|
||||
didLogHeartbeatStrip,
|
||||
blockStreamingEnabled,
|
||||
blockReplyPipeline,
|
||||
directlySentBlockKeys,
|
||||
replyToMode,
|
||||
replyToChannel,
|
||||
currentMessageId: sessionCtx.MessageSid,
|
||||
|
||||
@@ -11,6 +11,7 @@ const resolveGatewayPort = vi.fn(() => 18789);
|
||||
const discoverGatewayBeacons = vi.fn(async () => []);
|
||||
const pickPrimaryTailnetIPv4 = vi.fn(() => "100.64.0.10");
|
||||
const sshStop = vi.fn(async () => {});
|
||||
const resolveSshConfig = vi.fn(async () => null);
|
||||
const startSshPortForward = vi.fn(async () => ({
|
||||
parsedTarget: { user: "me", host: "studio", port: 22 },
|
||||
localPort: 18789,
|
||||
@@ -92,8 +93,16 @@ vi.mock("../infra/tailnet.js", () => ({
|
||||
pickPrimaryTailnetIPv4: () => pickPrimaryTailnetIPv4(),
|
||||
}));
|
||||
|
||||
vi.mock("../infra/ssh-tunnel.js", () => ({
|
||||
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
|
||||
vi.mock("../infra/ssh-tunnel.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../infra/ssh-tunnel.js")>();
|
||||
return {
|
||||
...actual,
|
||||
startSshPortForward: (opts: unknown) => startSshPortForward(opts),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../infra/ssh-config.js", () => ({
|
||||
resolveSshConfig: (opts: unknown) => resolveSshConfig(opts),
|
||||
}));
|
||||
|
||||
vi.mock("../gateway/probe.js", () => ({
|
||||
@@ -179,4 +188,122 @@ describe("gateway-status command", () => {
|
||||
const targets = parsed.targets as Array<Record<string, unknown>>;
|
||||
expect(targets.some((t) => t.kind === "sshTunnel")).toBe(true);
|
||||
});
|
||||
|
||||
it("infers SSH target from gateway.remote.url and ssh config", async () => {
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtime = {
|
||||
log: (msg: string) => runtimeLogs.push(msg),
|
||||
error: (_msg: string) => {},
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
const originalUser = process.env.USER;
|
||||
try {
|
||||
process.env.USER = "steipete";
|
||||
loadConfig.mockReturnValueOnce({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { url: "ws://peters-mac-studio-1.sheep-coho.ts.net:18789", token: "rtok" },
|
||||
},
|
||||
});
|
||||
resolveSshConfig.mockResolvedValueOnce({
|
||||
user: "steipete",
|
||||
host: "peters-mac-studio-1.sheep-coho.ts.net",
|
||||
port: 2222,
|
||||
identityFiles: ["/tmp/id_ed25519"],
|
||||
});
|
||||
|
||||
startSshPortForward.mockClear();
|
||||
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
||||
await gatewayStatusCommand(
|
||||
{ timeout: "1000", json: true },
|
||||
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
||||
);
|
||||
|
||||
expect(startSshPortForward).toHaveBeenCalledTimes(1);
|
||||
const call = startSshPortForward.mock.calls[0]?.[0] as {
|
||||
target: string;
|
||||
identity?: string;
|
||||
};
|
||||
expect(call.target).toBe("steipete@peters-mac-studio-1.sheep-coho.ts.net:2222");
|
||||
expect(call.identity).toBe("/tmp/id_ed25519");
|
||||
} finally {
|
||||
process.env.USER = originalUser;
|
||||
}
|
||||
});
|
||||
|
||||
it("falls back to host-only when USER is missing and ssh config is unavailable", async () => {
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtime = {
|
||||
log: (msg: string) => runtimeLogs.push(msg),
|
||||
error: (_msg: string) => {},
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
const originalUser = process.env.USER;
|
||||
try {
|
||||
process.env.USER = "";
|
||||
loadConfig.mockReturnValueOnce({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { url: "ws://studio.example:18789", token: "rtok" },
|
||||
},
|
||||
});
|
||||
resolveSshConfig.mockResolvedValueOnce(null);
|
||||
|
||||
startSshPortForward.mockClear();
|
||||
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
||||
await gatewayStatusCommand(
|
||||
{ timeout: "1000", json: true },
|
||||
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
||||
);
|
||||
|
||||
const call = startSshPortForward.mock.calls[0]?.[0] as {
|
||||
target: string;
|
||||
};
|
||||
expect(call.target).toBe("studio.example");
|
||||
} finally {
|
||||
process.env.USER = originalUser;
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps explicit SSH identity even when ssh config provides one", async () => {
|
||||
const runtimeLogs: string[] = [];
|
||||
const runtime = {
|
||||
log: (msg: string) => runtimeLogs.push(msg),
|
||||
error: (_msg: string) => {},
|
||||
exit: (code: number) => {
|
||||
throw new Error(`__exit__:${code}`);
|
||||
},
|
||||
};
|
||||
|
||||
loadConfig.mockReturnValueOnce({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { url: "ws://studio.example:18789", token: "rtok" },
|
||||
},
|
||||
});
|
||||
resolveSshConfig.mockResolvedValueOnce({
|
||||
user: "me",
|
||||
host: "studio.example",
|
||||
port: 22,
|
||||
identityFiles: ["/tmp/id_from_config"],
|
||||
});
|
||||
|
||||
startSshPortForward.mockClear();
|
||||
const { gatewayStatusCommand } = await import("./gateway-status.js");
|
||||
await gatewayStatusCommand(
|
||||
{ timeout: "1000", json: true, sshIdentity: "/tmp/explicit_id" },
|
||||
runtime as unknown as import("../runtime.js").RuntimeEnv,
|
||||
);
|
||||
|
||||
const call = startSshPortForward.mock.calls[0]?.[0] as {
|
||||
identity?: string;
|
||||
};
|
||||
expect(call.identity).toBe("/tmp/explicit_id");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,8 @@ import { withProgress } from "../cli/progress.js";
|
||||
import { loadConfig, resolveGatewayPort } from "../config/config.js";
|
||||
import { probeGateway } from "../gateway/probe.js";
|
||||
import { discoverGatewayBeacons } from "../infra/bonjour-discovery.js";
|
||||
import { startSshPortForward } from "../infra/ssh-tunnel.js";
|
||||
import { resolveSshConfig } from "../infra/ssh-config.js";
|
||||
import { parseSshTarget, startSshPortForward } from "../infra/ssh-tunnel.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { colorize, isRich, theme } from "../terminal/theme.js";
|
||||
|
||||
@@ -47,13 +48,25 @@ export async function gatewayStatusCommand(
|
||||
});
|
||||
|
||||
let sshTarget = sanitizeSshTarget(opts.ssh) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshTarget);
|
||||
const sshIdentity =
|
||||
let sshIdentity =
|
||||
sanitizeSshTarget(opts.sshIdentity) ?? sanitizeSshTarget(cfg.gateway?.remote?.sshIdentity);
|
||||
const remotePort = resolveGatewayPort(cfg);
|
||||
|
||||
let sshTunnelError: string | null = null;
|
||||
let sshTunnelStarted = false;
|
||||
|
||||
if (!sshTarget) {
|
||||
sshTarget = inferSshTargetFromRemoteUrl(cfg.gateway?.remote?.url);
|
||||
}
|
||||
|
||||
if (sshTarget) {
|
||||
const resolved = await resolveSshTarget(sshTarget, sshIdentity, overallTimeoutMs);
|
||||
if (resolved) {
|
||||
sshTarget = resolved.target;
|
||||
if (!sshIdentity && resolved.identity) sshIdentity = resolved.identity;
|
||||
}
|
||||
}
|
||||
|
||||
const { discovery, probed } = await withProgress(
|
||||
{
|
||||
label: "Inspecting gateways…",
|
||||
@@ -314,3 +327,53 @@ export async function gatewayStatusCommand(
|
||||
|
||||
if (!ok) runtime.exit(1);
|
||||
}
|
||||
|
||||
function inferSshTargetFromRemoteUrl(rawUrl?: string | null): string | null {
|
||||
if (typeof rawUrl !== "string") return null;
|
||||
const trimmed = rawUrl.trim();
|
||||
if (!trimmed) return null;
|
||||
let host: string | null = null;
|
||||
try {
|
||||
host = new URL(trimmed).hostname || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!host) return null;
|
||||
const user = process.env.USER?.trim() || "";
|
||||
return user ? `${user}@${host}` : host;
|
||||
}
|
||||
|
||||
function buildSshTarget(input: { user?: string; host?: string; port?: number }): string | null {
|
||||
const host = input.host?.trim() ?? "";
|
||||
if (!host) return null;
|
||||
const user = input.user?.trim() ?? "";
|
||||
const base = user ? `${user}@${host}` : host;
|
||||
const port = input.port ?? 22;
|
||||
if (port && port !== 22) return `${base}:${port}`;
|
||||
return base;
|
||||
}
|
||||
|
||||
async function resolveSshTarget(
|
||||
rawTarget: string,
|
||||
identity: string | null,
|
||||
overallTimeoutMs: number,
|
||||
): Promise<{ target: string; identity?: string } | null> {
|
||||
const parsed = parseSshTarget(rawTarget);
|
||||
if (!parsed) return null;
|
||||
const config = await resolveSshConfig(parsed, {
|
||||
identity: identity ?? undefined,
|
||||
timeoutMs: Math.min(800, overallTimeoutMs),
|
||||
});
|
||||
if (!config) return { target: rawTarget, identity: identity ?? undefined };
|
||||
const target = buildSshTarget({
|
||||
user: config.user ?? parsed.user,
|
||||
host: config.host ?? parsed.host,
|
||||
port: config.port ?? parsed.port,
|
||||
});
|
||||
if (!target) return { target: rawTarget, identity: identity ?? undefined };
|
||||
const identityFile =
|
||||
identity ??
|
||||
config.identityFiles.find((entry) => entry.trim().length > 0)?.trim() ??
|
||||
undefined;
|
||||
return { target, identity: identityFile };
|
||||
}
|
||||
@@ -119,10 +119,11 @@ export async function statusAllCommand(
|
||||
|
||||
const localFallbackAuth = resolveProbeAuth("local");
|
||||
const remoteAuth = resolveProbeAuth("remote");
|
||||
const probeAuth = isRemoteMode && !remoteUrlMissing ? remoteAuth : localFallbackAuth;
|
||||
|
||||
const gatewayProbe = await probeGateway({
|
||||
url: connection.url,
|
||||
auth: remoteUrlMissing ? localFallbackAuth : remoteAuth,
|
||||
auth: probeAuth,
|
||||
timeoutMs: Math.min(5000, opts?.timeoutMs ?? 10_000),
|
||||
}).catch(() => null);
|
||||
const gatewayReachable = gatewayProbe?.ok === true;
|
||||
@@ -291,7 +292,7 @@ export async function statusAllCommand(
|
||||
? `unreachable (${gatewayProbe.error})`
|
||||
: "unreachable";
|
||||
const gatewayAuth = gatewayReachable
|
||||
? ` · auth ${formatGatewayAuthUsed(remoteUrlMissing ? localFallbackAuth : remoteAuth)}`
|
||||
? ` · auth ${formatGatewayAuthUsed(probeAuth)}`
|
||||
: "";
|
||||
const gatewaySelfLine =
|
||||
gatewaySelf?.host || gatewaySelf?.ip || gatewaySelf?.version || gatewaySelf?.platform
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { ClawdbotSchema } from "./zod-schema.js";
|
||||
|
||||
describe("telegram custom commands schema", () => {
|
||||
it("normalizes custom commands", () => {
|
||||
const res = ClawdbotSchema.safeParse({
|
||||
channels: {
|
||||
telegram: {
|
||||
customCommands: [{ command: "/Backup", description: " Git backup " }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.success).toBe(true);
|
||||
if (!res.success) return;
|
||||
|
||||
expect(res.data.channels?.telegram?.customCommands).toEqual([
|
||||
{ command: "backup", description: "Git backup" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects custom commands with invalid names", () => {
|
||||
const res = ClawdbotSchema.safeParse({
|
||||
channels: {
|
||||
telegram: {
|
||||
customCommands: [{ command: "Bad-Name", description: "Override status" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.success).toBe(false);
|
||||
if (res.success) return;
|
||||
|
||||
expect(
|
||||
res.error.issues.some(
|
||||
(issue) =>
|
||||
issue.path.join(".") === "channels.telegram.customCommands.0.command" &&
|
||||
issue.message.includes("invalid"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -175,6 +175,7 @@ const FIELD_LABELS: Record<string, string> = {
|
||||
"talk.apiKey": "Talk API Key",
|
||||
"channels.whatsapp": "WhatsApp",
|
||||
"channels.telegram": "Telegram",
|
||||
"channels.telegram.customCommands": "Telegram Custom Commands",
|
||||
"channels.discord": "Discord",
|
||||
"channels.slack": "Slack",
|
||||
"channels.signal": "Signal",
|
||||
@@ -348,6 +349,8 @@ const FIELD_HELP: Record<string, string> = {
|
||||
"channels.slack.commands.native": 'Override native commands for Slack (bool or "auto").',
|
||||
"session.agentToAgent.maxPingPongTurns":
|
||||
"Max reply-back turns between requester and target (0–5).",
|
||||
"channels.telegram.customCommands":
|
||||
"Additional Telegram bot menu commands (merged with native; conflicts ignored).",
|
||||
"messages.ackReaction": "Emoji reaction used to acknowledge inbound messages (empty disables).",
|
||||
"messages.ackReactionScope":
|
||||
'When to send ack reactions ("group-mentions", "group-all", "direct", "all").',
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
export const TELEGRAM_COMMAND_NAME_PATTERN = /^[a-z0-9_]{1,32}$/;
|
||||
|
||||
export type TelegramCustomCommandInput = {
|
||||
command?: string | null;
|
||||
description?: string | null;
|
||||
};
|
||||
|
||||
export type TelegramCustomCommandIssue = {
|
||||
index: number;
|
||||
field: "command" | "description";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export function normalizeTelegramCommandName(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return "";
|
||||
const withoutSlash = trimmed.startsWith("/") ? trimmed.slice(1) : trimmed;
|
||||
return withoutSlash.trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function normalizeTelegramCommandDescription(value: string): string {
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function resolveTelegramCustomCommands(params: {
|
||||
commands?: TelegramCustomCommandInput[] | null;
|
||||
reservedCommands?: Set<string>;
|
||||
checkReserved?: boolean;
|
||||
checkDuplicates?: boolean;
|
||||
}): {
|
||||
commands: Array<{ command: string; description: string }>;
|
||||
issues: TelegramCustomCommandIssue[];
|
||||
} {
|
||||
const entries = Array.isArray(params.commands) ? params.commands : [];
|
||||
const reserved = params.reservedCommands ?? new Set<string>();
|
||||
const checkReserved = params.checkReserved !== false;
|
||||
const checkDuplicates = params.checkDuplicates !== false;
|
||||
const seen = new Set<string>();
|
||||
const resolved: Array<{ command: string; description: string }> = [];
|
||||
const issues: TelegramCustomCommandIssue[] = [];
|
||||
|
||||
for (let index = 0; index < entries.length; index += 1) {
|
||||
const entry = entries[index];
|
||||
const normalized = normalizeTelegramCommandName(String(entry?.command ?? ""));
|
||||
if (!normalized) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: "Telegram custom command is missing a command name.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (!TELEGRAM_COMMAND_NAME_PATTERN.test(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `Telegram custom command "/${normalized}" is invalid (use a-z, 0-9, underscore; max 32 chars).`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkReserved && reserved.has(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `Telegram custom command "/${normalized}" conflicts with a native command.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkDuplicates && seen.has(normalized)) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "command",
|
||||
message: `Telegram custom command "/${normalized}" is duplicated.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const description = normalizeTelegramCommandDescription(String(entry?.description ?? ""));
|
||||
if (!description) {
|
||||
issues.push({
|
||||
index,
|
||||
field: "description",
|
||||
message: `Telegram custom command "/${normalized}" is missing a description.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (checkDuplicates) {
|
||||
seen.add(normalized);
|
||||
}
|
||||
resolved.push({ command: normalized, description });
|
||||
}
|
||||
|
||||
return { commands: resolved, issues };
|
||||
}
|
||||
@@ -14,6 +14,14 @@ export type TelegramActionConfig = {
|
||||
deleteMessage?: boolean;
|
||||
};
|
||||
|
||||
/** Custom command definition for Telegram bot menu. */
|
||||
export type TelegramCustomCommand = {
|
||||
/** Command name (without leading /). */
|
||||
command: string;
|
||||
/** Description shown in Telegram command menu. */
|
||||
description: string;
|
||||
};
|
||||
|
||||
export type TelegramAccountConfig = {
|
||||
/** Optional display name for this account (used in CLI/UI lists). */
|
||||
name?: string;
|
||||
@@ -21,6 +29,8 @@ export type TelegramAccountConfig = {
|
||||
capabilities?: string[];
|
||||
/** Override native command registration for Telegram (bool or "auto"). */
|
||||
commands?: ProviderCommandsConfig;
|
||||
/** Custom commands to register in Telegram's command menu (merged with native). */
|
||||
customCommands?: TelegramCustomCommand[];
|
||||
/** Allow channel-initiated config writes (default: true). */
|
||||
configWrites?: boolean;
|
||||
/**
|
||||
|
||||
@@ -13,6 +13,11 @@ import {
|
||||
RetryConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "./zod-schema.core.js";
|
||||
import {
|
||||
normalizeTelegramCommandDescription,
|
||||
normalizeTelegramCommandName,
|
||||
resolveTelegramCustomCommands,
|
||||
} from "./telegram-custom-commands.js";
|
||||
|
||||
export const TelegramTopicSchema = z.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
@@ -31,11 +36,36 @@ export const TelegramGroupSchema = z.object({
|
||||
topics: z.record(z.string(), TelegramTopicSchema.optional()).optional(),
|
||||
});
|
||||
|
||||
const TelegramCustomCommandSchema = z.object({
|
||||
command: z.string().transform(normalizeTelegramCommandName),
|
||||
description: z.string().transform(normalizeTelegramCommandDescription),
|
||||
});
|
||||
|
||||
const validateTelegramCustomCommands = (
|
||||
value: { customCommands?: Array<{ command?: string; description?: string }> },
|
||||
ctx: z.RefinementCtx,
|
||||
) => {
|
||||
if (!value.customCommands || value.customCommands.length === 0) return;
|
||||
const { issues } = resolveTelegramCustomCommands({
|
||||
commands: value.customCommands,
|
||||
checkReserved: false,
|
||||
checkDuplicates: false,
|
||||
});
|
||||
for (const issue of issues) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["customCommands", issue.index, issue.field],
|
||||
message: issue.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
export const TelegramAccountSchemaBase = z.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
commands: ProviderCommandsSchema,
|
||||
customCommands: z.array(TelegramCustomCommandSchema).optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
botToken: z.string().optional(),
|
||||
@@ -80,6 +110,7 @@ export const TelegramAccountSchema = TelegramAccountSchemaBase.superRefine((valu
|
||||
message:
|
||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||
});
|
||||
validateTelegramCustomCommands(value, ctx);
|
||||
});
|
||||
|
||||
export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
||||
@@ -93,6 +124,7 @@ export const TelegramConfigSchema = TelegramAccountSchemaBase.extend({
|
||||
message:
|
||||
'channels.telegram.dmPolicy="open" requires channels.telegram.allowFrom to include "*"',
|
||||
});
|
||||
validateTelegramCustomCommands(value, ctx);
|
||||
});
|
||||
|
||||
export const DiscordDmSchema = z
|
||||
|
||||
@@ -93,6 +93,34 @@ describe("gateway auth", () => {
|
||||
expect(missingProxy.reason).toBe("tailscale_proxy_missing");
|
||||
});
|
||||
|
||||
it("treats local tailscale serve hostnames as direct", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
req: {
|
||||
socket: { remoteAddress: "127.0.0.1" },
|
||||
headers: { host: "gateway.tailnet-1234.ts.net:443" },
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(true);
|
||||
expect(res.method).toBe("none");
|
||||
});
|
||||
|
||||
it("does not treat tailscale clients as direct", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "none", allowTailscale: true },
|
||||
connectAuth: null,
|
||||
req: {
|
||||
socket: { remoteAddress: "100.64.0.42" },
|
||||
headers: { host: "gateway.tailnet-1234.ts.net" },
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.reason).toBe("tailscale_user_missing");
|
||||
});
|
||||
|
||||
it("allows tailscale identity to satisfy token mode auth", async () => {
|
||||
const res = await authorizeGatewayConnect({
|
||||
auth: { mode: "token", token: "secret", allowTailscale: true },
|
||||
|
||||
+43
-13
@@ -1,6 +1,9 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import type { IncomingMessage } from "node:http";
|
||||
import type { GatewayAuthConfig, GatewayTailscaleMode } from "../config/config.js";
|
||||
import type {
|
||||
GatewayAuthConfig,
|
||||
GatewayTailscaleMode,
|
||||
} from "../config/config.js";
|
||||
export type ResolvedGatewayAuthMode = "none" | "token" | "password";
|
||||
|
||||
export type ResolvedGatewayAuth = {
|
||||
@@ -42,20 +45,34 @@ function isLoopbackAddress(ip: string | undefined): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function getHostName(hostHeader?: string): string {
|
||||
const host = (hostHeader ?? "").trim().toLowerCase();
|
||||
if (!host) return "";
|
||||
if (host.startsWith("[")) {
|
||||
const end = host.indexOf("]");
|
||||
if (end !== -1) return host.slice(1, end);
|
||||
}
|
||||
const [name] = host.split(":");
|
||||
return name ?? "";
|
||||
}
|
||||
|
||||
function isLocalDirectRequest(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
const clientIp = req.socket?.remoteAddress ?? "";
|
||||
if (!isLoopbackAddress(clientIp)) return false;
|
||||
|
||||
const host = (req.headers.host ?? "").toLowerCase();
|
||||
const host = getHostName(req.headers?.host);
|
||||
const hostIsLocal =
|
||||
host.startsWith("localhost") || host.startsWith("127.0.0.1") || host.startsWith("[::1]");
|
||||
host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
const hostIsTailscaleServe = host.endsWith(".ts.net");
|
||||
|
||||
const hasForwarded = Boolean(
|
||||
req.headers["x-forwarded-for"] || req.headers["x-real-ip"] || req.headers["x-forwarded-host"],
|
||||
req.headers?.["x-forwarded-for"] ||
|
||||
req.headers?.["x-real-ip"] ||
|
||||
req.headers?.["x-forwarded-host"],
|
||||
);
|
||||
|
||||
return hostIsLocal && !hasForwarded;
|
||||
return (hostIsLocal || hostIsTailscaleServe) && !hasForwarded;
|
||||
}
|
||||
|
||||
function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
@@ -64,11 +81,17 @@ function getTailscaleUser(req?: IncomingMessage): TailscaleUser | null {
|
||||
if (typeof login !== "string" || !login.trim()) return null;
|
||||
const nameRaw = req.headers["tailscale-user-name"];
|
||||
const profilePic = req.headers["tailscale-user-profile-pic"];
|
||||
const name = typeof nameRaw === "string" && nameRaw.trim() ? nameRaw.trim() : login.trim();
|
||||
const name =
|
||||
typeof nameRaw === "string" && nameRaw.trim()
|
||||
? nameRaw.trim()
|
||||
: login.trim();
|
||||
return {
|
||||
login: login.trim(),
|
||||
name,
|
||||
profilePic: typeof profilePic === "string" && profilePic.trim() ? profilePic.trim() : undefined,
|
||||
profilePic:
|
||||
typeof profilePic === "string" && profilePic.trim()
|
||||
? profilePic.trim()
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -76,14 +99,17 @@ function hasTailscaleProxyHeaders(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
return Boolean(
|
||||
req.headers["x-forwarded-for"] &&
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
req.headers["x-forwarded-host"],
|
||||
req.headers["x-forwarded-proto"] &&
|
||||
req.headers["x-forwarded-host"],
|
||||
);
|
||||
}
|
||||
|
||||
function isTailscaleProxyRequest(req?: IncomingMessage): boolean {
|
||||
if (!req) return false;
|
||||
return isLoopbackAddress(req.socket?.remoteAddress) && hasTailscaleProxyHeaders(req);
|
||||
return (
|
||||
isLoopbackAddress(req.socket?.remoteAddress) &&
|
||||
hasTailscaleProxyHeaders(req)
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveGatewayAuth(params: {
|
||||
@@ -94,11 +120,13 @@ export function resolveGatewayAuth(params: {
|
||||
const authConfig = params.authConfig ?? {};
|
||||
const env = params.env ?? process.env;
|
||||
const token = authConfig.token ?? env.CLAWDBOT_GATEWAY_TOKEN ?? undefined;
|
||||
const password = authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
||||
const password =
|
||||
authConfig.password ?? env.CLAWDBOT_GATEWAY_PASSWORD ?? undefined;
|
||||
const mode: ResolvedGatewayAuth["mode"] =
|
||||
authConfig.mode ?? (password ? "password" : token ? "token" : "none");
|
||||
const allowTailscale =
|
||||
authConfig.allowTailscale ?? (params.tailscaleMode === "serve" && mode !== "password");
|
||||
authConfig.allowTailscale ??
|
||||
(params.tailscaleMode === "serve" && mode !== "password");
|
||||
return {
|
||||
mode,
|
||||
token,
|
||||
@@ -114,7 +142,9 @@ export function assertGatewayAuthConfigured(auth: ResolvedGatewayAuth): void {
|
||||
);
|
||||
}
|
||||
if (auth.mode === "password" && !auth.password) {
|
||||
throw new Error("gateway auth mode is password, but no password was configured");
|
||||
throw new Error(
|
||||
"gateway auth mode is password, but no password was configured",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,6 +18,10 @@ const RESET_KEYS = [
|
||||
"resetAt",
|
||||
"reset_time",
|
||||
"resetTime",
|
||||
"next_reset_at",
|
||||
"nextResetAt",
|
||||
"next_reset_time",
|
||||
"nextResetTime",
|
||||
"expires_at",
|
||||
"expiresAt",
|
||||
"expire_at",
|
||||
@@ -52,6 +56,14 @@ const USED_KEYS = [
|
||||
"usedQuota",
|
||||
"used_times",
|
||||
"usedTimes",
|
||||
"prompt_used",
|
||||
"promptUsed",
|
||||
"used_prompt",
|
||||
"usedPrompt",
|
||||
"prompts_used",
|
||||
"promptsUsed",
|
||||
"current_interval_usage_count",
|
||||
"currentIntervalUsageCount",
|
||||
"consumed",
|
||||
] as const;
|
||||
|
||||
@@ -65,6 +77,20 @@ const TOTAL_KEYS = [
|
||||
"totalQuota",
|
||||
"total_times",
|
||||
"totalTimes",
|
||||
"prompt_total",
|
||||
"promptTotal",
|
||||
"total_prompt",
|
||||
"totalPrompt",
|
||||
"prompt_limit",
|
||||
"promptLimit",
|
||||
"limit_prompt",
|
||||
"limitPrompt",
|
||||
"prompts_total",
|
||||
"promptsTotal",
|
||||
"total_prompts",
|
||||
"totalPrompts",
|
||||
"current_interval_total_count",
|
||||
"currentIntervalTotalCount",
|
||||
"limit",
|
||||
"quota",
|
||||
"quota_limit",
|
||||
@@ -87,6 +113,20 @@ const REMAINING_KEYS = [
|
||||
"remain_times",
|
||||
"remainingTimes",
|
||||
"remaining_times",
|
||||
"prompt_remain",
|
||||
"promptRemain",
|
||||
"remain_prompt",
|
||||
"remainPrompt",
|
||||
"prompt_remaining",
|
||||
"promptRemaining",
|
||||
"remaining_prompt",
|
||||
"remainingPrompt",
|
||||
"prompts_remaining",
|
||||
"promptsRemaining",
|
||||
"prompt_left",
|
||||
"promptLeft",
|
||||
"prompts_left",
|
||||
"promptsLeft",
|
||||
"left",
|
||||
] as const;
|
||||
|
||||
@@ -144,6 +184,62 @@ function parseEpoch(value: unknown): number | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function hasAny(record: Record<string, unknown>, keys: readonly string[]): boolean {
|
||||
return keys.some((key) => key in record);
|
||||
}
|
||||
|
||||
function scoreUsageRecord(record: Record<string, unknown>): number {
|
||||
let score = 0;
|
||||
if (hasAny(record, PERCENT_KEYS)) score += 4;
|
||||
if (hasAny(record, TOTAL_KEYS)) score += 3;
|
||||
if (hasAny(record, USED_KEYS) || hasAny(record, REMAINING_KEYS)) score += 2;
|
||||
if (hasAny(record, RESET_KEYS)) score += 1;
|
||||
if (hasAny(record, PLAN_KEYS)) score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
function collectUsageCandidates(root: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const MAX_SCAN_DEPTH = 4;
|
||||
const MAX_SCAN_NODES = 60;
|
||||
const queue: Array<{ value: unknown; depth: number }> = [{ value: root, depth: 0 }];
|
||||
const seen = new Set<object>();
|
||||
const candidates: Array<{ record: Record<string, unknown>; score: number; depth: number }> = [];
|
||||
let scanned = 0;
|
||||
|
||||
while (queue.length && scanned < MAX_SCAN_NODES) {
|
||||
const next = queue.shift();
|
||||
if (!next) break;
|
||||
scanned += 1;
|
||||
const { value, depth } = next;
|
||||
|
||||
if (isRecord(value)) {
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
const score = scoreUsageRecord(value);
|
||||
if (score > 0) candidates.push({ record: value, score, depth });
|
||||
if (depth < MAX_SCAN_DEPTH) {
|
||||
for (const nested of Object.values(value)) {
|
||||
if (isRecord(nested) || Array.isArray(nested)) {
|
||||
queue.push({ value: nested, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(value) && depth < MAX_SCAN_DEPTH) {
|
||||
for (const nested of value) {
|
||||
if (isRecord(nested) || Array.isArray(nested)) {
|
||||
queue.push({ value: nested, depth: depth + 1 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => b.score - a.score || a.depth - b.depth);
|
||||
return candidates.map((candidate) => candidate.record);
|
||||
}
|
||||
|
||||
function deriveWindowLabel(payload: Record<string, unknown>): string {
|
||||
const hours = pickNumber(payload, WINDOW_HOUR_KEYS);
|
||||
if (hours && Number.isFinite(hours)) return `${hours}h`;
|
||||
@@ -219,7 +315,20 @@ export async function fetchMinimaxUsage(
|
||||
}
|
||||
|
||||
const payload = isRecord(data.data) ? data.data : data;
|
||||
const usedPercent = deriveUsedPercent(payload);
|
||||
const candidates = collectUsageCandidates(payload);
|
||||
let usageRecord: Record<string, unknown> = payload;
|
||||
let usedPercent: number | null = null;
|
||||
for (const candidate of candidates) {
|
||||
const candidatePercent = deriveUsedPercent(candidate);
|
||||
if (candidatePercent !== null) {
|
||||
usageRecord = candidate;
|
||||
usedPercent = candidatePercent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (usedPercent === null) {
|
||||
usedPercent = deriveUsedPercent(payload);
|
||||
}
|
||||
if (usedPercent === null) {
|
||||
return {
|
||||
provider: "minimax",
|
||||
@@ -230,10 +339,13 @@ export async function fetchMinimaxUsage(
|
||||
}
|
||||
|
||||
const resetAt =
|
||||
parseEpoch(pickString(payload, RESET_KEYS)) ?? parseEpoch(pickNumber(payload, RESET_KEYS));
|
||||
parseEpoch(pickString(usageRecord, RESET_KEYS)) ??
|
||||
parseEpoch(pickNumber(usageRecord, RESET_KEYS)) ??
|
||||
parseEpoch(pickString(payload, RESET_KEYS)) ??
|
||||
parseEpoch(pickNumber(payload, RESET_KEYS));
|
||||
const windows: UsageWindow[] = [
|
||||
{
|
||||
label: deriveWindowLabel(payload),
|
||||
label: deriveWindowLabel(usageRecord),
|
||||
usedPercent,
|
||||
resetAt,
|
||||
},
|
||||
@@ -243,6 +355,6 @@ export async function fetchMinimaxUsage(
|
||||
provider: "minimax",
|
||||
displayName: PROVIDER_LABELS.minimax,
|
||||
windows,
|
||||
plan: pickString(payload, PLAN_KEYS),
|
||||
plan: pickString(usageRecord, PLAN_KEYS) ?? pickString(payload, PLAN_KEYS),
|
||||
};
|
||||
}
|
||||
@@ -137,6 +137,83 @@ describe("provider usage loading", () => {
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles nested MiniMax usage payloads", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
data: {
|
||||
plan_name: "Coding Plan",
|
||||
usage: {
|
||||
prompt_limit: 200,
|
||||
prompt_remain: 50,
|
||||
next_reset_time: "2026-01-07T05:00:00Z",
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
auth: [{ provider: "minimax", token: "token-1b" }],
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const minimax = summary.providers.find((p) => p.provider === "minimax");
|
||||
expect(minimax?.windows[0]?.usedPercent).toBe(75);
|
||||
expect(minimax?.plan).toBe("Coding Plan");
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("handles MiniMax model_remains usage payloads", async () => {
|
||||
const makeResponse = (status: number, body: unknown): Response => {
|
||||
const payload = typeof body === "string" ? body : JSON.stringify(body);
|
||||
const headers = typeof body === "string" ? undefined : { "Content-Type": "application/json" };
|
||||
return new Response(payload, { status, headers });
|
||||
};
|
||||
|
||||
const mockFetch = vi.fn<Parameters<typeof fetch>, ReturnType<typeof fetch>>(async (input) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.includes("api.minimax.io/v1/coding_plan/remains")) {
|
||||
return makeResponse(200, {
|
||||
base_resp: { status_code: 0, status_msg: "ok" },
|
||||
model_remains: [
|
||||
{
|
||||
start_time: 1736217600,
|
||||
end_time: 1736235600,
|
||||
remains_time: 600,
|
||||
current_interval_total_count: 120,
|
||||
current_interval_usage_count: 30,
|
||||
model_name: "MiniMax-M2.1",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
return makeResponse(404, "not found");
|
||||
});
|
||||
|
||||
const summary = await loadProviderUsageSummary({
|
||||
now: Date.UTC(2026, 0, 7, 0, 0, 0),
|
||||
auth: [{ provider: "minimax", token: "token-1b" }],
|
||||
fetch: mockFetch,
|
||||
});
|
||||
|
||||
const minimax = summary.providers.find((p) => p.provider === "minimax");
|
||||
expect(minimax?.windows[0]?.usedPercent).toBe(25);
|
||||
expect(mockFetch).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discovers Claude usage from token auth profiles", async () => {
|
||||
await withTempHome(
|
||||
async (tempHome) => {
|
||||
|
||||
+47
-21
@@ -37,6 +37,10 @@ function supportsSystemRun(commands?: string[]): boolean {
|
||||
return Array.isArray(commands) && commands.includes("system.run");
|
||||
}
|
||||
|
||||
function supportsSystemWhich(commands?: string[]): boolean {
|
||||
return Array.isArray(commands) && commands.includes("system.which");
|
||||
}
|
||||
|
||||
function upsertNode(record: {
|
||||
nodeId: string;
|
||||
displayName?: string;
|
||||
@@ -136,6 +140,27 @@ function buildBinProbeScript(bins: string[]): string {
|
||||
return `for b in ${escaped}; do if command -v "$b" >/dev/null 2>&1; then echo "$b"; fi; done`;
|
||||
}
|
||||
|
||||
function parseBinProbePayload(payloadJSON: string | null | undefined): string[] {
|
||||
if (!payloadJSON) return [];
|
||||
try {
|
||||
const parsed = JSON.parse(payloadJSON) as { stdout?: unknown; bins?: unknown };
|
||||
if (Array.isArray(parsed.bins)) {
|
||||
return parsed.bins
|
||||
.map((bin) => String(bin).trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (typeof parsed.stdout === "string") {
|
||||
return parsed.stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export async function refreshRemoteNodeBins(params: {
|
||||
nodeId: string;
|
||||
platform?: string;
|
||||
@@ -146,7 +171,9 @@ export async function refreshRemoteNodeBins(params: {
|
||||
}) {
|
||||
if (!remoteBridge) return;
|
||||
if (!isMacPlatform(params.platform, params.deviceFamily)) return;
|
||||
if (!supportsSystemRun(params.commands)) return;
|
||||
const canWhich = supportsSystemWhich(params.commands);
|
||||
const canRun = supportsSystemRun(params.commands);
|
||||
if (!canWhich && !canRun) return;
|
||||
|
||||
const workspaceDirs = listWorkspaceDirs(params.cfg);
|
||||
const requiredBins = new Set<string>();
|
||||
@@ -158,31 +185,30 @@ export async function refreshRemoteNodeBins(params: {
|
||||
}
|
||||
if (requiredBins.size === 0) return;
|
||||
|
||||
const script = buildBinProbeScript([...requiredBins]);
|
||||
const payload = {
|
||||
command: ["/bin/sh", "-lc", script],
|
||||
};
|
||||
try {
|
||||
const res = await remoteBridge.invoke({
|
||||
nodeId: params.nodeId,
|
||||
command: "system.run",
|
||||
paramsJSON: JSON.stringify(payload),
|
||||
timeoutMs: params.timeoutMs ?? 15_000,
|
||||
});
|
||||
const binsList = [...requiredBins];
|
||||
const res = await remoteBridge.invoke(
|
||||
canWhich
|
||||
? {
|
||||
nodeId: params.nodeId,
|
||||
command: "system.which",
|
||||
paramsJSON: JSON.stringify({ bins: binsList }),
|
||||
timeoutMs: params.timeoutMs ?? 15_000,
|
||||
}
|
||||
: {
|
||||
nodeId: params.nodeId,
|
||||
command: "system.run",
|
||||
paramsJSON: JSON.stringify({
|
||||
command: ["/bin/sh", "-lc", buildBinProbeScript(binsList)],
|
||||
}),
|
||||
timeoutMs: params.timeoutMs ?? 15_000,
|
||||
},
|
||||
);
|
||||
if (!res.ok) {
|
||||
log.warn(`remote bin probe failed (${params.nodeId}): ${res.error?.message ?? "unknown"}`);
|
||||
return;
|
||||
}
|
||||
const raw = typeof res.payloadJSON === "string" ? res.payloadJSON : "";
|
||||
const parsed =
|
||||
raw && raw.trim().length > 0
|
||||
? (JSON.parse(raw) as { stdout?: string })
|
||||
: ({ stdout: "" } as { stdout?: string });
|
||||
const stdout = typeof parsed.stdout === "string" ? parsed.stdout : "";
|
||||
const bins = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
const bins = parseBinProbePayload(res.payloadJSON);
|
||||
recordRemoteNodeBins(params.nodeId, bins);
|
||||
await updatePairedNodeMetadata(params.nodeId, { bins });
|
||||
bumpSkillsSnapshotVersion({ reason: "remote-node" });
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("node:child_process", () => {
|
||||
const spawn = vi.fn(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
|
||||
kill?: (signal?: string) => void;
|
||||
};
|
||||
const stdout = new EventEmitter() as EventEmitter & {
|
||||
setEncoding?: (enc: string) => void;
|
||||
};
|
||||
stdout.setEncoding = vi.fn();
|
||||
child.stdout = stdout;
|
||||
child.kill = vi.fn();
|
||||
process.nextTick(() => {
|
||||
stdout.emit(
|
||||
"data",
|
||||
[
|
||||
"user steipete",
|
||||
"hostname peters-mac-studio-1.sheep-coho.ts.net",
|
||||
"port 2222",
|
||||
"identityfile none",
|
||||
"identityfile /tmp/id_ed25519",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
child.emit("exit", 0);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
return { spawn };
|
||||
});
|
||||
|
||||
const spawnMock = vi.mocked(spawn);
|
||||
|
||||
describe("ssh-config", () => {
|
||||
it("parses ssh -G output", async () => {
|
||||
const { parseSshConfigOutput } = await import("./ssh-config.js");
|
||||
const parsed = parseSshConfigOutput(
|
||||
"user bob\nhostname example.com\nport 2222\nidentityfile none\nidentityfile /tmp/id\n",
|
||||
);
|
||||
expect(parsed.user).toBe("bob");
|
||||
expect(parsed.host).toBe("example.com");
|
||||
expect(parsed.port).toBe(2222);
|
||||
expect(parsed.identityFiles).toEqual(["/tmp/id"]);
|
||||
});
|
||||
|
||||
it("resolves ssh config via ssh -G", async () => {
|
||||
const { resolveSshConfig } = await import("./ssh-config.js");
|
||||
const config = await resolveSshConfig({ user: "me", host: "alias", port: 22 });
|
||||
expect(config?.user).toBe("steipete");
|
||||
expect(config?.host).toBe("peters-mac-studio-1.sheep-coho.ts.net");
|
||||
expect(config?.port).toBe(2222);
|
||||
expect(config?.identityFiles).toEqual(["/tmp/id_ed25519"]);
|
||||
});
|
||||
|
||||
it("returns null when ssh -G fails", async () => {
|
||||
spawnMock.mockImplementationOnce(() => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
stdout?: EventEmitter & { setEncoding?: (enc: string) => void };
|
||||
kill?: (signal?: string) => void;
|
||||
};
|
||||
const stdout = new EventEmitter() as EventEmitter & {
|
||||
setEncoding?: (enc: string) => void;
|
||||
};
|
||||
stdout.setEncoding = vi.fn();
|
||||
child.stdout = stdout;
|
||||
child.kill = vi.fn();
|
||||
process.nextTick(() => {
|
||||
child.emit("exit", 1);
|
||||
});
|
||||
return child;
|
||||
});
|
||||
|
||||
const { resolveSshConfig } = await import("./ssh-config.js");
|
||||
const config = await resolveSshConfig({ user: "me", host: "bad-host", port: 22 });
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,95 @@
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
import type { SshParsedTarget } from "./ssh-tunnel.js";
|
||||
|
||||
export type SshResolvedConfig = {
|
||||
user?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
identityFiles: string[];
|
||||
};
|
||||
|
||||
function parsePort(value: string | undefined): number | undefined {
|
||||
if (!value) return undefined;
|
||||
const parsed = Number.parseInt(value, 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) return undefined;
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseSshConfigOutput(output: string): SshResolvedConfig {
|
||||
const result: SshResolvedConfig = { identityFiles: [] };
|
||||
const lines = output.split("\n");
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (!line) continue;
|
||||
const [key, ...rest] = line.split(/\s+/);
|
||||
const value = rest.join(" ").trim();
|
||||
if (!key || !value) continue;
|
||||
switch (key) {
|
||||
case "user":
|
||||
result.user = value;
|
||||
break;
|
||||
case "hostname":
|
||||
result.host = value;
|
||||
break;
|
||||
case "port":
|
||||
result.port = parsePort(value);
|
||||
break;
|
||||
case "identityfile":
|
||||
if (value !== "none") result.identityFiles.push(value);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function resolveSshConfig(
|
||||
target: SshParsedTarget,
|
||||
opts: { identity?: string; timeoutMs?: number } = {},
|
||||
): Promise<SshResolvedConfig | null> {
|
||||
const sshPath = "/usr/bin/ssh";
|
||||
const args = ["-G"];
|
||||
if (target.port > 0 && target.port !== 22) {
|
||||
args.push("-p", String(target.port));
|
||||
}
|
||||
if (opts.identity?.trim()) {
|
||||
args.push("-i", opts.identity.trim());
|
||||
}
|
||||
const userHost = target.user ? `${target.user}@${target.host}` : target.host;
|
||||
args.push(userHost);
|
||||
|
||||
return await new Promise<SshResolvedConfig | null>((resolve) => {
|
||||
const child = spawn(sshPath, args, {
|
||||
stdio: ["ignore", "pipe", "ignore"],
|
||||
});
|
||||
let stdout = "";
|
||||
child.stdout?.setEncoding("utf8");
|
||||
child.stdout?.on("data", (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
|
||||
const timeoutMs = Math.max(200, opts.timeoutMs ?? 800);
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
child.kill("SIGKILL");
|
||||
} finally {
|
||||
resolve(null);
|
||||
}
|
||||
}, timeoutMs);
|
||||
|
||||
child.once("error", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(null);
|
||||
});
|
||||
child.once("exit", (code) => {
|
||||
clearTimeout(timer);
|
||||
if (code !== 0 || !stdout.trim()) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
resolve(parseSshConfigOutput(stdout));
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -4,11 +4,13 @@ import { resolveEffectiveMessagesConfig } from "../agents/identity.js";
|
||||
import {
|
||||
buildCommandTextFromArgs,
|
||||
findCommandByNativeName,
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
parseCommandArgs,
|
||||
resolveCommandArgMenu,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
import type { CommandArgs } from "../auto-reply/commands-registry.js";
|
||||
import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js";
|
||||
import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js";
|
||||
import { danger, logVerbose } from "../globals.js";
|
||||
import { resolveAgentRoute } from "../routing/resolve-route.js";
|
||||
@@ -42,7 +44,26 @@ export const registerTelegramNativeCommands = ({
|
||||
opts,
|
||||
}) => {
|
||||
const nativeCommands = nativeEnabled ? listNativeCommandSpecsForConfig(cfg) : [];
|
||||
if (nativeCommands.length > 0) {
|
||||
const reservedCommands = new Set(
|
||||
listNativeCommandSpecs().map((command) => command.name.toLowerCase()),
|
||||
);
|
||||
const customResolution = resolveTelegramCustomCommands({
|
||||
commands: telegramCfg.customCommands,
|
||||
reservedCommands,
|
||||
});
|
||||
for (const issue of customResolution.issues) {
|
||||
runtime.error?.(danger(issue.message));
|
||||
}
|
||||
const customCommands = customResolution.commands;
|
||||
const allCommands: Array<{ command: string; description: string }> = [
|
||||
...nativeCommands.map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
})),
|
||||
...customCommands,
|
||||
];
|
||||
|
||||
if (allCommands.length > 0) {
|
||||
const api = bot.api as unknown as {
|
||||
setMyCommands?: (
|
||||
commands: Array<{ command: string; description: string }>,
|
||||
@@ -50,12 +71,7 @@ export const registerTelegramNativeCommands = ({
|
||||
};
|
||||
if (typeof api.setMyCommands === "function") {
|
||||
api
|
||||
.setMyCommands(
|
||||
nativeCommands.map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
})),
|
||||
)
|
||||
.setMyCommands(allCommands)
|
||||
.catch((err) => {
|
||||
runtime.error?.(danger(`telegram setMyCommands failed: ${String(err)}`));
|
||||
});
|
||||
|
||||
@@ -2,6 +2,10 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listNativeCommandSpecs,
|
||||
listNativeCommandSpecsForConfig,
|
||||
} from "../auto-reply/commands-registry.js";
|
||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||
import * as replyModule from "../auto-reply/reply.js";
|
||||
import { createTelegramBot, getTelegramSequentialKey } from "./bot.js";
|
||||
@@ -164,6 +168,107 @@ describe("createTelegramBot", () => {
|
||||
expect(useSpy).toHaveBeenCalledWith("throttler");
|
||||
});
|
||||
|
||||
it("merges custom commands with native commands", () => {
|
||||
const config = {
|
||||
channels: {
|
||||
telegram: {
|
||||
customCommands: [
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "/Custom_Generate", description: "Create an image" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
|
||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const native = listNativeCommandSpecsForConfig(config).map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
}));
|
||||
expect(registered.slice(0, native.length)).toEqual(native);
|
||||
expect(registered.slice(native.length)).toEqual([
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "custom_generate", description: "Create an image" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores custom commands that collide with native commands", () => {
|
||||
const errorSpy = vi.fn();
|
||||
const config = {
|
||||
channels: {
|
||||
telegram: {
|
||||
customCommands: [
|
||||
{ command: "status", description: "Custom status" },
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
|
||||
createTelegramBot({
|
||||
token: "tok",
|
||||
runtime: {
|
||||
log: vi.fn(),
|
||||
error: errorSpy,
|
||||
exit: ((code: number) => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as (code: number) => never,
|
||||
},
|
||||
});
|
||||
|
||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
const native = listNativeCommandSpecsForConfig(config).map((command) => ({
|
||||
command: command.name,
|
||||
description: command.description,
|
||||
}));
|
||||
const nativeStatus = native.find((command) => command.command === "status");
|
||||
expect(nativeStatus).toBeDefined();
|
||||
expect(registered).toContainEqual({ command: "custom_backup", description: "Git backup" });
|
||||
expect(registered).not.toContainEqual({ command: "status", description: "Custom status" });
|
||||
expect(registered.filter((command) => command.command === "status")).toEqual([
|
||||
nativeStatus,
|
||||
]);
|
||||
expect(errorSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers custom commands when native commands are disabled", () => {
|
||||
const config = {
|
||||
commands: { native: false },
|
||||
channels: {
|
||||
telegram: {
|
||||
customCommands: [
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "custom_generate", description: "Create an image" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
loadConfig.mockReturnValue(config);
|
||||
|
||||
createTelegramBot({ token: "tok" });
|
||||
|
||||
const registered = setMyCommandsSpy.mock.calls[0]?.[0] as Array<{
|
||||
command: string;
|
||||
description: string;
|
||||
}>;
|
||||
expect(registered).toEqual([
|
||||
{ command: "custom_backup", description: "Git backup" },
|
||||
{ command: "custom_generate", description: "Create an image" },
|
||||
]);
|
||||
const reserved = listNativeCommandSpecs().map((command) => command.name);
|
||||
expect(registered.some((command) => reserved.includes(command.command))).toBe(false);
|
||||
});
|
||||
|
||||
it("forces native fetch only under Bun", () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalBun = (globalThis as { Bun?: unknown }).Bun;
|
||||
|
||||
Reference in new issue
Block a user