Compare commits

..
Author SHA1 Message Date
Peter Steinberger 862e89e683 fix: add per-channel markdown table conversion (#1495) (thanks @odysseus0) 2026-01-23 17:56:50 +00:00
George Zhang 03384e4b80 feat(telegram): convert markdown tables to bullet points
Tables render poorly in Telegram (pipes stripped, whitespace collapses).
This adds a 'tableMode' option to markdownToIR that converts tables to
nested bullet points, which render cleanly on mobile.

- Add tableMode: 'flat' | 'bullets' to MarkdownParseOptions
- Track table state during token rendering
- Render tables as bullet points with first column as row labels
- Apply bold styling to row labels for visual hierarchy
- Enable tableMode: 'bullets' for Telegram formatter

Closes #TBD
2026-01-23 12:00:26 +00:00
156 changed files with 2282 additions and 3429 deletions

No files matched your search

-4
View File
@@ -128,10 +128,6 @@
- **Multi-agent safety:** do **not** switch branches / check out a different branch unless explicitly requested.
- **Multi-agent safety:** running multiple agents is OK as long as each agent has its own session.
- **Multi-agent safety:** when you see unrecognized files, keep going; focus on your changes and commit only those.
- Lint/format churn:
- If staged+unstaged diffs are formatting-only, auto-resolve without asking.
- If commit/push already requested, auto-stage and include formatting-only follow-ups in the same commit (or a tiny follow-up commit if needed), no extra confirmation.
- Only ask when changes are semantic (logic/data/behavior).
- Lobster seam: use the shared CLI palette in `src/terminal/palette.ts` (no hardcoded colors); apply palette to onboarding/config prompts and other TTY UI output as needed.
- **Multi-agent safety:** focus reports on your edits; avoid guard-rail disclaimers unless truly blocked; when multiple agents touch the same file, continue if safe; end with a brief “other files present” note only if relevant.
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
-9
View File
@@ -6,20 +6,11 @@ Docs: https://docs.clawd.bot
### Changes
- CLI: restart the gateway by default after `clawdbot update`; add `--no-restart` to skip it.
- CLI: add live auth probes to `clawdbot models status` for per-profile verification.
- Markdown: add per-channel table conversion (bullets for Signal/WhatsApp, code blocks elsewhere). (#1495) Thanks @odysseus0.
### Fixes
- macOS: sync voice wake trigger words on submit/blur in settings. (#1506) Thanks @shiv19.
- TUI: forward unknown slash commands (for example, `/context`) to the Gateway.
- TUI: include Gateway slash commands in autocomplete and `/help`.
- CLI: skip usage lines in `clawdbot models status` when provider usage is unavailable.
- CLI: suppress diagnostic session/run noise during auth probes.
- Linux: include env-configured user bin roots in systemd PATH and align PATH audits. (#1512) Thanks @robbyczgw-cla.
- TUI: render Gateway slash-command replies as system output (for example, `/context`).
- Media: preserve PNG alpha when possible; fall back to JPEG when still over size cap. (#1491) Thanks @robbyczgw-cla.
- Agents: treat plugin-only tool allowlists as opt-ins; keep core tools enabled. (#1467)
- Exec approvals: persist allowlist entry ids to keep macOS allowlist rows stable. (#1521) Thanks @ngutman.
## 2026.1.22
@@ -84,52 +84,11 @@ enum ExecApprovalDecision: String, Codable, Sendable {
case deny
}
struct ExecAllowlistEntry: Codable, Hashable, Identifiable {
var id: UUID
struct ExecAllowlistEntry: Codable, Hashable {
var pattern: String
var lastUsedAt: Double?
var lastUsedCommand: String?
var lastResolvedPath: String?
init(
id: UUID = UUID(),
pattern: String,
lastUsedAt: Double? = nil,
lastUsedCommand: String? = nil,
lastResolvedPath: String? = nil)
{
self.id = id
self.pattern = pattern
self.lastUsedAt = lastUsedAt
self.lastUsedCommand = lastUsedCommand
self.lastResolvedPath = lastResolvedPath
}
private enum CodingKeys: String, CodingKey {
case id
case pattern
case lastUsedAt
case lastUsedCommand
case lastResolvedPath
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
self.id = try container.decodeIfPresent(UUID.self, forKey: .id) ?? UUID()
self.pattern = try container.decode(String.self, forKey: .pattern)
self.lastUsedAt = try container.decodeIfPresent(Double.self, forKey: .lastUsedAt)
self.lastUsedCommand = try container.decodeIfPresent(String.self, forKey: .lastUsedCommand)
self.lastResolvedPath = try container.decodeIfPresent(String.self, forKey: .lastResolvedPath)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(self.id, forKey: .id)
try container.encode(self.pattern, forKey: .pattern)
try container.encodeIfPresent(self.lastUsedAt, forKey: .lastUsedAt)
try container.encodeIfPresent(self.lastUsedCommand, forKey: .lastUsedCommand)
try container.encodeIfPresent(self.lastResolvedPath, forKey: .lastResolvedPath)
}
}
struct ExecApprovalsDefaults: Codable {
@@ -336,7 +295,6 @@ enum ExecApprovalsStore {
let allowlist = ((wildcardEntry.allowlist ?? []) + (agentEntry.allowlist ?? []))
.map { entry in
ExecAllowlistEntry(
id: entry.id,
pattern: entry.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: entry.lastUsedAt,
lastUsedCommand: entry.lastUsedCommand,
@@ -421,7 +379,6 @@ enum ExecApprovalsStore {
let allowlist = (entry.allowlist ?? []).map { item -> ExecAllowlistEntry in
guard item.pattern == pattern else { return item }
return ExecAllowlistEntry(
id: item.id,
pattern: item.pattern,
lastUsedAt: Date().timeIntervalSince1970 * 1000,
lastUsedCommand: command,
@@ -441,7 +398,6 @@ enum ExecApprovalsStore {
let cleaned = allowlist
.map { item in
ExecAllowlistEntry(
id: item.id,
pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines),
lastUsedAt: item.lastUsedAt,
lastUsedCommand: item.lastUsedCommand,
@@ -123,12 +123,12 @@ struct SystemRunSettingsView: View {
.foregroundStyle(.secondary)
} else {
VStack(alignment: .leading, spacing: 8) {
ForEach(self.model.entries, id: \.id) { entry in
ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in
ExecAllowlistRow(
entry: Binding(
get: { self.model.entry(for: entry.id) ?? entry },
set: { self.model.updateEntry($0, id: entry.id) }),
onRemove: { self.model.removeEntry(id: entry.id) })
get: { self.model.entries[index] },
set: { self.model.updateEntry($0, at: index) }),
onRemove: { self.model.removeEntry(at: index) })
}
}
}
@@ -373,24 +373,20 @@ final class ExecApprovalsSettingsModel {
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) {
func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries[index] = entry
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func removeEntry(id: UUID) {
func removeEntry(at index: Int) {
guard !self.isDefaultsScope else { return }
guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return }
guard self.entries.indices.contains(index) else { return }
self.entries.remove(at: index)
ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries)
}
func entry(for id: UUID) -> ExecAllowlistEntry? {
self.entries.first(where: { $0.id == id })
}
func refreshSkillBins(force: Bool = false) async {
guard self.autoAllowSkills else {
self.skillBins = []
@@ -21,8 +21,6 @@ struct VoiceWakeSettings: View {
@State private var micObserver = AudioInputDeviceObserver()
@State private var micRefreshTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
@FocusState private var focusedTriggerEntryID: UUID?
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview
@@ -33,9 +31,9 @@ struct VoiceWakeSettings: View {
var id: String { self.uid }
}
private struct TriggerEntry: Identifiable {
let id: UUID
var value: String
private struct IndexedWord: Identifiable {
let id: Int
let value: String
}
private var voiceWakeBinding: Binding<Bool> {
@@ -107,7 +105,6 @@ struct VoiceWakeSettings: View {
.onAppear {
guard !self.isPreview else { return }
self.startMicObserver()
self.loadTriggerEntries()
}
.onChange(of: self.state.voiceWakeMicID) { _, _ in
guard !self.isPreview else { return }
@@ -125,10 +122,8 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
Task { await self.meter.stop() }
self.micObserver.stop()
self.syncTriggerEntriesToState()
} else {
self.startMicObserver()
self.loadTriggerEntries()
}
}
.onDisappear {
@@ -141,21 +136,11 @@ struct VoiceWakeSettings: View {
self.micRefreshTask = nil
self.micObserver.stop()
Task { await self.meter.stop() }
self.syncTriggerEntriesToState()
}
.onChange(of: self.state.swabbleTriggerWords) { _, _ in
guard !self.isPreview else { return }
guard self.focusedTriggerEntryID == nil else { return }
self.loadTriggerEntries()
}
}
private func loadTriggerEntries() {
self.triggerEntries = self.state.swabbleTriggerWords.map { TriggerEntry(id: UUID(), value: $0) }
}
private func syncTriggerEntriesToState() {
self.state.swabbleTriggerWords = self.triggerEntries.map(\.value)
private var indexedWords: [IndexedWord] {
self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) }
}
private var triggerTable: some View {
@@ -169,47 +154,29 @@ struct VoiceWakeSettings: View {
} label: {
Label("Add word", systemImage: "plus")
}
.disabled(self.triggerEntries
.contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
.disabled(self.state.swabbleTriggerWords
.contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }))
Button("Reset defaults") {
self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) }
self.syncTriggerEntriesToState()
Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers }
}
Table(self.indexedWords) {
TableColumn("Word") { row in
TextField("Wake word", text: self.binding(for: row.id))
.textFieldStyle(.roundedBorder)
}
}
VStack(spacing: 0) {
ForEach(self.$triggerEntries) { $entry in
HStack(spacing: 8) {
TextField("Wake word", text: $entry.value)
.textFieldStyle(.roundedBorder)
.focused(self.$focusedTriggerEntryID, equals: entry.id)
.onSubmit {
self.syncTriggerEntriesToState()
}
Button {
self.removeWord(id: entry.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
.frame(width: 24)
}
.padding(8)
if entry.id != self.triggerEntries.last?.id {
Divider()
TableColumn("") { row in
Button {
self.removeWord(at: row.id)
} label: {
Image(systemName: "trash")
}
.buttonStyle(.borderless)
.help("Remove trigger word")
}
.width(36)
}
.onChange(of: self.focusedTriggerEntryID) { oldValue, newValue in
guard oldValue != nil, oldValue != newValue else { return }
self.syncTriggerEntriesToState()
}
.frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading)
.background(Color(nsColor: .textBackgroundColor))
.frame(minHeight: 180)
.clipShape(RoundedRectangle(cornerRadius: 6))
.overlay(
RoundedRectangle(cornerRadius: 6)
@@ -244,12 +211,24 @@ struct VoiceWakeSettings: View {
}
private func addWord() {
self.triggerEntries.append(TriggerEntry(id: UUID(), value: ""))
self.state.swabbleTriggerWords.append("")
}
private func removeWord(id: UUID) {
self.triggerEntries.removeAll { $0.id == id }
self.syncTriggerEntriesToState()
private func removeWord(at index: Int) {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords.remove(at: index)
}
private func binding(for index: Int) -> Binding<String> {
Binding(
get: {
guard self.state.swabbleTriggerWords.indices.contains(index) else { return "" }
return self.state.swabbleTriggerWords[index]
},
set: { newValue in
guard self.state.swabbleTriggerWords.indices.contains(index) else { return }
self.state.swabbleTriggerWords[index] = newValue
})
}
private func toggleTest() {
@@ -659,14 +638,13 @@ extension VoiceWakeSettings {
state.voicePushToTalkEnabled = true
state.swabbleTriggerWords = ["Claude", "Hey"]
var view = VoiceWakeSettings(state: state, isActive: true)
let view = VoiceWakeSettings(state: state, isActive: true)
view.availableMics = [AudioInputDevice(uid: "mic-1", name: "Built-in")]
view.availableLocales = [Locale(identifier: "en_US")]
view.meterLevel = 0.42
view.meterError = "No input"
view.testState = .detected("ok")
view.isTesting = true
view.triggerEntries = [TriggerEntry(id: UUID(), value: "Claude")]
_ = view.body
_ = view.localePicker
@@ -676,9 +654,8 @@ extension VoiceWakeSettings {
_ = view.chimeSection
view.addWord()
if let entryId = view.triggerEntries.first?.id {
view.removeWord(id: entryId)
}
_ = view.binding(for: 0).wrappedValue
view.removeWord(at: 0)
}
}
#endif
-7
View File
@@ -700,15 +700,8 @@ Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>`
- `--probe-profile <id>` (repeat or comma-separated)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
Always includes the auth overview and OAuth expiry status for profiles in the auth store.
`--probe` runs live requests (may consume tokens and trigger rate limits).
### `models set <model>`
Set `agents.defaults.model.primary`.
-14
View File
@@ -25,26 +25,12 @@ clawdbot models scan
`clawdbot models status` shows the resolved default/fallbacks plus an auth overview.
When provider usage snapshots are available, the OAuth/token status section includes
provider usage headers.
Add `--probe` to run live auth probes against each configured provider profile.
Probes are real requests (may consume tokens and trigger rate limits).
Notes:
- `models set <model-or-alias>` accepts `provider/model` or an alias.
- Model refs are parsed by splitting on the **first** `/`. If the model ID includes `/` (OpenRouter-style), include the provider prefix (example: `openrouter/moonshotai/kimi-k2`).
- If you omit the provider, Clawdbot treats the input as an alias or a model for the **default provider** (only works when there is no `/` in the model ID).
### `models status`
Options:
- `--json`
- `--plain`
- `--check` (exit 1=expired/missing, 2=expiring)
- `--probe` (live probe of configured auth profiles)
- `--probe-provider <name>` (probe one provider)
- `--probe-profile <id>` (repeat or comma-separated profile ids)
- `--probe-timeout <ms>`
- `--probe-concurrency <n>`
- `--probe-max-tokens <n>`
## Aliases + fallbacks
```bash
-1
View File
@@ -324,7 +324,6 @@ brew install <formula>
```
If you run Clawdbot via systemd, ensure the service PATH includes `/home/linuxbrew/.linuxbrew/bin` (or your brew prefix) so `brew`-installed tools resolve in non‑login shells.
Recent builds also prepend common user bin dirs on Linux systemd services (for example `~/.local/bin`, `~/.npm-global/bin`, `~/.local/share/pnpm`, `~/.bun/bin`) and honor `PNPM_HOME`, `NPM_CONFIG_PREFIX`, `BUN_INSTALL`, `VOLTA_HOME`, `ASDF_DATA_DIR`, `NVM_DIR`, and `FNM_DIR` when set.
### Can I switch between npm and git installs later?
-2
View File
@@ -54,7 +54,6 @@ Example schema:
"autoAllowSkills": true,
"allowlist": [
{
"id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F",
"pattern": "~/Projects/**/bin/rg",
"lastUsedAt": 1737150000000,
"lastUsedCommand": "rg -n TODO",
@@ -97,7 +96,6 @@ Examples:
- `/opt/homebrew/bin/rg`
Each allowlist entry tracks:
- **id** stable UUID used for UI identity (optional)
- **last used** timestamp
- **last used command**
- **last resolved path**
-2
View File
@@ -88,8 +88,6 @@ Session lifecycle:
- `/settings`
- `/exit`
Other Gateway slash commands (for example, `/context`) are forwarded to the Gateway and shown as system output. See [Slash commands](/tools/slash-commands).
## Local shell commands
- Prefix a line with `!` to run a local shell command on the TUI host.
- The TUI prompts once per session to allow local execution; declining keeps `!` disabled for the session.
+36 -42
View File
@@ -222,12 +222,6 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb
return res;
}
const flushAsync = async () => {
for (let i = 0; i < 2; i += 1) {
await new Promise<void>((resolve) => setImmediate(resolve));
}
};
describe("BlueBubbles webhook monitor", () => {
let unregister: () => void;
@@ -514,7 +508,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).toHaveBeenCalledWith(
expect.objectContaining({
@@ -562,7 +556,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(resolveChatGuidForTarget).not.toHaveBeenCalled();
expect(sendMessageBlueBubbles).toHaveBeenCalledWith(
@@ -609,7 +603,7 @@ describe("BlueBubbles webhook monitor", () => {
await handleBlueBubblesWebhookRequest(req, res);
// Wait for async processing
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -648,7 +642,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(res.statusCode).toBe(200);
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -689,7 +683,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
@@ -732,7 +726,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockUpsertPairingRequest).toHaveBeenCalled();
// Should not send pairing reply since created=false
@@ -773,7 +767,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -810,7 +804,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -850,7 +844,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -888,7 +882,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -927,7 +921,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -966,7 +960,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1007,7 +1001,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1048,7 +1042,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1086,7 +1080,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
});
@@ -1129,7 +1123,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1175,7 +1169,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1221,7 +1215,7 @@ describe("BlueBubbles webhook monitor", () => {
const originalRes = createMockResponse();
await handleBlueBubblesWebhookRequest(originalReq, originalRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Only assert the reply message behavior below.
mockDispatchReplyWithBufferedBlockDispatcher.mockClear();
@@ -1245,7 +1239,7 @@ describe("BlueBubbles webhook monitor", () => {
const replyRes = createMockResponse();
await handleBlueBubblesWebhookRequest(replyReq, replyRes);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1291,7 +1285,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1339,7 +1333,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesReaction).toHaveBeenCalledWith(
expect.objectContaining({
@@ -1392,7 +1386,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should process even without mention because it's an authorized control command
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
@@ -1435,7 +1429,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
@@ -1478,7 +1472,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).toHaveBeenCalled();
});
@@ -1519,7 +1513,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(markBlueBubblesChatRead).not.toHaveBeenCalled();
});
@@ -1562,7 +1556,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Should call typing start when reply flow triggers it.
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
@@ -1612,7 +1606,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1657,7 +1651,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(sendBlueBubblesTyping).toHaveBeenCalledWith(
expect.any(String),
@@ -1705,7 +1699,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2")
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
@@ -1750,7 +1744,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction added"),
@@ -1790,7 +1784,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reaction removed"),
@@ -1830,7 +1824,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
});
@@ -1868,7 +1862,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("👍"),
@@ -1909,7 +1903,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
@@ -1949,7 +1943,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
@@ -2001,7 +1995,7 @@ describe("BlueBubbles webhook monitor", () => {
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
-1
View File
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
vi.mock("./tools/gateway.js", () => ({
-1
View File
@@ -16,7 +16,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("agents_list", () => {
-1
View File
@@ -10,7 +10,6 @@ vi.mock("../media/image-ops.js", () => ({
resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")),
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("nodes camera_snap", () => {
@@ -75,7 +75,6 @@ vi.mock("../infra/provider-usage.js", () => ({
formatUsageSummaryLine: () => null,
}));
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
describe("session_status tool", () => {
+3 -1
View File
@@ -4,6 +4,9 @@ const callGatewayMock = vi.fn();
vi.mock("../gateway/call.js", () => ({
callGateway: (opts: unknown) => callGatewayMock(opts),
}));
vi.mock("../plugins/tools.js", () => ({
resolvePluginTools: () => [],
}));
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
@@ -20,7 +23,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2000) => {
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -21,7 +21,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
@@ -22,7 +22,6 @@ vi.mock("../config/config.js", async (importOriginal) => {
});
import { emitAgentEvent } from "../infra/agent-events.js";
import "./test-helpers/fast-core-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { resetSubagentRegistryForTests } from "./subagent-registry.js";
+2 -6
View File
@@ -109,18 +109,14 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH
state: "processing",
reason: wasActive ? "run_replaced" : "run_started",
});
if (!sessionId.startsWith("probe-")) {
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
export function clearActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueHandle) {
if (ACTIVE_EMBEDDED_RUNS.get(sessionId) === handle) {
ACTIVE_EMBEDDED_RUNS.delete(sessionId);
logSessionStateChange({ sessionId, state: "idle", reason: "run_completed" });
if (!sessionId.startsWith("probe-")) {
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
}
diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`);
notifyEmbeddedRunEnded(sessionId);
} else {
diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`);
@@ -1,6 +1,5 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import sharp from "sharp";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
const defaultTools = createClawdbotCodingTools();
@@ -2,7 +2,6 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotCodingTools } from "./pi-tools.js";
describe("createClawdbotCodingTools", () => {
@@ -3,7 +3,6 @@ import os from "node:os";
import path from "node:path";
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { describe, expect, it, vi } from "vitest";
import "./test-helpers/fast-coding-tools.js";
import { createClawdbotTools } from "./clawdbot-tools.js";
import { __testing, createClawdbotCodingTools } from "./pi-tools.js";
import { createSandboxedReadTool } from "./pi-tools.read.js";
-34
View File
@@ -1,34 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { acquireSessionWriteLock } from "./session-write-lock.js";
describe("acquireSessionWriteLock", () => {
it("reuses locks across symlinked session paths", async () => {
if (process.platform === "win32") {
expect(true).toBe(true);
return;
}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lock-"));
try {
const realDir = path.join(root, "real");
const linkDir = path.join(root, "link");
await fs.mkdir(realDir, { recursive: true });
await fs.symlink(realDir, linkDir);
const sessionReal = path.join(realDir, "sessions.json");
const sessionLink = path.join(linkDir, "sessions.json");
const lockA = await acquireSessionWriteLock({ sessionFile: sessionReal, timeoutMs: 500 });
const lockB = await acquireSessionWriteLock({ sessionFile: sessionLink, timeoutMs: 500 });
await lockB.release();
await lockA.release();
} finally {
await fs.rm(root, { recursive: true, force: true });
}
});
});
+9 -17
View File
@@ -45,28 +45,20 @@ export async function acquireSessionWriteLock(params: {
}> {
const timeoutMs = params.timeoutMs ?? 10_000;
const staleMs = params.staleMs ?? 30 * 60 * 1000;
const sessionFile = path.resolve(params.sessionFile);
const sessionDir = path.dirname(sessionFile);
await fs.mkdir(sessionDir, { recursive: true });
let normalizedDir = sessionDir;
try {
normalizedDir = await fs.realpath(sessionDir);
} catch {
// Fall back to the resolved path if realpath fails (permissions, transient FS).
}
const normalizedSessionFile = path.join(normalizedDir, path.basename(sessionFile));
const lockPath = `${normalizedSessionFile}.lock`;
const sessionFile = params.sessionFile;
const lockPath = `${sessionFile}.lock`;
await fs.mkdir(path.dirname(lockPath), { recursive: true });
const held = HELD_LOCKS.get(normalizedSessionFile);
const held = HELD_LOCKS.get(sessionFile);
if (held) {
held.count += 1;
return {
release: async () => {
const current = HELD_LOCKS.get(normalizedSessionFile);
const current = HELD_LOCKS.get(sessionFile);
if (!current) return;
current.count -= 1;
if (current.count > 0) return;
HELD_LOCKS.delete(normalizedSessionFile);
HELD_LOCKS.delete(sessionFile);
await current.handle.close();
await fs.rm(current.lockPath, { force: true });
},
@@ -83,14 +75,14 @@ export async function acquireSessionWriteLock(params: {
JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2),
"utf8",
);
HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath });
HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath });
return {
release: async () => {
const current = HELD_LOCKS.get(normalizedSessionFile);
const current = HELD_LOCKS.get(sessionFile);
if (!current) return;
current.count -= 1;
if (current.count > 0) return;
HELD_LOCKS.delete(normalizedSessionFile);
HELD_LOCKS.delete(sessionFile);
await current.handle.close();
await fs.rm(current.lockPath, { force: true });
},
@@ -1,22 +0,0 @@
import { vi } from "vitest";
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => stubTool("image"),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
}));
@@ -1,30 +0,0 @@
import { vi } from "vitest";
const stubTool = (name: string) => ({
name,
description: `${name} stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(),
});
vi.mock("../tools/browser-tool.js", () => ({
createBrowserTool: () => stubTool("browser"),
}));
vi.mock("../tools/canvas-tool.js", () => ({
createCanvasTool: () => stubTool("canvas"),
}));
vi.mock("../tools/image-tool.js", () => ({
createImageTool: () => stubTool("image"),
}));
vi.mock("../tools/web-tools.js", () => ({
createWebSearchTool: () => null,
createWebFetchTool: () => null,
}));
vi.mock("../../plugins/tools.js", () => ({
resolvePluginTools: () => [],
getPluginToolMeta: () => undefined,
}));
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import type { MsgContext } from "./templating.js";
describe("resolveCommandAuthorization", () => {
it("falls back from empty SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from whitespace SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+999"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: " ",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+999");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from un-normalizable SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "wat",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:120363401234567890@g.us",
SenderId: "123@lid",
SenderE164: "+41796666864",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+41796666864");
expect(auth.isAuthorizedSender).toBe(true);
});
});
@@ -1,14 +1,10 @@
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
import { resolveCommandAuthorization } from "./command-auth.js";
import { hasControlCommand, hasInlineCommandTokens } from "./command-detection.js";
import { listChatCommands } from "./commands-registry.js";
import { parseActivationCommand } from "./group-activation.js";
import { parseSendPolicyCommand } from "./send-policy.js";
import type { MsgContext } from "./templating.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
beforeEach(() => {
setActivePluginRegistry(createTestRegistry([]));
@@ -18,123 +14,6 @@ afterEach(() => {
setActivePluginRegistry(createTestRegistry([]));
});
describe("resolveCommandAuthorization", () => {
it("falls back from empty SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from whitespace SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back to From when SenderId and SenderE164 are whitespace", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+999"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: " ",
SenderE164: " ",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+999");
expect(auth.isAuthorizedSender).toBe(true);
});
it("falls back from un-normalizable SenderId to SenderE164", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+123"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:+999",
SenderId: "wat",
SenderE164: "+123",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+123");
expect(auth.isAuthorizedSender).toBe(true);
});
it("prefers SenderE164 when SenderId does not match allowFrom", () => {
const cfg = {
channels: { whatsapp: { allowFrom: ["+41796666864"] } },
} as ClawdbotConfig;
const ctx = {
Provider: "whatsapp",
Surface: "whatsapp",
From: "whatsapp:120363401234567890@g.us",
SenderId: "123@lid",
SenderE164: "+41796666864",
} as MsgContext;
const auth = resolveCommandAuthorization({
ctx,
cfg,
commandAuthorized: true,
});
expect(auth.senderId).toBe("+41796666864");
expect(auth.isAuthorizedSender).toBe(true);
});
});
describe("control command parsing", () => {
it("requires slash for send policy", () => {
expect(parseSendPolicyCommand("/send on")).toEqual({
@@ -0,0 +1,156 @@
import { describe, expect, it } from "vitest";
import {
buildCommandTextFromArgs,
parseCommandArgs,
resolveCommandArgMenu,
serializeCommandArgs,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
describe("commands registry args", () => {
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
description: "debug",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [
{ name: "action", description: "action", type: "string" },
{ name: "path", description: "path", type: "string" },
{ name: "value", description: "value", type: "string", captureRemaining: true },
],
};
const args = parseCommandArgs(command, "set foo bar baz");
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
});
it("serializes args via raw first, then values", () => {
const command: ChatCommandDefinition = {
key: "model",
description: "model",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
};
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"gpt-5.2-codex",
);
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"/model gpt-5.2-codex",
);
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
});
it("resolves function-based choices with a default provider/model context", () => {
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
null;
const command: ChatCommandDefinition = {
key: "think",
description: "think",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "level",
description: "level",
type: "string",
choices: ({ provider, model, command, arg }) => {
seen = { provider, model, commandKey: command.key, argName: arg.name };
return ["low", "high"];
},
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
expect(seen?.model).toBeTruthy();
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { raw: "on" },
cfg: {} as never,
});
expect(menu).toBeNull();
});
});
-152
View File
@@ -2,19 +2,14 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import {
buildCommandText,
buildCommandTextFromArgs,
getCommandDetection,
listChatCommands,
listChatCommandsForConfig,
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
normalizeCommandBody,
parseCommandArgs,
resolveCommandArgMenu,
serializeCommandArgs,
shouldHandleTextCommands,
} from "./commands-registry.js";
import type { ChatCommandDefinition } from "./commands-registry.types.js";
import { setActivePluginRegistry } from "../plugins/runtime.js";
import { createTestRegistry } from "../test-utils/channel-plugins.js";
@@ -159,150 +154,3 @@ describe("commands registry", () => {
expect(normalizeCommandBody("/dock_telegram")).toBe("/dock-telegram");
});
});
describe("commands registry args", () => {
it("parses positional args and captureRemaining", () => {
const command: ChatCommandDefinition = {
key: "debug",
description: "debug",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [
{ name: "action", description: "action", type: "string" },
{ name: "path", description: "path", type: "string" },
{ name: "value", description: "value", type: "string", captureRemaining: true },
],
};
const args = parseCommandArgs(command, "set foo bar baz");
expect(args?.values).toEqual({ action: "set", path: "foo", value: "bar baz" });
});
it("serializes args via raw first, then values", () => {
const command: ChatCommandDefinition = {
key: "model",
description: "model",
textAliases: [],
scope: "both",
argsParsing: "positional",
args: [{ name: "model", description: "model", type: "string", captureRemaining: true }],
};
expect(serializeCommandArgs(command, { raw: "gpt-5.2-codex" })).toBe("gpt-5.2-codex");
expect(serializeCommandArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"gpt-5.2-codex",
);
expect(buildCommandTextFromArgs(command, { values: { model: "gpt-5.2-codex" } })).toBe(
"/model gpt-5.2-codex",
);
});
it("resolves auto arg menus when missing a choice arg", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("mode");
expect(menu?.choices).toEqual(["off", "tokens", "full", "cost"]);
});
it("does not show menus when arg already provided", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "mode",
description: "mode",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { values: { mode: "tokens" } },
cfg: {} as never,
});
expect(menu).toBeNull();
});
it("resolves function-based choices with a default provider/model context", () => {
let seen: { provider: string; model: string; commandKey: string; argName: string } | null =
null;
const command: ChatCommandDefinition = {
key: "think",
description: "think",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "positional",
args: [
{
name: "level",
description: "level",
type: "string",
choices: ({ provider, model, command, arg }) => {
seen = { provider, model, commandKey: command.key, argName: arg.name };
return ["low", "high"];
},
},
],
};
const menu = resolveCommandArgMenu({ command, args: undefined, cfg: {} as never });
expect(menu?.arg.name).toBe("level");
expect(menu?.choices).toEqual(["low", "high"]);
expect(seen?.commandKey).toBe("think");
expect(seen?.argName).toBe("level");
expect(seen?.provider).toBeTruthy();
expect(seen?.model).toBeTruthy();
});
it("does not show menus when args were provided as raw text only", () => {
const command: ChatCommandDefinition = {
key: "usage",
description: "usage",
textAliases: [],
scope: "both",
argsMenu: "auto",
argsParsing: "none",
args: [
{
name: "mode",
description: "on or off",
type: "string",
choices: ["off", "tokens", "full", "cost"],
},
],
};
const menu = resolveCommandArgMenu({
command,
args: { raw: "on" },
cfg: {} as never,
});
expect(menu).toBeNull();
});
});
+48
View File
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vitest";
import { createInboundDebouncer } from "./inbound-debounce.js";
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
-402
View File
@@ -1,402 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../config/config.js";
import type { GroupKeyResolution } from "../config/sessions.js";
import { createInboundDebouncer } from "./inbound-debounce.js";
import { applyTemplate, type MsgContext, type TemplateContext } from "./templating.js";
import { finalizeInboundContext } from "./reply/inbound-context.js";
import {
buildInboundDedupeKey,
resetInboundDedupe,
shouldSkipDuplicateInbound,
} from "./reply/inbound-dedupe.js";
import { formatInboundBodyWithSenderMeta } from "./reply/inbound-sender-meta.js";
import { normalizeInboundTextNewlines } from "./reply/inbound-text.js";
import { resolveGroupRequireMention } from "./reply/groups.js";
import {
buildMentionRegexes,
matchesMentionPatterns,
normalizeMentionText,
} from "./reply/mentions.js";
import { initSessionState } from "./reply/session.js";
describe("applyTemplate", () => {
it("renders primitive values", () => {
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
const overrides = ctx as Record<string, unknown>;
overrides.MessageSid = 42;
overrides.IsNewSession = true;
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
});
it("renders arrays of primitives", () => {
const ctx = { MediaPaths: ["a"] } as TemplateContext;
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
});
it("drops object values", () => {
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
});
it("renders missing placeholders as empty", () => {
const ctx: TemplateContext = {};
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
});
});
describe("normalizeInboundTextNewlines", () => {
it("keeps real newlines", () => {
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
});
it("normalizes CRLF/CR to LF", () => {
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
});
it("decodes literal \\n to newlines when no real newlines exist", () => {
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
});
});
describe("finalizeInboundContext", () => {
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
const ctx: MsgContext = {
Body: "a\\nb\r\nc",
RawBody: "raw\\nline",
ChatType: "channel",
From: "whatsapp:group:123@g.us",
GroupSubject: "Test",
};
const out = finalizeInboundContext(ctx);
expect(out.Body).toBe("a\nb\nc");
expect(out.RawBody).toBe("raw\nline");
expect(out.BodyForAgent).toBe("a\nb\nc");
expect(out.BodyForCommands).toBe("raw\nline");
expect(out.CommandAuthorized).toBe(false);
expect(out.ChatType).toBe("channel");
expect(out.ConversationLabel).toContain("Test");
});
it("can force BodyForCommands to follow updated CommandBody", () => {
const ctx: MsgContext = {
Body: "base",
BodyForCommands: "<media:audio>",
CommandBody: "say hi",
From: "signal:+15550001111",
ChatType: "direct",
};
finalizeInboundContext(ctx, { forceBodyForCommands: true });
expect(ctx.BodyForCommands).toBe("say hi");
});
});
describe("formatInboundBodyWithSenderMeta", () => {
it("does nothing for direct messages", () => {
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
});
it("appends a sender meta line for non-direct messages", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("prefers SenderE164 in the label when present", () => {
const ctx: MsgContext = {
ChatType: "group",
SenderName: "Bob",
SenderId: "bob@s.whatsapp.net",
SenderE164: "+222",
};
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Bob (+222)]",
);
});
it("appends with a real newline even if the body contains literal \\n", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
"[X] one\\n[X] two\n[from: Bob (+222)]",
);
});
it("does not duplicate a sender meta line when one is already present", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("does not append when the body already includes a sender prefix", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
});
it("does not append when the sender prefix follows an envelope header", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
"[Signal Group] Alice (A1): hi",
);
});
});
describe("inbound dedupe", () => {
it("builds a stable key when MessageSid is present", () => {
const ctx: MsgContext = {
Provider: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123",
MessageSid: "42",
};
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
});
it("skips duplicates with the same key", () => {
resetInboundDedupe();
const ctx: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
});
it("does not dedupe when the peer changes", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
).toBe(false);
});
it("does not dedupe across session keys", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
).toBe(true);
});
});
describe("createInboundDebouncer", () => {
it("debounces and combines items", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string }>({
debounceMs: 10,
buildKey: (item) => item.key,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1" });
await debouncer.enqueue({ key: "a", id: "2" });
expect(calls).toEqual([]);
await vi.advanceTimersByTimeAsync(10);
expect(calls).toEqual([["1", "2"]]);
vi.useRealTimers();
});
it("flushes buffered items before non-debounced item", async () => {
vi.useFakeTimers();
const calls: Array<string[]> = [];
const debouncer = createInboundDebouncer<{ key: string; id: string; debounce: boolean }>({
debounceMs: 50,
buildKey: (item) => item.key,
shouldDebounce: (item) => item.debounce,
onFlush: async (items) => {
calls.push(items.map((entry) => entry.id));
},
});
await debouncer.enqueue({ key: "a", id: "1", debounce: true });
await debouncer.enqueue({ key: "a", id: "2", debounce: false });
expect(calls).toEqual([["1"], ["2"]]);
vi.useRealTimers();
});
});
describe("initSessionState sender meta", () => {
it("injects sender meta into BodyStripped for group chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp 123@g.us] ping",
ChatType: "group",
SenderName: "Bob",
SenderE164: "+222",
SenderId: "222@s.whatsapp.net",
SessionKey: "agent:main:whatsapp:group:123@g.us",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
});
it("does not inject sender meta for direct chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp +1] ping",
ChatType: "direct",
SenderName: "Bob",
SenderE164: "+222",
SessionKey: "agent:main:whatsapp:dm:+222",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
});
});
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({
messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
},
});
expect(regexes).toHaveLength(1);
expect(regexes[0]?.test("clawd")).toBe(true);
});
it("normalizes zero-width characters", () => {
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
});
it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
},
},
"work",
);
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
});
});
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
},
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "discord",
From: "discord:group:123",
GroupChannel: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
channel: "discord",
id: "123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("respects Slack channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
slack: {
channels: {
C123: { requireMention: false },
},
},
},
};
const ctx: TemplateContext = {
Provider: "slack",
From: "slack:channel:C123",
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
channel: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
});
@@ -2,79 +2,174 @@ import fs from "node:fs/promises";
import { basename, join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js";
import type { MsgContext, TemplateContext } from "./templating.js";
const sandboxMocks = vi.hoisted(() => ({
ensureSandboxWorkspaceForSession: vi.fn(),
vi.mock("../agents/pi-embedded.js", () => ({
abortEmbeddedPiRun: vi.fn().mockReturnValue(false),
compactEmbeddedPiSession: vi.fn(),
runEmbeddedPiAgent: vi.fn(),
queueEmbeddedPiMessage: vi.fn().mockReturnValue(false),
resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`,
isEmbeddedPiRunActive: vi.fn().mockReturnValue(false),
isEmbeddedPiRunStreaming: vi.fn().mockReturnValue(false),
}));
vi.mock("../agents/sandbox.js", () => sandboxMocks);
const usageMocks = vi.hoisted(() => ({
loadProviderUsageSummary: vi.fn().mockResolvedValue({
updatedAt: 0,
providers: [],
}),
formatUsageSummaryLine: vi.fn().mockReturnValue("📊 Usage: Claude 80% left"),
resolveUsageProviderId: vi.fn((provider: string) => provider.split("/")[0]),
}));
vi.mock("../infra/provider-usage.js", () => usageMocks);
const modelCatalogMocks = vi.hoisted(() => ({
loadModelCatalog: vi.fn().mockResolvedValue([
{
provider: "anthropic",
id: "claude-opus-4-5",
name: "Claude Opus 4.5",
contextWindow: 200000,
},
{
provider: "openrouter",
id: "anthropic/claude-opus-4-5",
name: "Claude Opus 4.5 (OpenRouter)",
contextWindow: 200000,
},
{ provider: "openai", id: "gpt-4.1-mini", name: "GPT-4.1 mini" },
{ provider: "openai", id: "gpt-5.2", name: "GPT-5.2" },
{ provider: "openai-codex", id: "gpt-5.2", name: "GPT-5.2 (Codex)" },
{ provider: "minimax", id: "MiniMax-M2.1", name: "MiniMax M2.1" },
]),
resetModelCatalogCacheForTest: vi.fn(),
}));
vi.mock("../agents/model-catalog.js", () => modelCatalogMocks);
import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js";
import { abortEmbeddedPiRun, runEmbeddedPiAgent } from "../agents/pi-embedded.js";
import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js";
import { stageSandboxMedia } from "./reply/stage-sandbox-media.js";
import { resolveAgentIdFromSessionKey, resolveSessionKey } from "../config/sessions.js";
import { getReplyFromConfig } from "./reply.js";
const _MAIN_SESSION_KEY = "agent:main:main";
const webMocks = vi.hoisted(() => ({
webAuthExists: vi.fn().mockResolvedValue(true),
getWebAuthAgeMs: vi.fn().mockReturnValue(120_000),
readWebSelfId: vi.fn().mockReturnValue({ e164: "+1999" }),
}));
vi.mock("../web/session.js", () => webMocks);
async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" });
return withTempHomeBase(
async (home) => {
vi.mocked(runEmbeddedPiAgent).mockClear();
vi.mocked(abortEmbeddedPiRun).mockClear();
return await fn(home);
},
{ prefix: "clawdbot-triggers-" },
);
}
function _makeCfg(home: string) {
return {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: { store: join(home, "sessions.json") },
};
}
afterEach(() => {
vi.restoreAllMocks();
});
describe("stageSandboxMedia", () => {
it("stages inbound media into the sandbox workspace", async () => {
describe("trigger handling", () => {
it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => {
await withTempHome(async (home) => {
const inboundDir = join(home, ".clawdbot", "media", "inbound");
await fs.mkdir(inboundDir, { recursive: true });
const mediaPath = join(inboundDir, "photo.jpg");
await fs.writeFile(mediaPath, "test");
const sandboxDir = join(home, "sandboxes", "session");
vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({
workspaceDir: sandboxDir,
containerWorkdir: "/work",
vi.mocked(runEmbeddedPiAgent).mockResolvedValue({
payloads: [{ text: "ok" }],
meta: {
durationMs: 1,
agentMeta: { sessionId: "s", provider: "p", model: "m" },
},
});
const ctx: MsgContext = {
const cfg = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main" as const,
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: {
whatsapp: {
allowFrom: ["*"],
},
},
session: {
store: join(home, "sessions.json"),
},
};
const ctx = {
Body: "hi",
From: "whatsapp:group:demo",
To: "+2000",
ChatType: "group",
Provider: "whatsapp",
ChatType: "group" as const,
Provider: "whatsapp" as const,
MediaPath: mediaPath,
MediaType: "image/jpeg",
MediaUrl: mediaPath,
};
const sessionCtx: TemplateContext = { ...ctx };
await stageSandboxMedia({
ctx,
sessionCtx,
cfg: {
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
workspace: join(home, "clawd"),
sandbox: {
mode: "non-main",
workspaceRoot: join(home, "sandboxes"),
},
},
},
channels: { whatsapp: { allowFrom: ["*"] } },
session: { store: join(home, "sessions.json") },
},
sessionKey: "agent:main:main",
workspaceDir: join(home, "clawd"),
});
const res = await getReplyFromConfig(ctx, {}, cfg);
const text = Array.isArray(res) ? res[0]?.text : res?.text;
expect(text).toBe("ok");
expect(runEmbeddedPiAgent).toHaveBeenCalledOnce();
const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? "";
const stagedPath = `media/inbound/${basename(mediaPath)}`;
expect(ctx.MediaPath).toBe(stagedPath);
expect(sessionCtx.MediaPath).toBe(stagedPath);
expect(ctx.MediaUrl).toBe(stagedPath);
expect(sessionCtx.MediaUrl).toBe(stagedPath);
expect(prompt).toContain(stagedPath);
expect(prompt).not.toContain(mediaPath);
const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath));
const sessionKey = resolveSessionKey(
cfg.session?.scope ?? "per-sender",
ctx,
cfg.session?.mainKey,
);
const agentId = resolveAgentIdFromSessionKey(sessionKey);
const sandbox = await ensureSandboxWorkspaceForSession({
config: cfg,
sessionKey,
workspaceDir: resolveAgentWorkspaceDir(cfg, agentId),
});
expect(sandbox).not.toBeNull();
if (!sandbox) {
throw new Error("Expected sandbox to be set");
}
const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath));
await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy();
});
});
+25
View File
@@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { parseAudioTag } from "./audio-tags.js";
describe("parseAudioTag", () => {
it("detects audio_as_voice and strips the tag", () => {
const result = parseAudioTag("Hello [[audio_as_voice]] world");
expect(result.audioAsVoice).toBe(true);
expect(result.hadTag).toBe(true);
expect(result.text).toBe("Hello world");
});
it("returns empty output for missing text", () => {
const result = parseAudioTag(undefined);
expect(result.audioAsVoice).toBe(false);
expect(result.hadTag).toBe(false);
expect(result.text).toBe("");
});
it("removes tag-only messages", () => {
const result = parseAudioTag("[[audio_as_voice]]");
expect(result.audioAsVoice).toBe(true);
expect(result.text).toBe("");
});
});
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
describe("block reply coalescer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("coalesces chunks within the idle window", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
await vi.advanceTimersByTimeAsync(100);
expect(flushes).toEqual(["Hello world"]);
coalescer.stop();
});
it("waits until minChars before idle flush", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "short" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual([]);
coalescer.enqueue({ text: "message" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual(["short message"]);
coalescer.stop();
});
it("flushes buffered text before media payloads", () => {
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
void coalescer.flush({ force: true });
expect(flushes[0].text).toBe("Hello world");
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
coalescer.stop();
});
});
@@ -0,0 +1,139 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "telegram",
Surface: "telegram",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "telegram",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});
@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("handleCommands /config configWrites gating", () => {
it("blocks /config set when channel config writes are disabled", async () => {
const cfg = {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
} as ClawdbotConfig;
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Config writes are disabled");
});
});
@@ -5,47 +5,6 @@ import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn());
const validateConfigObjectWithPluginsMock = vi.hoisted(() => vi.fn());
const writeConfigFileMock = vi.hoisted(() => vi.fn());
vi.mock("../../config/config.js", async () => {
const actual =
await vi.importActual<typeof import("../../config/config.js")>("../../config/config.js");
return {
...actual,
readConfigFileSnapshot: readConfigFileSnapshotMock,
validateConfigObjectWithPlugins: validateConfigObjectWithPluginsMock,
writeConfigFile: writeConfigFileMock,
};
});
const readChannelAllowFromStoreMock = vi.hoisted(() => vi.fn());
const addChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
const removeChannelAllowFromStoreEntryMock = vi.hoisted(() => vi.fn());
vi.mock("../../pairing/pairing-store.js", async () => {
const actual = await vi.importActual<typeof import("../../pairing/pairing-store.js")>(
"../../pairing/pairing-store.js",
);
return {
...actual,
readChannelAllowFromStore: readChannelAllowFromStoreMock,
addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock,
removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock,
};
});
vi.mock("../../channels/plugins/pairing.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/pairing.js")>(
"../../channels/plugins/pairing.js",
);
return {
...actual,
listPairingChannels: () => ["telegram"],
};
});
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "anthropic", id: "claude-opus-4-5", name: "Claude Opus" },
@@ -87,70 +46,17 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "telegram",
model: "test-model",
contextTokens: 0,
provider: "anthropic",
model: "claude-opus-4-5",
contextTokens: 16000,
isGroup: false,
};
}
describe("handleCommands /allowlist", () => {
it("lists config + store allowFrom entries", async () => {
readChannelAllowFromStoreMock.mockResolvedValueOnce(["456"]);
const cfg = {
commands: { text: true },
channels: { telegram: { allowFrom: ["123", "@Alice"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist list dm", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Channel: telegram");
expect(result.reply?.text).toContain("DM allowFrom (config): 123, @alice");
expect(result.reply?.text).toContain("Paired allowFrom (store): 456");
});
it("adds entries to config and pairing store", async () => {
readConfigFileSnapshotMock.mockResolvedValueOnce({
valid: true,
parsed: {
channels: { telegram: { allowFrom: ["123"] } },
},
});
validateConfigObjectWithPluginsMock.mockImplementation((config: unknown) => ({
ok: true,
config,
}));
addChannelAllowFromStoreEntryMock.mockResolvedValueOnce({
changed: true,
allowFrom: ["123", "789"],
});
const cfg = {
commands: { text: true, config: true },
channels: { telegram: { allowFrom: ["123"] } },
} as ClawdbotConfig;
const params = buildParams("/allowlist add dm 789", cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(writeConfigFileMock).toHaveBeenCalledWith(
expect.objectContaining({
channels: { telegram: { allowFrom: ["123", "789"] } },
}),
);
expect(addChannelAllowFromStoreEntryMock).toHaveBeenCalledWith({
channel: "telegram",
entry: "789",
});
expect(result.reply?.text).toContain("DM allowlist added");
});
});
describe("/models command", () => {
const cfg = {
commands: { text: true },
// allowlist is empty => allowAny, but still okay for listing
agents: { defaults: { model: { primary: "anthropic/claude-opus-4-5" } } },
} as unknown as ClawdbotConfig;
@@ -1,125 +0,0 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import { buildCommandContext, handleCommands } from "./commands.js";
import { extractMessageText } from "./commands-subagents.js";
import { parseConfigCommand } from "./config-commands.js";
import { parseDebugCommand } from "./debug-commands.js";
import { parseInlineDirectives } from "./directive-handling.js";
function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial<MsgContext>) {
const ctx = {
Body: commandBody,
CommandBody: commandBody,
CommandSource: "text",
CommandAuthorized: true,
Provider: "whatsapp",
Surface: "whatsapp",
...ctxOverrides,
} as MsgContext;
const command = buildCommandContext({
ctx,
cfg,
isGroup: false,
triggerBodyNormalized: commandBody.trim().toLowerCase(),
commandAuthorized: true,
});
return {
ctx,
cfg,
command,
directives: parseInlineDirectives(commandBody),
elevated: { enabled: true, allowed: true, failures: [] },
sessionKey: "agent:main:main",
workspaceDir: "/tmp",
defaultGroupActivation: () => "mention",
resolvedVerboseLevel: "off" as const,
resolvedReasoningLevel: "off" as const,
resolveDefaultThinkingLevel: async () => undefined,
provider: "whatsapp",
model: "test-model",
contextTokens: 0,
isGroup: false,
};
}
describe("parseConfigCommand", () => {
it("parses show/unset", () => {
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
expect(parseConfigCommand("/config show")).toEqual({
action: "show",
path: undefined,
});
expect(parseConfigCommand("/config show foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config get foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
action: "unset",
path: "foo.bar",
});
});
it("parses set with JSON", () => {
const cmd = parseConfigCommand('/config set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
});
describe("parseDebugCommand", () => {
it("parses show/reset", () => {
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
});
it("parses set with JSON", () => {
const cmd = parseDebugCommand('/debug set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
it("parses unset", () => {
const cmd = parseDebugCommand("/debug unset foo.bar");
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
});
});
describe("extractMessageText", () => {
it("preserves user text that looks like tool call markers", () => {
const message = {
role: "user",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
});
it("sanitizes assistant tool call markers", () => {
const message = {
role: "assistant",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toBe("Here ok");
});
});
describe("handleCommands /config configWrites gating", () => {
it("blocks /config set when channel config writes are disabled", async () => {
const cfg = {
commands: { config: true, text: true },
channels: { whatsapp: { allowFrom: ["*"], configWrites: false } },
} as ClawdbotConfig;
const params = buildParams('/config set messages.ackReaction=":)"', cfg);
const result = await handleCommands(params);
expect(result.shouldContinue).toBe(false);
expect(result.reply?.text).toContain("Config writes are disabled");
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { extractMessageText } from "./commands-subagents.js";
describe("extractMessageText", () => {
it("preserves user text that looks like tool call markers", () => {
const message = {
role: "user",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toContain("[Tool Call: foo (ID: 1)]");
});
it("sanitizes assistant tool call markers", () => {
const message = {
role: "assistant",
content: "Here [Tool Call: foo (ID: 1)] ok",
};
const result = extractMessageText(message);
expect(result?.text).toBe("Here ok");
});
});
@@ -0,0 +1,30 @@
import { describe, expect, it } from "vitest";
import { parseConfigCommand } from "./config-commands.js";
describe("parseConfigCommand", () => {
it("parses show/unset", () => {
expect(parseConfigCommand("/config")).toEqual({ action: "show" });
expect(parseConfigCommand("/config show")).toEqual({
action: "show",
path: undefined,
});
expect(parseConfigCommand("/config show foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config get foo.bar")).toEqual({
action: "show",
path: "foo.bar",
});
expect(parseConfigCommand("/config unset foo.bar")).toEqual({
action: "unset",
path: "foo.bar",
});
});
it("parses set with JSON", () => {
const cmd = parseConfigCommand('/config set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from "vitest";
import { parseDebugCommand } from "./debug-commands.js";
describe("parseDebugCommand", () => {
it("parses show/reset", () => {
expect(parseDebugCommand("/debug")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug show")).toEqual({ action: "show" });
expect(parseDebugCommand("/debug reset")).toEqual({ action: "reset" });
});
it("parses set with JSON", () => {
const cmd = parseDebugCommand('/debug set foo={"a":1}');
expect(cmd).toEqual({ action: "set", path: "foo", value: { a: 1 } });
});
it("parses unset", () => {
const cmd = parseDebugCommand("/debug unset foo.bar");
expect(cmd).toEqual({ action: "unset", path: "foo.bar" });
});
});
@@ -5,12 +5,8 @@ import type { ClawdbotConfig } from "../../config/config.js";
import type { SessionEntry } from "../../config/sessions.js";
import { parseInlineDirectives } from "./directive-handling.js";
import { handleDirectiveOnly } from "./directive-handling.impl.js";
import {
maybeHandleModelDirectiveInfo,
resolveModelSelectionFromDirective,
} from "./directive-handling.model.js";
// Mock dependencies for directive handling persistence.
// Mock dependencies
vi.mock("../../agents/agent-scope.js", () => ({
resolveAgentConfig: vi.fn(() => ({})),
resolveAgentDir: vi.fn(() => "/tmp/agent"),
@@ -40,55 +36,6 @@ function baseConfig(): ClawdbotConfig {
} as unknown as ClawdbotConfig;
}
describe("/model chat UX", () => {
it("shows summary for /model with no args", async () => {
const directives = parseInlineDirectives("/model");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const reply = await maybeHandleModelDirectiveInfo({
directives,
cfg,
agentDir: "/tmp/agent",
activeAgentId: "main",
provider: "anthropic",
model: "claude-opus-4-5",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelCatalog: [],
resetModelOverride: false,
});
expect(reply?.text).toContain("Current:");
expect(reply?.text).toContain("Browse: /models");
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("auto-applies closest match for typos", () => {
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const resolved = resolveModelSelectionFromDirective({
directives,
cfg,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
provider: "anthropic",
});
expect(resolved.modelSelection).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
isDefault: true,
});
expect(resolved.errorText).toBeUndefined();
});
});
describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
const allowedModelKeys = new Set(["anthropic/claude-opus-4-5", "openai/gpt-4o"]);
const allowedModelCatalog = [
@@ -159,6 +106,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
formatModelSwitchEvent: (label) => `Switched to ${label}`,
});
// No model directive = no model message
expect(result?.text ?? "").not.toContain("Model set to");
expect(result?.text ?? "").not.toContain("failed");
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import type { ModelAliasIndex } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { parseInlineDirectives } from "./directive-handling.js";
import {
maybeHandleModelDirectiveInfo,
resolveModelSelectionFromDirective,
} from "./directive-handling.model.js";
function baseAliasIndex(): ModelAliasIndex {
return { byAlias: new Map(), byKey: new Map() };
}
describe("/model chat UX", () => {
it("shows summary for /model with no args", async () => {
const directives = parseInlineDirectives("/model");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const reply = await maybeHandleModelDirectiveInfo({
directives,
cfg,
agentDir: "/tmp/agent",
activeAgentId: "main",
provider: "anthropic",
model: "claude-opus-4-5",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelCatalog: [],
resetModelOverride: false,
});
expect(reply?.text).toContain("Current:");
expect(reply?.text).toContain("Browse: /models");
expect(reply?.text).toContain("Switch: /model <provider/model>");
});
it("auto-applies closest match for typos", () => {
const directives = parseInlineDirectives("/model anthropic/claud-opus-4-5");
const cfg = { commands: { text: true } } as unknown as ClawdbotConfig;
const resolved = resolveModelSelectionFromDirective({
directives,
cfg,
agentDir: "/tmp/agent",
defaultProvider: "anthropic",
defaultModel: "claude-opus-4-5",
aliasIndex: baseAliasIndex(),
allowedModelKeys: new Set(["anthropic/claude-opus-4-5"]),
allowedModelCatalog: [{ provider: "anthropic", id: "claude-opus-4-5" }],
provider: "anthropic",
});
expect(resolved.modelSelection).toEqual({
provider: "anthropic",
model: "claude-opus-4-5",
isDefault: true,
});
expect(resolved.errorText).toBeUndefined();
});
});
@@ -0,0 +1,105 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import type { FollowupRun } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
const runEmbeddedPiAgentMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: async ({
provider,
model,
run,
}: {
provider: string;
model: string;
run: (provider: string, model: string) => Promise<unknown>;
}) => ({
result: await run(provider, model),
provider,
model,
}),
}));
vi.mock("../../agents/pi-embedded.js", () => ({
runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params),
}));
import { createFollowupRunner } from "./followup-runner.js";
describe("createFollowupRunner compaction", () => {
it("adds verbose auto-compaction notice and tracks count", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
"sessions.json",
);
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = {
main: sessionEntry,
};
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}) => {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false },
});
return { payloads: [{ text: "final" }], meta: {} };
},
);
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
defaultModel: "anthropic/claude-opus-4-5",
});
const queued = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "on",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as FollowupRun;
await runner(queued);
expect(onBlockReply).toHaveBeenCalled();
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
expect(sessionStore.main.compactionCount).toBe(1);
});
});
@@ -1,9 +1,5 @@
import fs from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
import type { FollowupRun } from "./queue.js";
import { createMockTypingController } from "./test-helpers.js";
@@ -61,79 +57,6 @@ const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun =>
},
}) as FollowupRun;
describe("createFollowupRunner compaction", () => {
it("adds verbose auto-compaction notice and tracks count", async () => {
const storePath = path.join(
await fs.mkdtemp(path.join(tmpdir(), "clawdbot-compaction-")),
"sessions.json",
);
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
};
const sessionStore: Record<string, SessionEntry> = {
main: sessionEntry,
};
const onBlockReply = vi.fn(async () => {});
runEmbeddedPiAgentMock.mockImplementationOnce(
async (params: {
onAgentEvent?: (evt: { stream: string; data: Record<string, unknown> }) => void;
}) => {
params.onAgentEvent?.({
stream: "compaction",
data: { phase: "end", willRetry: false },
});
return { payloads: [{ text: "final" }], meta: {} };
},
);
const runner = createFollowupRunner({
opts: { onBlockReply },
typing: createMockTypingController(),
typingMode: "instant",
sessionEntry,
sessionStore,
sessionKey: "main",
storePath,
defaultModel: "anthropic/claude-opus-4-5",
});
const queued = {
prompt: "hello",
summaryLine: "hello",
enqueuedAt: Date.now(),
run: {
sessionId: "session",
sessionKey: "main",
messageProvider: "whatsapp",
sessionFile: "/tmp/session.jsonl",
workspaceDir: "/tmp",
config: {},
skillsSnapshot: {},
provider: "anthropic",
model: "claude",
thinkLevel: "low",
verboseLevel: "on",
elevatedLevel: "off",
bashElevated: {
enabled: false,
allowed: false,
defaultLevel: "off",
},
timeoutMs: 1_000,
blockReplyBreak: "message_end",
},
} as FollowupRun;
await runner(queued);
expect(onBlockReply).toHaveBeenCalled();
expect(onBlockReply.mock.calls[0][0].text).toContain("Auto-compaction complete");
expect(sessionStore.main.compactionCount).toBe(1);
});
});
describe("createFollowupRunner messaging tool dedupe", () => {
it("drops payloads already sent via messaging tool", async () => {
const onBlockReply = vi.fn(async () => {});
-185
View File
@@ -1,185 +0,0 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { parseAudioTag } from "./audio-tags.js";
import { createBlockReplyCoalescer } from "./block-reply-coalescer.js";
import { createReplyReferencePlanner } from "./reply-reference.js";
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
describe("parseAudioTag", () => {
it("detects audio_as_voice and strips the tag", () => {
const result = parseAudioTag("Hello [[audio_as_voice]] world");
expect(result.audioAsVoice).toBe(true);
expect(result.hadTag).toBe(true);
expect(result.text).toBe("Hello world");
});
it("returns empty output for missing text", () => {
const result = parseAudioTag(undefined);
expect(result.audioAsVoice).toBe(false);
expect(result.hadTag).toBe(false);
expect(result.text).toBe("");
});
it("removes tag-only messages", () => {
const result = parseAudioTag("[[audio_as_voice]]");
expect(result.audioAsVoice).toBe(true);
expect(result.text).toBe("");
});
});
describe("block reply coalescer", () => {
afterEach(() => {
vi.useRealTimers();
});
it("coalesces chunks within the idle window", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 100, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
await vi.advanceTimersByTimeAsync(100);
expect(flushes).toEqual(["Hello world"]);
coalescer.stop();
});
it("waits until minChars before idle flush", async () => {
vi.useFakeTimers();
const flushes: string[] = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 10, maxChars: 200, idleMs: 50, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push(payload.text ?? "");
},
});
coalescer.enqueue({ text: "short" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual([]);
coalescer.enqueue({ text: "message" });
await vi.advanceTimersByTimeAsync(50);
expect(flushes).toEqual(["short message"]);
coalescer.stop();
});
it("flushes buffered text before media payloads", () => {
const flushes: Array<{ text?: string; mediaUrls?: string[] }> = [];
const coalescer = createBlockReplyCoalescer({
config: { minChars: 1, maxChars: 200, idleMs: 0, joiner: " " },
shouldAbort: () => false,
onFlush: (payload) => {
flushes.push({
text: payload.text,
mediaUrls: payload.mediaUrls,
});
},
});
coalescer.enqueue({ text: "Hello" });
coalescer.enqueue({ text: "world" });
coalescer.enqueue({ mediaUrls: ["https://example.com/a.png"] });
void coalescer.flush({ force: true });
expect(flushes[0].text).toBe("Hello world");
expect(flushes[1].mediaUrls).toEqual(["https://example.com/a.png"]);
coalescer.stop();
});
});
describe("createReplyReferencePlanner", () => {
it("disables references when mode is off", () => {
const planner = createReplyReferencePlanner({
replyToMode: "off",
startId: "parent",
});
expect(planner.use()).toBeUndefined();
expect(planner.hasReplied()).toBe(false);
});
it("uses startId once when mode is first", () => {
const planner = createReplyReferencePlanner({
replyToMode: "first",
startId: "parent",
});
expect(planner.use()).toBe("parent");
expect(planner.hasReplied()).toBe(true);
planner.markSent();
expect(planner.use()).toBeUndefined();
});
it("returns startId for every call when mode is all", () => {
const planner = createReplyReferencePlanner({
replyToMode: "all",
startId: "parent",
});
expect(planner.use()).toBe("parent");
expect(planner.use()).toBe("parent");
});
it("prefers existing thread id regardless of mode", () => {
const planner = createReplyReferencePlanner({
replyToMode: "off",
existingId: "thread-1",
startId: "parent",
});
expect(planner.use()).toBe("thread-1");
expect(planner.hasReplied()).toBe(true);
});
it("honors allowReference=false", () => {
const planner = createReplyReferencePlanner({
replyToMode: "all",
startId: "parent",
allowReference: false,
});
expect(planner.use()).toBeUndefined();
expect(planner.hasReplied()).toBe(false);
planner.markSent();
expect(planner.hasReplied()).toBe(true);
});
});
describe("createStreamingDirectiveAccumulator", () => {
it("stashes reply_to_current until a renderable chunk arrives", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
const result = accumulator.consume("Hello");
expect(result?.text).toBe("Hello");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("handles reply tags split across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_")).toBeNull();
const result = accumulator.consume("current]] Yo");
expect(result?.text).toBe("Yo");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("propagates explicit reply ids across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
const result = accumulator.consume("Hi");
expect(result?.text).toBe("Hi");
expect(result?.replyToId).toBe("abc-123");
expect(result?.replyToTag).toBe(true);
});
});
+7 -10
View File
@@ -28,7 +28,6 @@ export async function getReplyFromConfig(
opts?: GetReplyOptions,
configOverride?: ClawdbotConfig,
): Promise<ReplyPayload | ReplyPayload[] | undefined> {
const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1";
const cfg = configOverride ?? loadConfig();
const targetSessionKey =
ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined;
@@ -63,7 +62,7 @@ export async function getReplyFromConfig(
const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR;
const workspace = await ensureAgentWorkspace({
dir: workspaceDirRaw,
ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv,
ensureBootstrapFiles: !agentCfg?.skipBootstrap,
});
const workspaceDir = workspace.dir;
const agentDir = resolveAgentDir(cfg, agentId);
@@ -82,14 +81,12 @@ export async function getReplyFromConfig(
const finalized = finalizeInboundContext(ctx);
if (!isFastTestEnv) {
await applyMediaUnderstanding({
ctx: finalized,
cfg,
agentDir,
activeModel: { provider, model },
});
}
await applyMediaUnderstanding({
ctx: finalized,
cfg,
agentDir,
activeModel: { provider, model },
});
const commandAuthorized = finalized.CommandAuthorized;
resolveCommandAuthorization({
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import type { GroupKeyResolution } from "../../config/sessions.js";
import type { TemplateContext } from "../templating.js";
import { resolveGroupRequireMention } from "./groups.js";
describe("resolveGroupRequireMention", () => {
it("respects Discord guild/channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
discord: {
guilds: {
"145": {
requireMention: false,
channels: {
general: { allow: true },
},
},
},
},
},
};
const ctx: TemplateContext = {
Provider: "discord",
From: "discord:group:123",
GroupChannel: "#general",
GroupSpace: "145",
};
const groupResolution: GroupKeyResolution = {
channel: "discord",
id: "123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
it("respects Slack channel requireMention settings", () => {
const cfg: ClawdbotConfig = {
channels: {
slack: {
channels: {
C123: { requireMention: false },
},
},
},
};
const ctx: TemplateContext = {
Provider: "slack",
From: "slack:channel:C123",
GroupSubject: "#general",
};
const groupResolution: GroupKeyResolution = {
channel: "slack",
id: "C123",
chatType: "group",
};
expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false);
});
});
@@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import type { MsgContext } from "../templating.js";
import { finalizeInboundContext } from "./inbound-context.js";
describe("finalizeInboundContext", () => {
it("fills BodyForAgent/BodyForCommands and normalizes newlines", () => {
const ctx: MsgContext = {
Body: "a\\nb\r\nc",
RawBody: "raw\\nline",
ChatType: "channel",
From: "whatsapp:group:123@g.us",
GroupSubject: "Test",
};
const out = finalizeInboundContext(ctx);
expect(out.Body).toBe("a\nb\nc");
expect(out.RawBody).toBe("raw\nline");
expect(out.BodyForAgent).toBe("a\nb\nc");
expect(out.BodyForCommands).toBe("raw\nline");
expect(out.CommandAuthorized).toBe(false);
expect(out.ChatType).toBe("channel");
expect(out.ConversationLabel).toContain("Test");
});
it("can force BodyForCommands to follow updated CommandBody", () => {
const ctx: MsgContext = {
Body: "base",
BodyForCommands: "<media:audio>",
CommandBody: "say hi",
From: "signal:+15550001111",
ChatType: "direct",
};
finalizeInboundContext(ctx, { forceBodyForCommands: true });
expect(ctx.BodyForCommands).toBe("say hi");
});
});
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { MsgContext } from "../templating.js";
import {
buildInboundDedupeKey,
resetInboundDedupe,
shouldSkipDuplicateInbound,
} from "./inbound-dedupe.js";
describe("inbound dedupe", () => {
it("builds a stable key when MessageSid is present", () => {
const ctx: MsgContext = {
Provider: "telegram",
OriginatingChannel: "telegram",
OriginatingTo: "telegram:123",
MessageSid: "42",
};
expect(buildInboundDedupeKey(ctx)).toBe("telegram|telegram:123|42");
});
it("skips duplicates with the same key", () => {
resetInboundDedupe();
const ctx: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(shouldSkipDuplicateInbound(ctx, { now: 100 })).toBe(false);
expect(shouldSkipDuplicateInbound(ctx, { now: 200 })).toBe(true);
});
it("does not dedupe when the peer changes", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+1000" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, OriginatingTo: "whatsapp:+2000" }, { now: 200 }),
).toBe(false);
});
it("does not dedupe across session keys", () => {
resetInboundDedupe();
const base: MsgContext = {
Provider: "whatsapp",
OriginatingChannel: "whatsapp",
OriginatingTo: "whatsapp:+1555",
MessageSid: "msg-1",
};
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 100 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:bravo:main" }, { now: 200 }),
).toBe(false);
expect(
shouldSkipDuplicateInbound({ ...base, SessionKey: "agent:alpha:main" }, { now: 300 }),
).toBe(true);
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import type { MsgContext } from "../templating.js";
import { formatInboundBodyWithSenderMeta } from "./inbound-sender-meta.js";
describe("formatInboundBodyWithSenderMeta", () => {
it("does nothing for direct messages", () => {
const ctx: MsgContext = { ChatType: "direct", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe("[X] hi");
});
it("appends a sender meta line for non-direct messages", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("prefers SenderE164 in the label when present", () => {
const ctx: MsgContext = {
ChatType: "group",
SenderName: "Bob",
SenderId: "bob@s.whatsapp.net",
SenderE164: "+222",
};
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi" })).toBe(
"[X] hi\n[from: Bob (+222)]",
);
});
it("appends with a real newline even if the body contains literal \\\\n", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Bob", SenderId: "+222" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] one\\n[X] two" })).toBe(
"[X] one\\n[X] two\n[from: Bob (+222)]",
);
});
it("does not duplicate a sender meta line when one is already present", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[X] hi\n[from: Alice (A1)]" })).toBe(
"[X] hi\n[from: Alice (A1)]",
);
});
it("does not append when the body already includes a sender prefix", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "Alice (A1): hi" })).toBe("Alice (A1): hi");
});
it("does not append when the sender prefix follows an envelope header", () => {
const ctx: MsgContext = { ChatType: "group", SenderName: "Alice", SenderId: "A1" };
expect(formatInboundBodyWithSenderMeta({ ctx, body: "[Signal Group] Alice (A1): hi" })).toBe(
"[Signal Group] Alice (A1): hi",
);
});
});
+18
View File
@@ -0,0 +1,18 @@
import { describe, expect, it } from "vitest";
import { normalizeInboundTextNewlines } from "./inbound-text.js";
describe("normalizeInboundTextNewlines", () => {
it("keeps real newlines", () => {
expect(normalizeInboundTextNewlines("a\nb")).toBe("a\nb");
});
it("normalizes CRLF/CR to LF", () => {
expect(normalizeInboundTextNewlines("a\r\nb")).toBe("a\nb");
expect(normalizeInboundTextNewlines("a\rb")).toBe("a\nb");
});
it("decodes literal \\\\n to newlines when no real newlines exist", () => {
expect(normalizeInboundTextNewlines("a\\nb")).toBe("a\nb");
});
});
+47
View File
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { buildMentionRegexes, matchesMentionPatterns, normalizeMentionText } from "./mentions.js";
describe("mention helpers", () => {
it("builds regexes and skips invalid patterns", () => {
const regexes = buildMentionRegexes({
messages: {
groupChat: { mentionPatterns: ["\\bclawd\\b", "(invalid"] },
},
});
expect(regexes).toHaveLength(1);
expect(regexes[0]?.test("clawd")).toBe(true);
});
it("normalizes zero-width characters", () => {
expect(normalizeMentionText("cl\u200bawd")).toBe("clawd");
});
it("matches patterns case-insensitively", () => {
const regexes = buildMentionRegexes({
messages: { groupChat: { mentionPatterns: ["\\bclawd\\b"] } },
});
expect(matchesMentionPatterns("CLAWD: hi", regexes)).toBe(true);
});
it("uses per-agent mention patterns when configured", () => {
const regexes = buildMentionRegexes(
{
messages: {
groupChat: { mentionPatterns: ["\\bglobal\\b"] },
},
agents: {
list: [
{
id: "work",
groupChat: { mentionPatterns: ["\\bworkbot\\b"] },
},
],
},
},
"work",
);
expect(matchesMentionPatterns("workbot: hi", regexes)).toBe(true);
expect(matchesMentionPatterns("global: hi", regexes)).toBe(false);
});
});
@@ -1,11 +1,6 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { HEARTBEAT_TOKEN, SILENT_REPLY_TOKEN } from "../tokens.js";
import { createReplyDispatcher } from "./reply-dispatcher.js";
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
const emptyCfg = {} as ClawdbotConfig;
describe("createReplyDispatcher", () => {
it("drops empty payloads and silent tokens without media", async () => {
@@ -155,94 +150,3 @@ describe("createReplyDispatcher", () => {
vi.useRealTimers();
});
});
describe("resolveReplyToMode", () => {
it("defaults to first for Telegram", () => {
expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first");
});
it("defaults to off for Discord and Slack", () => {
expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off");
expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off");
});
it("defaults to all when channel is unknown", () => {
expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all");
});
it("uses configured value when present", () => {
const cfg = {
channels: {
telegram: { replyToMode: "all" },
discord: { replyToMode: "first" },
slack: { replyToMode: "all" },
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
});
it("uses chat-type replyToMode overrides for Slack when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all", group: "first" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
});
it("falls back to top-level replyToMode when no chat-type override is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
});
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
});
});
describe("createReplyToModeFilter", () => {
it("drops replyToId when mode is off", () => {
const filter = createReplyToModeFilter("off");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
});
it("keeps replyToId when mode is off and reply tags are allowed", () => {
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
});
it("keeps replyToId when mode is all", () => {
const filter = createReplyToModeFilter("all");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
});
it("keeps only the first replyToId when mode is first", () => {
const filter = createReplyToModeFilter("first");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined();
});
});
@@ -0,0 +1,56 @@
import { describe, expect, it } from "vitest";
import { createReplyReferencePlanner } from "./reply-reference.js";
describe("createReplyReferencePlanner", () => {
it("disables references when mode is off", () => {
const planner = createReplyReferencePlanner({
replyToMode: "off",
startId: "parent",
});
expect(planner.use()).toBeUndefined();
expect(planner.hasReplied()).toBe(false);
});
it("uses startId once when mode is first", () => {
const planner = createReplyReferencePlanner({
replyToMode: "first",
startId: "parent",
});
expect(planner.use()).toBe("parent");
expect(planner.hasReplied()).toBe(true);
planner.markSent();
expect(planner.use()).toBeUndefined();
});
it("returns startId for every call when mode is all", () => {
const planner = createReplyReferencePlanner({
replyToMode: "all",
startId: "parent",
});
expect(planner.use()).toBe("parent");
expect(planner.use()).toBe("parent");
});
it("prefers existing thread id regardless of mode", () => {
const planner = createReplyReferencePlanner({
replyToMode: "off",
existingId: "thread-1",
startId: "parent",
});
expect(planner.use()).toBe("thread-1");
expect(planner.hasReplied()).toBe(true);
});
it("honors allowReference=false", () => {
const planner = createReplyReferencePlanner({
replyToMode: "all",
startId: "parent",
allowReference: false,
});
expect(planner.use()).toBeUndefined();
expect(planner.hasReplied()).toBe(false);
planner.markSent();
expect(planner.hasReplied()).toBe(true);
});
});
@@ -0,0 +1,97 @@
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { createReplyToModeFilter, resolveReplyToMode } from "./reply-threading.js";
const emptyCfg = {} as ClawdbotConfig;
describe("resolveReplyToMode", () => {
it("defaults to first for Telegram", () => {
expect(resolveReplyToMode(emptyCfg, "telegram")).toBe("first");
});
it("defaults to off for Discord and Slack", () => {
expect(resolveReplyToMode(emptyCfg, "discord")).toBe("off");
expect(resolveReplyToMode(emptyCfg, "slack")).toBe("off");
});
it("defaults to all when channel is unknown", () => {
expect(resolveReplyToMode(emptyCfg, undefined)).toBe("all");
});
it("uses configured value when present", () => {
const cfg = {
channels: {
telegram: { replyToMode: "all" },
discord: { replyToMode: "first" },
slack: { replyToMode: "all" },
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "telegram")).toBe("all");
expect(resolveReplyToMode(cfg, "discord")).toBe("first");
expect(resolveReplyToMode(cfg, "slack")).toBe("all");
});
it("uses chat-type replyToMode overrides for Slack when configured", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
replyToModeByChatType: { direct: "all", group: "first" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "group")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
expect(resolveReplyToMode(cfg, "slack", null, undefined)).toBe("off");
});
it("falls back to top-level replyToMode when no chat-type override is set", () => {
const cfg = {
channels: {
slack: {
replyToMode: "first",
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("first");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("first");
});
it("uses legacy dm.replyToMode for direct messages when no chat-type override exists", () => {
const cfg = {
channels: {
slack: {
replyToMode: "off",
dm: { replyToMode: "all" },
},
},
} as ClawdbotConfig;
expect(resolveReplyToMode(cfg, "slack", null, "direct")).toBe("all");
expect(resolveReplyToMode(cfg, "slack", null, "channel")).toBe("off");
});
});
describe("createReplyToModeFilter", () => {
it("drops replyToId when mode is off", () => {
const filter = createReplyToModeFilter("off");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBeUndefined();
});
it("keeps replyToId when mode is off and reply tags are allowed", () => {
const filter = createReplyToModeFilter("off", { allowTagsWhenOff: true });
expect(filter({ text: "hi", replyToId: "1", replyToTag: true }).replyToId).toBe("1");
});
it("keeps replyToId when mode is all", () => {
const filter = createReplyToModeFilter("all");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
});
it("keeps only the first replyToId when mode is first", () => {
const filter = createReplyToModeFilter("first");
expect(filter({ text: "hi", replyToId: "1" }).replyToId).toBe("1");
expect(filter({ text: "next", replyToId: "1" }).replyToId).toBeUndefined();
});
});
@@ -2,21 +2,10 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { buildModelAliasIndex } from "../../agents/model-selection.js";
import type { ClawdbotConfig } from "../../config/config.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { initSessionState } from "./session.js";
import { applyResetModelOverride } from "./session-reset-model.js";
import { prependSystemEvents } from "./session-updates.js";
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "minimax", id: "m2.1", name: "M2.1" },
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
]),
}));
describe("initSessionState reset triggers in WhatsApp groups", () => {
async function createStorePath(prefix: string): Promise<string> {
@@ -65,6 +54,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
allowFrom: ["+41796666864"],
});
// Group message context matching what WhatsApp handler creates
const groupMessageCtx = {
Body: `[Chat messages since your last reply - for context]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Someone: hello\\n\\n[Current message - respond to this]\\n[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Peschiño: /new\\n[from: Peschiño (+41796666864)]`,
RawBody: "/new",
@@ -86,6 +76,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
commandAuthorized: true,
});
// The reset should be detected
expect(result.triggerBodyNormalized).toBe("/new");
expect(result.isNewSession).toBe(true);
expect(result.sessionId).not.toBe(existingSessionId);
@@ -108,6 +99,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
allowFrom: ["+41796666864"],
});
// Group message from different sender (not in allowFrom)
const groupMessageCtx = {
Body: `[Context]\\n[WhatsApp ...] OtherPerson: /new\\n[from: OtherPerson (+1555123456)]`,
RawBody: "/new",
@@ -119,7 +111,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
Provider: "whatsapp",
Surface: "whatsapp",
SenderName: "OtherPerson",
SenderE164: "+1555123456",
SenderE164: "+1555123456", // Different sender (not authorized)
SenderId: "1555123456:0@s.whatsapp.net",
};
@@ -129,8 +121,9 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
commandAuthorized: true,
});
// Reset should NOT be triggered for unauthorized sender - session ID should stay the same
expect(result.triggerBodyNormalized).toBe("/new");
expect(result.sessionId).toBe(existingSessionId);
expect(result.sessionId).toBe(existingSessionId); // Session should NOT change
expect(result.isNewSession).toBe(false);
});
@@ -150,7 +143,9 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
});
const groupMessageCtx = {
// Body is wrapped with context prefixes
Body: `[WhatsApp 120363406150318674@g.us 2026-01-13T07:45Z] Jake: /new\n[from: Jake (+1222)]`,
// RawBody is clean
RawBody: "/new",
CommandBody: "/new",
From: "120363406150318674@g.us",
@@ -256,124 +251,3 @@ describe("initSessionState reset triggers in WhatsApp groups", () => {
expect(result.isNewSession).toBe(false);
});
});
describe("applyResetModelOverride", () => {
it("selects a model hint and strips it from the body", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBe("minimax");
expect(sessionEntry.modelOverride).toBe("m2.1");
expect(sessionCtx.BodyStripped).toBe("summarize");
});
it("clears auth profile overrides when reset applies a model", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: "anthropic:default",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.authProfileOverride).toBeUndefined();
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
});
it("skips when resetTriggered is false", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: false,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBeUndefined();
expect(sessionEntry.modelOverride).toBeUndefined();
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
});
});
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => {
vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp);
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
const result = await prependSystemEvents({
cfg: {} as ClawdbotConfig,
sessionKey: "agent:main:main",
isMainSession: false,
isNewSession: false,
prefixedBodyBase: "User: hi",
});
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});
@@ -0,0 +1,107 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { buildModelAliasIndex } from "../../agents/model-selection.js";
import { applyResetModelOverride } from "./session-reset-model.js";
vi.mock("../../agents/model-catalog.js", () => ({
loadModelCatalog: vi.fn(async () => [
{ provider: "minimax", id: "m2.1", name: "M2.1" },
{ provider: "openai", id: "gpt-4o-mini", name: "GPT-4o mini" },
]),
}));
describe("applyResetModelOverride", () => {
it("selects a model hint and strips it from the body", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBe("minimax");
expect(sessionEntry.modelOverride).toBe("m2.1");
expect(sessionCtx.BodyStripped).toBe("summarize");
});
it("clears auth profile overrides when reset applies a model", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
authProfileOverride: "anthropic:default",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: true,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.authProfileOverride).toBeUndefined();
expect(sessionEntry.authProfileOverrideSource).toBeUndefined();
expect(sessionEntry.authProfileOverrideCompactionCount).toBeUndefined();
});
it("skips when resetTriggered is false", async () => {
const cfg = {} as ClawdbotConfig;
const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: "openai" });
const sessionEntry = {
sessionId: "s1",
updatedAt: Date.now(),
};
const sessionStore = { "agent:main:dm:1": sessionEntry };
const sessionCtx = { BodyStripped: "minimax summarize" };
const ctx = { ChatType: "direct" };
await applyResetModelOverride({
cfg,
resetTriggered: false,
bodyStripped: "minimax summarize",
sessionCtx,
ctx,
sessionEntry,
sessionStore,
sessionKey: "agent:main:dm:1",
defaultProvider: "openai",
defaultModel: "gpt-4o-mini",
aliasIndex,
});
expect(sessionEntry.providerOverride).toBeUndefined();
expect(sessionEntry.modelOverride).toBeUndefined();
expect(sessionCtx.BodyStripped).toBe("minimax summarize");
});
});
@@ -0,0 +1,31 @@
import { describe, expect, it, vi } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system-events.js";
import { prependSystemEvents } from "./session-updates.js";
describe("prependSystemEvents", () => {
it("adds a local timestamp to queued system events by default", async () => {
vi.useFakeTimers();
const originalTz = process.env.TZ;
process.env.TZ = "America/Los_Angeles";
const timestamp = new Date("2026-01-12T20:19:17Z");
vi.setSystemTime(timestamp);
enqueueSystemEvent("Model switched.", { sessionKey: "agent:main:main" });
const result = await prependSystemEvents({
cfg: {} as ClawdbotConfig,
sessionKey: "agent:main:main",
isMainSession: false,
isNewSession: false,
prefixedBodyBase: "User: hi",
});
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
resetSystemEventsForTest();
process.env.TZ = originalTz;
vi.useRealTimers();
});
});
@@ -0,0 +1,51 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { ClawdbotConfig } from "../../config/config.js";
import { initSessionState } from "./session.js";
describe("initSessionState sender meta", () => {
it("injects sender meta into BodyStripped for group chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp 123@g.us] ping",
ChatType: "group",
SenderName: "Bob",
SenderE164: "+222",
SenderId: "222@s.whatsapp.net",
SessionKey: "agent:main:whatsapp:group:123@g.us",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp 123@g.us] ping\n[from: Bob (+222)]");
});
it("does not inject sender meta for direct chats", async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sender-meta-direct-"));
const storePath = path.join(root, "sessions.json");
const cfg = { session: { store: storePath } } as ClawdbotConfig;
const result = await initSessionState({
ctx: {
Body: "[WhatsApp +1] ping",
ChatType: "direct",
SenderName: "Bob",
SenderE164: "+222",
SessionKey: "agent:main:whatsapp:dm:+222",
},
cfg,
commandAuthorized: true,
});
expect(result.sessionCtx.BodyStripped).toBe("[WhatsApp +1] ping");
});
});
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { createStreamingDirectiveAccumulator } from "./streaming-directives.js";
describe("createStreamingDirectiveAccumulator", () => {
it("stashes reply_to_current until a renderable chunk arrives", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_current]]")).toBeNull();
const result = accumulator.consume("Hello");
expect(result?.text).toBe("Hello");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("handles reply tags split across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to_")).toBeNull();
const result = accumulator.consume("current]] Yo");
expect(result?.text).toBe("Yo");
expect(result?.replyToCurrent).toBe(true);
expect(result?.replyToTag).toBe(true);
});
it("propagates explicit reply ids across chunks", () => {
const accumulator = createStreamingDirectiveAccumulator();
expect(accumulator.consume("[[reply_to: abc-123]]")).toBeNull();
const result = accumulator.consume("Hi");
expect(result?.text).toBe("Hi");
expect(result?.replyToId).toBe("abc-123");
expect(result?.replyToTag).toBe(true);
});
});
+193
View File
@@ -0,0 +1,193 @@
import { describe, expect, it, vi } from "vitest";
import { createMockTypingController } from "./test-helpers.js";
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
describe("resolveTypingMode", () => {
it("defaults to instant for direct chats", () => {
expect(
resolveTypingMode({
configured: undefined,
isGroupChat: false,
wasMentioned: false,
isHeartbeat: false,
}),
).toBe("instant");
});
it("defaults to message for group chats without mentions", () => {
expect(
resolveTypingMode({
configured: undefined,
isGroupChat: true,
wasMentioned: false,
isHeartbeat: false,
}),
).toBe("message");
});
it("defaults to instant for mentioned group chats", () => {
expect(
resolveTypingMode({
configured: undefined,
isGroupChat: true,
wasMentioned: true,
isHeartbeat: false,
}),
).toBe("instant");
});
it("honors configured mode across contexts", () => {
expect(
resolveTypingMode({
configured: "thinking",
isGroupChat: false,
wasMentioned: false,
isHeartbeat: false,
}),
).toBe("thinking");
expect(
resolveTypingMode({
configured: "message",
isGroupChat: true,
wasMentioned: true,
isHeartbeat: false,
}),
).toBe("message");
});
it("forces never for heartbeat runs", () => {
expect(
resolveTypingMode({
configured: "instant",
isGroupChat: false,
wasMentioned: false,
isHeartbeat: true,
}),
).toBe("never");
});
});
describe("createTypingSignaler", () => {
it("signals immediately for instant mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "instant",
isHeartbeat: false,
});
await signaler.signalRunStart();
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("signals on text for message mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalTextDelta("hello");
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("signals on message start for message mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalMessageStart();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
await signaler.signalTextDelta("hello");
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
});
it("signals on reasoning for thinking mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "thinking",
isHeartbeat: false,
});
await signaler.signalReasoningDelta();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
await signaler.signalTextDelta("hi");
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("refreshes ttl on text for thinking mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "thinking",
isHeartbeat: false,
});
await signaler.signalTextDelta("hi");
expect(typing.startTypingLoop).toHaveBeenCalled();
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
it("starts typing on tool start before text", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalToolStart();
expect(typing.startTypingLoop).toHaveBeenCalled();
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
it("refreshes ttl on tool start when active after text", async () => {
const typing = createMockTypingController({
isActive: vi.fn(() => true),
});
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalTextDelta("hello");
typing.startTypingLoop.mockClear();
typing.startTypingOnText.mockClear();
typing.refreshTypingTtl.mockClear();
await signaler.signalToolStart();
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("suppresses typing when disabled", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "instant",
isHeartbeat: true,
});
await signaler.signalRunStart();
await signaler.signalTextDelta("hi");
await signaler.signalReasoningDelta();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
});
-191
View File
@@ -1,7 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createMockTypingController } from "./test-helpers.js";
import { createTypingSignaler, resolveTypingMode } from "./typing-mode.js";
import { createTypingController } from "./typing.js";
describe("typing controller", () => {
@@ -93,192 +91,3 @@ describe("typing controller", () => {
expect(onReplyStart).toHaveBeenCalledTimes(1);
});
});
describe("resolveTypingMode", () => {
it("defaults to instant for direct chats", () => {
expect(
resolveTypingMode({
configured: undefined,
isGroupChat: false,
wasMentioned: false,
isHeartbeat: false,
}),
).toBe("instant");
});
it("defaults to message for group chats without mentions", () => {
expect(
resolveTypingMode({
configured: undefined,
isGroupChat: true,
wasMentioned: false,
isHeartbeat: false,
}),
).toBe("message");
});
it("defaults to instant for mentioned group chats", () => {
expect(
resolveTypingMode({
configured: undefined,
isGroupChat: true,
wasMentioned: true,
isHeartbeat: false,
}),
).toBe("instant");
});
it("honors configured mode across contexts", () => {
expect(
resolveTypingMode({
configured: "thinking",
isGroupChat: false,
wasMentioned: false,
isHeartbeat: false,
}),
).toBe("thinking");
expect(
resolveTypingMode({
configured: "message",
isGroupChat: true,
wasMentioned: true,
isHeartbeat: false,
}),
).toBe("message");
});
it("forces never for heartbeat runs", () => {
expect(
resolveTypingMode({
configured: "instant",
isGroupChat: false,
wasMentioned: false,
isHeartbeat: true,
}),
).toBe("never");
});
});
describe("createTypingSignaler", () => {
it("signals immediately for instant mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "instant",
isHeartbeat: false,
});
await signaler.signalRunStart();
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("signals on text for message mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalTextDelta("hello");
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("signals on message start for message mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalMessageStart();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
await signaler.signalTextDelta("hello");
expect(typing.startTypingOnText).toHaveBeenCalledWith("hello");
});
it("signals on reasoning for thinking mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "thinking",
isHeartbeat: false,
});
await signaler.signalReasoningDelta();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
await signaler.signalTextDelta("hi");
expect(typing.startTypingLoop).toHaveBeenCalled();
});
it("refreshes ttl on text for thinking mode", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "thinking",
isHeartbeat: false,
});
await signaler.signalTextDelta("hi");
expect(typing.startTypingLoop).toHaveBeenCalled();
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
it("starts typing on tool start before text", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalToolStart();
expect(typing.startTypingLoop).toHaveBeenCalled();
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
it("refreshes ttl on tool start when active after text", async () => {
const typing = createMockTypingController({
isActive: vi.fn(() => true),
});
const signaler = createTypingSignaler({
typing,
mode: "message",
isHeartbeat: false,
});
await signaler.signalTextDelta("hello");
typing.startTypingLoop.mockClear();
typing.startTypingOnText.mockClear();
typing.refreshTypingTtl.mockClear();
await signaler.signalToolStart();
expect(typing.refreshTypingTtl).toHaveBeenCalled();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
});
it("suppresses typing when disabled", async () => {
const typing = createMockTypingController();
const signaler = createTypingSignaler({
typing,
mode: "instant",
isHeartbeat: true,
});
await signaler.signalRunStart();
await signaler.signalTextDelta("hi");
await signaler.signalReasoningDelta();
expect(typing.startTypingLoop).not.toHaveBeenCalled();
expect(typing.startTypingOnText).not.toHaveBeenCalled();
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { applyTemplate, type TemplateContext } from "./templating.js";
describe("applyTemplate", () => {
it("renders primitive values", () => {
const ctx = { MessageSid: "sid", IsNewSession: "no" } as TemplateContext;
const overrides = ctx as Record<string, unknown>;
overrides.MessageSid = 42;
overrides.IsNewSession = true;
expect(applyTemplate("sid={{MessageSid}} new={{IsNewSession}}", ctx)).toBe("sid=42 new=true");
});
it("renders arrays of primitives", () => {
const ctx = { MediaPaths: ["a"] } as TemplateContext;
(ctx as Record<string, unknown>).MediaPaths = ["a", 2, true, null, { ok: false }];
expect(applyTemplate("paths={{MediaPaths}}", ctx)).toBe("paths=a,2,true");
});
it("drops object values", () => {
const ctx: TemplateContext = { CommandArgs: { raw: "go" } };
expect(applyTemplate("args={{CommandArgs}}", ctx)).toBe("args=");
});
it("renders missing placeholders as empty", () => {
const ctx: TemplateContext = {};
expect(applyTemplate("missing={{Missing}}", ctx)).toBe("missing=");
});
});
+1 -28
View File
@@ -71,36 +71,9 @@ export function registerModelsCli(program: Command) {
"Exit non-zero if auth is expiring/expired (1=expired/missing, 2=expiring)",
false,
)
.option("--probe", "Probe configured provider auth (live)", false)
.option("--probe-provider <name>", "Only probe a single provider")
.option(
"--probe-profile <id>",
"Only probe specific auth profile ids (repeat or comma-separated)",
(value, previous) => {
const next = Array.isArray(previous) ? previous : previous ? [previous] : [];
next.push(value);
return next;
},
)
.option("--probe-timeout <ms>", "Per-probe timeout in ms")
.option("--probe-concurrency <n>", "Concurrent probes")
.option("--probe-max-tokens <n>", "Probe max tokens (best-effort)")
.action(async (opts) => {
await runModelsCommand(async () => {
await modelsStatusCommand(
{
json: Boolean(opts.json),
plain: Boolean(opts.plain),
check: Boolean(opts.check),
probe: Boolean(opts.probe),
probeProvider: opts.probeProvider as string | undefined,
probeProfile: opts.probeProfile as string | string[] | undefined,
probeTimeout: opts.probeTimeout as string | undefined,
probeConcurrency: opts.probeConcurrency as string | undefined,
probeMaxTokens: opts.probeMaxTokens as string | undefined,
},
defaultRuntime,
);
await modelsStatusCommand(opts, defaultRuntime);
});
});
+134 -54
View File
@@ -122,62 +122,142 @@ describe("cli program (smoke)", () => {
expect(setupCommand).not.toHaveBeenCalled();
});
it("passes auth api keys to onboard", async () => {
const cases = [
{
it("passes opencode-zen api key to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"opencode-zen",
"--opencode-zen-api-key",
"sk-opencode-zen-test",
],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: "opencode-zen",
flag: "--opencode-zen-api-key",
key: "sk-opencode-zen-test",
field: "opencodeZenApiKey",
},
{
authChoice: "openrouter-api-key",
flag: "--openrouter-api-key",
key: "sk-openrouter-test",
field: "openrouterApiKey",
},
{
authChoice: "moonshot-api-key",
flag: "--moonshot-api-key",
key: "sk-moonshot-test",
field: "moonshotApiKey",
},
{
authChoice: "kimi-code-api-key",
flag: "--kimi-code-api-key",
key: "sk-kimi-code-test",
field: "kimiCodeApiKey",
},
{
authChoice: "synthetic-api-key",
flag: "--synthetic-api-key",
key: "sk-synthetic-test",
field: "syntheticApiKey",
},
{
authChoice: "zai-api-key",
flag: "--zai-api-key",
key: "sk-zai-test",
field: "zaiApiKey",
},
] as const;
opencodeZenApiKey: "sk-opencode-zen-test",
}),
runtime,
);
});
for (const entry of cases) {
const program = buildProgram();
await program.parseAsync(
["onboard", "--non-interactive", "--auth-choice", entry.authChoice, entry.flag, entry.key],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: entry.authChoice,
[entry.field]: entry.key,
}),
runtime,
);
onboardCommand.mockClear();
}
it("passes openrouter api key to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"openrouter-api-key",
"--openrouter-api-key",
"sk-openrouter-test",
],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: "openrouter-api-key",
openrouterApiKey: "sk-openrouter-test",
}),
runtime,
);
});
it("passes moonshot api key to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"moonshot-api-key",
"--moonshot-api-key",
"sk-moonshot-test",
],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: "moonshot-api-key",
moonshotApiKey: "sk-moonshot-test",
}),
runtime,
);
});
it("passes kimi code api key to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"kimi-code-api-key",
"--kimi-code-api-key",
"sk-kimi-code-test",
],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: "kimi-code-api-key",
kimiCodeApiKey: "sk-kimi-code-test",
}),
runtime,
);
});
it("passes synthetic api key to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"synthetic-api-key",
"--synthetic-api-key",
"sk-synthetic-test",
],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: "synthetic-api-key",
syntheticApiKey: "sk-synthetic-test",
}),
runtime,
);
});
it("passes zai api key to onboard", async () => {
const program = buildProgram();
await program.parseAsync(
[
"onboard",
"--non-interactive",
"--auth-choice",
"zai-api-key",
"--zai-api-key",
"sk-zai-test",
],
{ from: "user" },
);
expect(onboardCommand).toHaveBeenCalledWith(
expect.objectContaining({
nonInteractive: true,
authChoice: "zai-api-key",
zaiApiKey: "sk-zai-test",
}),
runtime,
);
});
it("runs channels login", async () => {
-1
View File
@@ -17,7 +17,6 @@ const discoverModels = vi.fn();
vi.mock("../config/config.js", () => ({
CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json",
STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state",
loadConfig,
}));
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more