diff --git a/AGENTS.md b/AGENTS.md index b381ceb2f..d7c76e235 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,6 +128,10 @@ - **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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 859c74a48..b33b621e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,21 @@ 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 +- Discord: limit autoThread mention bypass to bot-owned threads; keep ack reactions mention-gated. (#1511) Thanks @pvoo. +- Gateway: accept null optional fields in exec approval requests. (#1511) Thanks @pvoo. +- 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 diff --git a/apps/macos/Sources/Clawdbot/ExecApprovals.swift b/apps/macos/Sources/Clawdbot/ExecApprovals.swift index 537ceeaad..c6f413922 100644 --- a/apps/macos/Sources/Clawdbot/ExecApprovals.swift +++ b/apps/macos/Sources/Clawdbot/ExecApprovals.swift @@ -84,11 +84,52 @@ enum ExecApprovalDecision: String, Codable, Sendable { case deny } -struct ExecAllowlistEntry: Codable, Hashable { +struct ExecAllowlistEntry: Codable, Hashable, Identifiable { + var id: UUID 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 { @@ -295,6 +336,7 @@ 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, @@ -379,6 +421,7 @@ 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, @@ -398,6 +441,7 @@ enum ExecApprovalsStore { let cleaned = allowlist .map { item in ExecAllowlistEntry( + id: item.id, pattern: item.pattern.trimmingCharacters(in: .whitespacesAndNewlines), lastUsedAt: item.lastUsedAt, lastUsedCommand: item.lastUsedCommand, diff --git a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift index 0ac799e6d..eef826c3f 100644 --- a/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift +++ b/apps/macos/Sources/Clawdbot/SystemRunSettingsView.swift @@ -123,12 +123,12 @@ struct SystemRunSettingsView: View { .foregroundStyle(.secondary) } else { VStack(alignment: .leading, spacing: 8) { - ForEach(Array(self.model.entries.enumerated()), id: \.offset) { index, _ in + ForEach(self.model.entries, id: \.id) { entry in ExecAllowlistRow( entry: Binding( - get: { self.model.entries[index] }, - set: { self.model.updateEntry($0, at: index) }), - onRemove: { self.model.removeEntry(at: index) }) + get: { self.model.entry(for: entry.id) ?? entry }, + set: { self.model.updateEntry($0, id: entry.id) }), + onRemove: { self.model.removeEntry(id: entry.id) }) } } } @@ -373,20 +373,24 @@ final class ExecApprovalsSettingsModel { ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func updateEntry(_ entry: ExecAllowlistEntry, at index: Int) { + func updateEntry(_ entry: ExecAllowlistEntry, id: UUID) { guard !self.isDefaultsScope else { return } - guard self.entries.indices.contains(index) else { return } + guard let index = self.entries.firstIndex(where: { $0.id == id }) else { return } self.entries[index] = entry ExecApprovalsStore.updateAllowlist(agentId: self.selectedAgentId, allowlist: self.entries) } - func removeEntry(at index: Int) { + func removeEntry(id: UUID) { guard !self.isDefaultsScope else { return } - guard self.entries.indices.contains(index) else { return } + guard let index = self.entries.firstIndex(where: { $0.id == id }) 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 = [] diff --git a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift index 176980cc5..a41e8bb1f 100644 --- a/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift +++ b/apps/macos/Sources/Clawdbot/VoiceWakeSettings.swift @@ -21,6 +21,7 @@ struct VoiceWakeSettings: View { @State private var micObserver = AudioInputDeviceObserver() @State private var micRefreshTask: Task? @State private var availableLocales: [Locale] = [] + @State private var triggerEntries: [TriggerEntry] = [] private let fieldLabelWidth: CGFloat = 140 private let controlWidth: CGFloat = 240 private let isPreview = ProcessInfo.processInfo.isPreview @@ -31,9 +32,9 @@ struct VoiceWakeSettings: View { var id: String { self.uid } } - private struct IndexedWord: Identifiable { - let id: Int - let value: String + private struct TriggerEntry: Identifiable { + let id: UUID + var value: String } private var voiceWakeBinding: Binding { @@ -105,6 +106,7 @@ struct VoiceWakeSettings: View { .onAppear { guard !self.isPreview else { return } self.startMicObserver() + self.loadTriggerEntries() } .onChange(of: self.state.voiceWakeMicID) { _, _ in guard !self.isPreview else { return } @@ -122,8 +124,10 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil Task { await self.meter.stop() } self.micObserver.stop() + self.syncTriggerEntriesToState() } else { self.startMicObserver() + self.loadTriggerEntries() } } .onDisappear { @@ -136,11 +140,16 @@ struct VoiceWakeSettings: View { self.micRefreshTask = nil self.micObserver.stop() Task { await self.meter.stop() } + self.syncTriggerEntriesToState() } } - private var indexedWords: [IndexedWord] { - self.state.swabbleTriggerWords.enumerated().map { IndexedWord(id: $0.offset, value: $0.element) } + 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 triggerTable: some View { @@ -154,29 +163,42 @@ struct VoiceWakeSettings: View { } label: { Label("Add word", systemImage: "plus") } - .disabled(self.state.swabbleTriggerWords - .contains(where: { $0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) + .disabled(self.triggerEntries + .contains(where: { $0.value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })) - Button("Reset defaults") { self.state.swabbleTriggerWords = defaultVoiceWakeTriggers } + Button("Reset defaults") { + self.triggerEntries = defaultVoiceWakeTriggers.map { TriggerEntry(id: UUID(), value: $0) } + self.syncTriggerEntriesToState() + } } - Table(self.indexedWords) { - TableColumn("Word") { row in - TextField("Wake word", text: self.binding(for: row.id)) - .textFieldStyle(.roundedBorder) - } - TableColumn("") { row in - Button { - self.removeWord(at: row.id) - } label: { - Image(systemName: "trash") + VStack(spacing: 0) { + ForEach(self.$triggerEntries) { $entry in + HStack(spacing: 8) { + TextField("Wake word", text: $entry.value) + .textFieldStyle(.roundedBorder) + .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() } - .buttonStyle(.borderless) - .help("Remove trigger word") } - .width(36) } - .frame(minHeight: 180) + .frame(maxWidth: .infinity, minHeight: 180, alignment: .topLeading) + .background(Color(nsColor: .textBackgroundColor)) .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) @@ -211,24 +233,12 @@ struct VoiceWakeSettings: View { } private func addWord() { - self.state.swabbleTriggerWords.append("") + self.triggerEntries.append(TriggerEntry(id: UUID(), value: "")) } - 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 { - 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 removeWord(id: UUID) { + self.triggerEntries.removeAll { $0.id == id } + self.syncTriggerEntriesToState() } private func toggleTest() { @@ -638,13 +648,14 @@ extension VoiceWakeSettings { state.voicePushToTalkEnabled = true state.swabbleTriggerWords = ["Claude", "Hey"] - let view = VoiceWakeSettings(state: state, isActive: true) + var 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 @@ -654,8 +665,9 @@ extension VoiceWakeSettings { _ = view.chimeSection view.addWord() - _ = view.binding(for: 0).wrappedValue - view.removeWord(at: 0) + if let entryId = view.triggerEntries.first?.id { + view.removeWord(id: entryId) + } } } #endif diff --git a/docs/cli/index.md b/docs/cli/index.md index 46f6d173e..fcc013fdc 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -700,8 +700,15 @@ Options: - `--json` - `--plain` - `--check` (exit 1=expired/missing, 2=expiring) +- `--probe` (live probe of configured auth profiles) +- `--probe-provider ` +- `--probe-profile ` (repeat or comma-separated) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` 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 ` Set `agents.defaults.model.primary`. diff --git a/docs/cli/models.md b/docs/cli/models.md index f394a44f9..ba4600ce4 100644 --- a/docs/cli/models.md +++ b/docs/cli/models.md @@ -25,12 +25,26 @@ 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 ` 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 ` (probe one provider) +- `--probe-profile ` (repeat or comma-separated profile ids) +- `--probe-timeout ` +- `--probe-concurrency ` +- `--probe-max-tokens ` + ## Aliases + fallbacks ```bash diff --git a/docs/concepts/markdown-formatting.md b/docs/concepts/markdown-formatting.md index da2c1b268..91799a3e9 100644 --- a/docs/concepts/markdown-formatting.md +++ b/docs/concepts/markdown-formatting.md @@ -25,6 +25,7 @@ stay consistent across channels. 1. **Parse Markdown -> IR** - IR is plain text plus style spans (bold/italic/strike/code/spoiler) and link spans. - Offsets are UTF-16 code units so Signal style ranges align with its API. + - Tables are parsed only when a channel opts into table conversion. 2. **Chunk IR (format-first)** - Chunking happens on the IR text before rendering. - Inline formatting does not split across chunks; spans are sliced per chunk. @@ -59,7 +60,30 @@ IR (schematic): - Slack, Telegram, and Signal outbound adapters render from the IR. - Other channels (WhatsApp, iMessage, MS Teams, Discord) still use plain text or - their own formatting rules. + their own formatting rules, with Markdown table conversion applied before + chunking when enabled. + +## Table handling + +Markdown tables are not consistently supported across chat clients. Use +`markdown.tables` to control conversion per channel (and per account). + +- `code`: render tables as code blocks (default for most channels). +- `bullets`: convert each row into bullet points (default for Signal + WhatsApp). +- `off`: disable table parsing and conversion; raw table text passes through. + +Config keys: + +```yaml +channels: + discord: + markdown: + tables: code + accounts: + work: + markdown: + tables: off +``` ## Chunking rules diff --git a/docs/start/faq.md b/docs/start/faq.md index 38defb953..a3efb2b0b 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -324,6 +324,7 @@ brew install ``` 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? diff --git a/docs/tools/exec-approvals.md b/docs/tools/exec-approvals.md index fc657a74f..2ab96695c 100644 --- a/docs/tools/exec-approvals.md +++ b/docs/tools/exec-approvals.md @@ -54,6 +54,7 @@ Example schema: "autoAllowSkills": true, "allowlist": [ { + "id": "B0C8C0B3-2C2D-4F8A-9A3C-5A4B3C2D1E0F", "pattern": "~/Projects/**/bin/rg", "lastUsedAt": 1737150000000, "lastUsedCommand": "rg -n TODO", @@ -96,6 +97,7 @@ 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** diff --git a/docs/tui.md b/docs/tui.md index e67b22032..4d094dc6b 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -88,6 +88,8 @@ 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. diff --git a/extensions/bluebubbles/src/config-schema.ts b/extensions/bluebubbles/src/config-schema.ts index 84b389142..9e2f6e50f 100644 --- a/extensions/bluebubbles/src/config-schema.ts +++ b/extensions/bluebubbles/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -25,6 +26,7 @@ const bluebubblesGroupConfigSchema = z.object({ const bluebubblesAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, serverUrl: z.string().optional(), password: z.string().optional(), webhookPath: z.string().optional(), diff --git a/extensions/bluebubbles/src/monitor.test.ts b/extensions/bluebubbles/src/monitor.test.ts index 0dcccbef8..fa40e82a7 100644 --- a/extensions/bluebubbles/src/monitor.test.ts +++ b/extensions/bluebubbles/src/monitor.test.ts @@ -99,6 +99,8 @@ function createMockRuntime(): PluginRuntime { chunkText: vi.fn() as unknown as PluginRuntime["channel"]["text"]["chunkText"], resolveTextChunkLimit: vi.fn(() => 4000) as unknown as PluginRuntime["channel"]["text"]["resolveTextChunkLimit"], hasControlCommand: mockHasControlCommand as unknown as PluginRuntime["channel"]["text"]["hasControlCommand"], + resolveMarkdownTableMode: vi.fn(() => "code") as unknown as PluginRuntime["channel"]["text"]["resolveMarkdownTableMode"], + convertMarkdownTables: vi.fn((text: string) => text) as unknown as PluginRuntime["channel"]["text"]["convertMarkdownTables"], }, reply: { dispatchReplyWithBufferedBlockDispatcher: mockDispatchReplyWithBufferedBlockDispatcher as unknown as PluginRuntime["channel"]["reply"]["dispatchReplyWithBufferedBlockDispatcher"], @@ -220,6 +222,12 @@ function createMockResponse(): ServerResponse & { body: string; statusCode: numb return res; } +const flushAsync = async () => { + for (let i = 0; i < 2; i += 1) { + await new Promise((resolve) => setImmediate(resolve)); + } +}; + describe("BlueBubbles webhook monitor", () => { let unregister: () => void; @@ -506,7 +514,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(resolveChatGuidForTarget).toHaveBeenCalledWith( expect.objectContaining({ @@ -554,7 +562,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(resolveChatGuidForTarget).not.toHaveBeenCalled(); expect(sendMessageBlueBubbles).toHaveBeenCalledWith( @@ -601,7 +609,7 @@ describe("BlueBubbles webhook monitor", () => { await handleBlueBubblesWebhookRequest(req, res); // Wait for async processing - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(res.statusCode).toBe(200); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); @@ -640,7 +648,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(res.statusCode).toBe(200); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); @@ -681,7 +689,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockUpsertPairingRequest).toHaveBeenCalled(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); @@ -724,7 +732,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockUpsertPairingRequest).toHaveBeenCalled(); // Should not send pairing reply since created=false @@ -765,7 +773,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -802,7 +810,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -842,7 +850,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -880,7 +888,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -919,7 +927,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -958,7 +966,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -999,7 +1007,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1040,7 +1048,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -1078,7 +1086,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); }); @@ -1121,7 +1129,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1167,7 +1175,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1213,7 +1221,7 @@ describe("BlueBubbles webhook monitor", () => { const originalRes = createMockResponse(); await handleBlueBubblesWebhookRequest(originalReq, originalRes); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Only assert the reply message behavior below. mockDispatchReplyWithBufferedBlockDispatcher.mockClear(); @@ -1237,7 +1245,7 @@ describe("BlueBubbles webhook monitor", () => { const replyRes = createMockResponse(); await handleBlueBubblesWebhookRequest(replyReq, replyRes); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1283,7 +1291,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1331,7 +1339,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(sendBlueBubblesReaction).toHaveBeenCalledWith( expect.objectContaining({ @@ -1384,7 +1392,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Should process even without mention because it's an authorized control command expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); @@ -1427,7 +1435,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); @@ -1470,7 +1478,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(markBlueBubblesChatRead).toHaveBeenCalled(); }); @@ -1511,7 +1519,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(markBlueBubblesChatRead).not.toHaveBeenCalled(); }); @@ -1554,7 +1562,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Should call typing start when reply flow triggers it. expect(sendBlueBubblesTyping).toHaveBeenCalledWith( @@ -1604,7 +1612,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect.any(String), @@ -1649,7 +1657,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(sendBlueBubblesTyping).toHaveBeenCalledWith( expect.any(String), @@ -1697,7 +1705,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // Outbound message ID uses short ID "2" (inbound msg-1 is "1", outbound msg-123 is "2") expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( @@ -1742,7 +1750,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("reaction added"), @@ -1782,7 +1790,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("reaction removed"), @@ -1822,7 +1830,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).not.toHaveBeenCalled(); }); @@ -1860,7 +1868,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockEnqueueSystemEvent).toHaveBeenCalledWith( expect.stringContaining("👍"), @@ -1901,7 +1909,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled(); const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0]; @@ -1941,7 +1949,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); // The short ID "1" should resolve back to the full UUID expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345"); @@ -1993,7 +2001,7 @@ describe("BlueBubbles webhook monitor", () => { const res = createMockResponse(); await handleBlueBubblesWebhookRequest(req, res); - await new Promise((resolve) => setTimeout(resolve, 50)); + await flushAsync(); expect(mockDispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled(); }); diff --git a/extensions/bluebubbles/src/monitor.ts b/extensions/bluebubbles/src/monitor.ts index ab503882d..81a921ca9 100644 --- a/extensions/bluebubbles/src/monitor.ts +++ b/extensions/bluebubbles/src/monitor.ts @@ -1662,9 +1662,15 @@ async function processMessage( ? [payload.mediaUrl] : []; if (mediaList.length > 0) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; const result = await sendBlueBubblesMedia({ cfg: config, @@ -1686,8 +1692,14 @@ async function processMessage( account.config.textChunkLimit && account.config.textChunkLimit > 0 ? account.config.textChunkLimit : DEFAULT_TEXT_LIMIT; - const chunks = core.channel.text.chunkMarkdownText(payload.text ?? "", textLimit); - if (!chunks.length && payload.text) chunks.push(payload.text); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "bluebubbles", + accountId: account.accountId, + }); + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); + const chunks = core.channel.text.chunkMarkdownText(text, textLimit); + if (!chunks.length && text) chunks.push(text); if (!chunks.length) return; for (const chunk of chunks) { const result = await sendMessageBlueBubbles(outboundTarget, chunk, { diff --git a/extensions/matrix/src/config-schema.ts b/extensions/matrix/src/config-schema.ts index 3cb396883..2d035dc43 100644 --- a/extensions/matrix/src/config-schema.ts +++ b/extensions/matrix/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -35,6 +36,7 @@ const matrixRoomSchema = z export const MatrixConfigSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, homeserver: z.string().optional(), userId: z.string().optional(), accessToken: z.string().optional(), diff --git a/extensions/matrix/src/matrix/monitor/handler.ts b/extensions/matrix/src/matrix/monitor/handler.ts index 62a7a2c26..49deabbf8 100644 --- a/extensions/matrix/src/matrix/monitor/handler.ts +++ b/extensions/matrix/src/matrix/monitor/handler.ts @@ -548,6 +548,11 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam } let didSendReply = false; + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: route.accountId, + }); const { dispatcher, replyOptions, markDispatchIdle } = core.channel.reply.createReplyDispatcherWithTyping({ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId) @@ -562,6 +567,8 @@ export function createMatrixRoomMessageHandler(params: MatrixMonitorHandlerParam textLimit, replyToMode, threadId: threadTarget, + accountId: route.accountId, + tableMode, }); didSendReply = true; }, diff --git a/extensions/matrix/src/matrix/monitor/replies.ts b/extensions/matrix/src/matrix/monitor/replies.ts index 7a9cc06aa..d2a6e34da 100644 --- a/extensions/matrix/src/matrix/monitor/replies.ts +++ b/extensions/matrix/src/matrix/monitor/replies.ts @@ -1,6 +1,6 @@ import type { MatrixClient } from "matrix-bot-sdk"; -import type { ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { MarkdownTableMode, ReplyPayload, RuntimeEnv } from "clawdbot/plugin-sdk"; import { sendMessageMatrix } from "../send.js"; import { getMatrixRuntime } from "../../runtime.js"; @@ -12,8 +12,17 @@ export async function deliverMatrixReplies(params: { textLimit: number; replyToMode: "off" | "first" | "all"; threadId?: string; + accountId?: string; + tableMode?: MarkdownTableMode; }): Promise { const core = getMatrixRuntime(); + const tableMode = + params.tableMode ?? + core.channel.text.resolveMarkdownTableMode({ + cfg: core.config.loadConfig(), + channel: "matrix", + accountId: params.accountId, + }); const logVerbose = (message: string) => { if (core.logging.shouldLogVerbose()) { params.runtime.log?.(message); @@ -33,6 +42,8 @@ export async function deliverMatrixReplies(params: { } const replyToIdRaw = reply.replyToId?.trim(); const replyToId = params.threadId || params.replyToMode === "off" ? undefined : replyToIdRaw; + const rawText = reply.text ?? ""; + const text = core.channel.text.convertMarkdownTables(rawText, tableMode); const mediaList = reply.mediaUrls?.length ? reply.mediaUrls : reply.mediaUrl @@ -43,13 +54,14 @@ export async function deliverMatrixReplies(params: { Boolean(id) && (params.replyToMode === "all" || !hasReplied); if (mediaList.length === 0) { - for (const chunk of core.channel.text.chunkMarkdownText(reply.text ?? "", chunkLimit)) { + for (const chunk of core.channel.text.chunkMarkdownText(text, chunkLimit)) { const trimmed = chunk.trim(); if (!trimmed) continue; await sendMessageMatrix(params.roomId, trimmed, { client: params.client, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, threadId: params.threadId, + accountId: params.accountId, }); if (shouldIncludeReply(replyToId)) { hasReplied = true; @@ -60,13 +72,14 @@ export async function deliverMatrixReplies(params: { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? (reply.text ?? "") : ""; + const caption = first ? text : ""; await sendMessageMatrix(params.roomId, caption, { client: params.client, mediaUrl, replyToId: shouldIncludeReply(replyToId) ? replyToId : undefined, threadId: params.threadId, audioAsVoice: reply.audioAsVoice, + accountId: params.accountId, }); if (shouldIncludeReply(replyToId)) { hasReplied = true; diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 5520d126e..2f0053ecf 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -43,6 +43,8 @@ const runtimeStub = { text: { resolveTextChunkLimit: () => 4000, chunkMarkdownText: (text: string) => (text ? [text] : []), + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, }, }, } as unknown as PluginRuntime; diff --git a/extensions/matrix/src/matrix/send.ts b/extensions/matrix/src/matrix/send.ts index 634871123..79d20471c 100644 --- a/extensions/matrix/src/matrix/send.ts +++ b/extensions/matrix/src/matrix/send.ts @@ -50,9 +50,18 @@ export async function sendMessageMatrix( try { const roomId = await resolveMatrixRoomId(client, to); const cfg = getCore().config.loadConfig(); + const tableMode = getCore().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "matrix", + accountId: opts.accountId, + }); + const convertedMessage = getCore().channel.text.convertMarkdownTables( + trimmedMessage, + tableMode, + ); const textLimit = getCore().channel.text.resolveTextChunkLimit(cfg, "matrix"); const chunkLimit = Math.min(textLimit, MATRIX_TEXT_LIMIT); - const chunks = getCore().channel.text.chunkMarkdownText(trimmedMessage, chunkLimit); + const chunks = getCore().channel.text.chunkMarkdownText(convertedMessage, chunkLimit); const threadId = normalizeThreadId(opts.threadId); const relation = threadId ? buildThreadRelation(threadId, opts.replyToId) diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index 51b1b1024..eb59f8a62 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -87,6 +87,7 @@ export type MatrixSendResult = { export type MatrixSendOpts = { client?: import("matrix-bot-sdk").MatrixClient; mediaUrl?: string; + accountId?: string; replyToId?: string; threadId?: string | number | null; timeoutMs?: number; diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 618747995..40ae8a31a 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -4,6 +4,7 @@ import { BlockStreamingCoalesceSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, requireOpenAllowFrom, } from "clawdbot/plugin-sdk"; @@ -11,6 +12,7 @@ const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), botToken: z.string().optional(), diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 7e5079ecb..cce05f0cb 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -707,6 +707,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, { fallbackLimit: account.textChunkLimit ?? 4000, }); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), @@ -720,7 +725,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId), deliver: async (payload: ReplyPayload) => { const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); if (mediaUrls.length === 0) { const chunks = core.channel.text.chunkMarkdownText(text, textLimit); for (const chunk of chunks.length > 0 ? chunks : [text]) { diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index c2a2a251c..cd205340d 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -181,6 +181,15 @@ export async function sendMessageMattermost( } } + if (message) { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg, + channel: "mattermost", + accountId: account.accountId, + }); + message = core.channel.text.convertMarkdownTables(message, tableMode); + } + if (!message && (!fileIds || fileIds.length === 0)) { if (uploadError) { throw new Error(`Mattermost media upload failed: ${uploadError.message}`); @@ -205,4 +214,4 @@ export async function sendMessageMattermost( messageId: post.id ?? "unknown", channelId, }; -} \ No newline at end of file +} diff --git a/extensions/msteams/src/messenger.test.ts b/extensions/msteams/src/messenger.test.ts index 04d1f55e1..9fbd628c5 100644 --- a/extensions/msteams/src/messenger.test.ts +++ b/extensions/msteams/src/messenger.test.ts @@ -21,6 +21,8 @@ const runtimeStub = { } return chunks; }, + resolveMarkdownTableMode: () => "code", + convertMarkdownTables: (text: string) => text, }, }, } as unknown as PluginRuntime; @@ -34,6 +36,7 @@ describe("msteams messenger", () => { it("filters silent replies", () => { const messages = renderReplyPayloadsToMessages([{ text: SILENT_REPLY_TOKEN }], { textChunkLimit: 4000, + tableMode: "code", }); expect(messages).toEqual([]); }); @@ -41,7 +44,7 @@ describe("msteams messenger", () => { it("filters silent reply prefixes", () => { const messages = renderReplyPayloadsToMessages( [{ text: `${SILENT_REPLY_TOKEN} -- ignored` }], - { textChunkLimit: 4000 }, + { textChunkLimit: 4000, tableMode: "code" }, ); expect(messages).toEqual([]); }); @@ -49,7 +52,7 @@ describe("msteams messenger", () => { it("splits media into separate messages by default", () => { const messages = renderReplyPayloadsToMessages( [{ text: "hi", mediaUrl: "https://example.com/a.png" }], - { textChunkLimit: 4000 }, + { textChunkLimit: 4000, tableMode: "code" }, ); expect(messages).toEqual([{ text: "hi" }, { mediaUrl: "https://example.com/a.png" }]); }); @@ -57,7 +60,7 @@ describe("msteams messenger", () => { it("supports inline media mode", () => { const messages = renderReplyPayloadsToMessages( [{ text: "hi", mediaUrl: "https://example.com/a.png" }], - { textChunkLimit: 4000, mediaMode: "inline" }, + { textChunkLimit: 4000, mediaMode: "inline", tableMode: "code" }, ); expect(messages).toEqual([{ text: "hi", mediaUrl: "https://example.com/a.png" }]); }); @@ -66,6 +69,7 @@ describe("msteams messenger", () => { const long = "hello ".repeat(200); const messages = renderReplyPayloadsToMessages([{ text: long }], { textChunkLimit: 50, + tableMode: "code", }); expect(messages.length).toBeGreaterThan(1); }); diff --git a/extensions/msteams/src/messenger.ts b/extensions/msteams/src/messenger.ts index d6a0b9963..a5eb99b73 100644 --- a/extensions/msteams/src/messenger.ts +++ b/extensions/msteams/src/messenger.ts @@ -1,6 +1,7 @@ import { isSilentReplyText, loadWebMedia, + type MarkdownTableMode, type MSTeamsReplyStyle, type ReplyPayload, SILENT_REPLY_TOKEN, @@ -61,6 +62,7 @@ export type MSTeamsReplyRenderOptions = { textChunkLimit: number; chunkText?: boolean; mediaMode?: "split" | "inline"; + tableMode?: MarkdownTableMode; }; /** @@ -196,10 +198,19 @@ export function renderReplyPayloadsToMessages( const chunkLimit = Math.min(options.textChunkLimit, 4000); const chunkText = options.chunkText !== false; const mediaMode = options.mediaMode ?? "split"; + const tableMode = + options.tableMode ?? + getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg: getMSTeamsRuntime().config.loadConfig(), + channel: "msteams", + }); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const text = getMSTeamsRuntime().channel.text.convertMarkdownTables( + payload.text ?? "", + tableMode, + ); if (!text && mediaList.length === 0) continue; diff --git a/extensions/msteams/src/reply-dispatcher.ts b/extensions/msteams/src/reply-dispatcher.ts index d50661264..f711c8240 100644 --- a/extensions/msteams/src/reply-dispatcher.ts +++ b/extensions/msteams/src/reply-dispatcher.ts @@ -53,10 +53,15 @@ export function createMSTeamsReplyDispatcher(params: { ).responsePrefix, humanDelay: core.channel.reply.resolveHumanDelayConfig(params.cfg, params.agentId), deliver: async (payload) => { + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "msteams", + }); const messages = renderReplyPayloadsToMessages([payload], { textChunkLimit: params.textLimit, chunkText: true, mediaMode: "split", + tableMode, }); const mediaMaxBytes = resolveChannelMediaMaxBytes({ cfg: params.cfg, diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index 83d0cf149..82a4114ef 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -16,6 +16,7 @@ import { import { extractFilename, extractMessageId } from "./media-helpers.js"; import { buildConversationReference, sendMSTeamsMessages } from "./messenger.js"; import { buildMSTeamsPollCard } from "./polls.js"; +import { getMSTeamsRuntime } from "./runtime.js"; import { resolveMSTeamsSendContext, type MSTeamsProactiveContext } from "./send-context.js"; export type SendMSTeamsMessageParams = { @@ -93,13 +94,21 @@ export async function sendMessageMSTeams( params: SendMSTeamsMessageParams, ): Promise { const { cfg, to, text, mediaUrl } = params; + const tableMode = getMSTeamsRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "msteams", + }); + const messageText = getMSTeamsRuntime().channel.text.convertMarkdownTables( + text ?? "", + tableMode, + ); const ctx = await resolveMSTeamsSendContext({ cfg, to }); const { adapter, appId, conversationId, ref, log, conversationType, tokenProvider, sharePointSiteId } = ctx; log.debug("sending proactive message", { conversationId, conversationType, - textLength: text.length, + textLength: messageText.length, hasMedia: Boolean(mediaUrl), }); @@ -134,7 +143,7 @@ export async function sendMessageMSTeams( const { activity, uploadId } = prepareFileConsentActivity({ media: { buffer: media.buffer, filename: fileName, contentType: media.contentType }, conversationId, - description: text || undefined, + description: messageText || undefined, }); log.debug("sending file consent card", { uploadId, fileName, size: media.buffer.length }); @@ -172,14 +181,14 @@ export async function sendMessageMSTeams( const base64 = media.buffer.toString("base64"); const finalMediaUrl = `data:${media.contentType};base64,${base64}`; - return sendTextWithMedia(ctx, text, finalMediaUrl); + return sendTextWithMedia(ctx, messageText, finalMediaUrl); } if (isImage && !sharePointSiteId) { // Group chat/channel without SharePoint: send image inline (avoids OneDrive failures) const base64 = media.buffer.toString("base64"); const finalMediaUrl = `data:${media.contentType};base64,${base64}`; - return sendTextWithMedia(ctx, text, finalMediaUrl); + return sendTextWithMedia(ctx, messageText, finalMediaUrl); } // Group chat or channel: upload to SharePoint (if siteId configured) or OneDrive @@ -223,7 +232,7 @@ export async function sendMessageMSTeams( const fileCardAttachment = buildTeamsFileInfoCard(driveItem); const activity = { type: "message", - text: text || undefined, + text: messageText || undefined, attachments: [fileCardAttachment], }; @@ -264,7 +273,7 @@ export async function sendMessageMSTeams( const fileLink = `📎 [${uploaded.name}](${uploaded.shareUrl})`; const activity = { type: "message", - text: text ? `${text}\n\n${fileLink}` : fileLink, + text: messageText ? `${messageText}\n\n${fileLink}` : fileLink, }; const baseRef = buildConversationReference(ref); @@ -290,7 +299,7 @@ export async function sendMessageMSTeams( } // No media: send text only - return sendTextWithMedia(ctx, text, undefined); + return sendTextWithMedia(ctx, messageText, undefined); } /** diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts index c442f6b59..085319d1c 100644 --- a/extensions/nextcloud-talk/src/config-schema.ts +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -3,6 +3,7 @@ import { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, requireOpenAllowFrom, } from "clawdbot/plugin-sdk"; import { z } from "zod"; @@ -21,6 +22,7 @@ export const NextcloudTalkAccountSchemaBase = z .object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, baseUrl: z.string().optional(), botSecret: z.string().optional(), botSecretFile: z.string().optional(), diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts index cf55f5509..1dd8f5094 100644 --- a/extensions/nextcloud-talk/src/send.ts +++ b/extensions/nextcloud-talk/src/send.ts @@ -71,8 +71,18 @@ export async function sendMessageNextcloudTalk( throw new Error("Message must be non-empty for Nextcloud Talk sends"); } + const tableMode = getNextcloudTalkRuntime().channel.text.resolveMarkdownTableMode({ + cfg, + channel: "nextcloud-talk", + accountId: account.accountId, + }); + const message = getNextcloudTalkRuntime().channel.text.convertMarkdownTables( + text.trim(), + tableMode, + ); + const body: Record = { - message: text.trim(), + message, }; if (opts.replyTo) { body.replyTo = opts.replyTo; diff --git a/extensions/nostr/src/channel.ts b/extensions/nostr/src/channel.ts index 30f2f7dfc..e6df0872c 100644 --- a/extensions/nostr/src/channel.ts +++ b/extensions/nostr/src/channel.ts @@ -133,13 +133,20 @@ export const nostrPlugin: ChannelPlugin = { deliveryMode: "direct", textChunkLimit: 4000, sendText: async ({ to, text, accountId }) => { + const core = getNostrRuntime(); const aid = accountId ?? DEFAULT_ACCOUNT_ID; const bus = activeBuses.get(aid); if (!bus) { throw new Error(`Nostr bus not running for account ${aid}`); } + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: core.config.loadConfig(), + channel: "nostr", + accountId: aid, + }); + const message = core.channel.text.convertMarkdownTables(text ?? "", tableMode); const normalizedTo = normalizePubkey(to); - await bus.sendDm(normalizedTo, text); + await bus.sendDm(normalizedTo, message); return { channel: "nostr", to: normalizedTo }; }, }, diff --git a/extensions/nostr/src/config-schema.ts b/extensions/nostr/src/config-schema.ts index bb01a068d..08ac773b0 100644 --- a/extensions/nostr/src/config-schema.ts +++ b/extensions/nostr/src/config-schema.ts @@ -1,5 +1,5 @@ +import { MarkdownConfigSchema, buildChannelConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; -import { buildChannelConfigSchema } from "clawdbot/plugin-sdk"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -63,6 +63,9 @@ export const NostrConfigSchema = z.object({ /** Whether this channel is enabled */ enabled: z.boolean().optional(), + /** Markdown formatting overrides (tables). */ + markdown: MarkdownConfigSchema, + /** Private key in hex or nsec bech32 format */ privateKey: z.string().optional(), diff --git a/extensions/zalo/src/config-schema.ts b/extensions/zalo/src/config-schema.ts index 3ab955848..25e22bd3b 100644 --- a/extensions/zalo/src/config-schema.ts +++ b/extensions/zalo/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -5,6 +6,7 @@ const allowFromEntry = z.union([z.string(), z.number()]); const zaloAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, botToken: z.string().optional(), tokenFile: z.string().optional(), webhookUrl: z.string().optional(), diff --git a/extensions/zalo/src/monitor.ts b/extensions/zalo/src/monitor.ts index cb68388cf..939dcdbde 100644 --- a/extensions/zalo/src/monitor.ts +++ b/extensions/zalo/src/monitor.ts @@ -1,6 +1,6 @@ import type { IncomingMessage, ServerResponse } from "node:http"; -import type { ClawdbotConfig } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig, MarkdownTableMode } from "clawdbot/plugin-sdk"; import type { ResolvedZaloAccount } from "./accounts.js"; import { @@ -578,6 +578,12 @@ async function processMessageWithPipeline(params: { runtime.error?.(`zalo: failed updating session meta: ${String(err)}`); }); + const tableMode = core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalo", + accountId: account.accountId, + }); + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, cfg: config, @@ -591,6 +597,7 @@ async function processMessageWithPipeline(params: { core, statusSink, fetcher, + tableMode, }); }, onError: (err, info) => { @@ -608,8 +615,11 @@ async function deliverZaloReply(params: { core: ZaloCoreRuntime; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; fetcher?: ZaloFetch; + tableMode?: MarkdownTableMode; }): Promise { const { payload, token, chatId, runtime, core, statusSink, fetcher } = params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const mediaList = payload.mediaUrls?.length ? payload.mediaUrls @@ -620,7 +630,7 @@ async function deliverZaloReply(params: { if (mediaList.length > 0) { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; try { await sendPhoto(token, { chat_id: chatId, photo: mediaUrl, caption }, fetcher); @@ -632,8 +642,8 @@ async function deliverZaloReply(params: { return; } - if (payload.text) { - const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALO_TEXT_LIMIT); + if (text) { + const chunks = core.channel.text.chunkMarkdownText(text, ZALO_TEXT_LIMIT); for (const chunk of chunks) { try { await sendMessage(token, { chat_id: chatId, text: chunk }, fetcher); diff --git a/extensions/zalouser/src/config-schema.ts b/extensions/zalouser/src/config-schema.ts index ca36c1c72..bf80d28c0 100644 --- a/extensions/zalouser/src/config-schema.ts +++ b/extensions/zalouser/src/config-schema.ts @@ -1,3 +1,4 @@ +import { MarkdownConfigSchema } from "clawdbot/plugin-sdk"; import { z } from "zod"; const allowFromEntry = z.union([z.string(), z.number()]); @@ -10,6 +11,7 @@ const groupConfigSchema = z.object({ const zalouserAccountSchema = z.object({ name: z.string().optional(), enabled: z.boolean().optional(), + markdown: MarkdownConfigSchema, profile: z.string().optional(), dmPolicy: z.enum(["pairing", "allowlist", "open", "disabled"]).optional(), allowFrom: z.array(allowFromEntry).optional(), diff --git a/extensions/zalouser/src/monitor.ts b/extensions/zalouser/src/monitor.ts index b3ab31dd3..4015fcc8d 100644 --- a/extensions/zalouser/src/monitor.ts +++ b/extensions/zalouser/src/monitor.ts @@ -1,6 +1,6 @@ import type { ChildProcess } from "node:child_process"; -import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; +import type { ClawdbotConfig, MarkdownTableMode, RuntimeEnv } from "clawdbot/plugin-sdk"; import { mergeAllowlist, summarizeMapping } from "clawdbot/plugin-sdk"; import { sendMessageZalouser } from "./send.js"; import type { @@ -332,6 +332,11 @@ async function processMessage( runtime, core, statusSink, + tableMode: core.channel.text.resolveMarkdownTableMode({ + cfg: config, + channel: "zalouser", + accountId: account.accountId, + }), }); }, onError: (err, info) => { @@ -351,8 +356,11 @@ async function deliverZalouserReply(params: { runtime: RuntimeEnv; core: ZalouserCoreRuntime; statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; + tableMode?: MarkdownTableMode; }): Promise { const { payload, profile, chatId, isGroup, runtime, core, statusSink } = params; + const tableMode = params.tableMode ?? "code"; + const text = core.channel.text.convertMarkdownTables(payload.text ?? "", tableMode); const mediaList = payload.mediaUrls?.length ? payload.mediaUrls @@ -363,7 +371,7 @@ async function deliverZalouserReply(params: { if (mediaList.length > 0) { let first = true; for (const mediaUrl of mediaList) { - const caption = first ? payload.text : undefined; + const caption = first ? text : undefined; first = false; try { logVerbose(core, runtime, `Sending media to ${chatId}`); @@ -380,8 +388,8 @@ async function deliverZalouserReply(params: { return; } - if (payload.text) { - const chunks = core.channel.text.chunkMarkdownText(payload.text, ZALOUSER_TEXT_LIMIT); + if (text) { + const chunks = core.channel.text.chunkMarkdownText(text, ZALOUSER_TEXT_LIMIT); logVerbose(core, runtime, `Sending ${chunks.length} text chunk(s) to ${chatId}`); for (const chunk of chunks) { try { diff --git a/src/agents/clawdbot-gateway-tool.test.ts b/src/agents/clawdbot-gateway-tool.test.ts index a3dfa8309..0a283198c 100644 --- a/src/agents/clawdbot-gateway-tool.test.ts +++ b/src/agents/clawdbot-gateway-tool.test.ts @@ -3,6 +3,7 @@ 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", () => ({ diff --git a/src/agents/clawdbot-tools.agents.test.ts b/src/agents/clawdbot-tools.agents.test.ts index 5936c196c..0ae300bfb 100644 --- a/src/agents/clawdbot-tools.agents.test.ts +++ b/src/agents/clawdbot-tools.agents.test.ts @@ -16,6 +16,7 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); +import "./test-helpers/fast-core-tools.js"; import { createClawdbotTools } from "./clawdbot-tools.js"; describe("agents_list", () => { diff --git a/src/agents/clawdbot-tools.camera.test.ts b/src/agents/clawdbot-tools.camera.test.ts index 4347bacfa..c652e60d3 100644 --- a/src/agents/clawdbot-tools.camera.test.ts +++ b/src/agents/clawdbot-tools.camera.test.ts @@ -10,6 +10,7 @@ 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", () => { diff --git a/src/agents/clawdbot-tools.session-status.test.ts b/src/agents/clawdbot-tools.session-status.test.ts index c361f59d6..94ee3e8b4 100644 --- a/src/agents/clawdbot-tools.session-status.test.ts +++ b/src/agents/clawdbot-tools.session-status.test.ts @@ -75,6 +75,7 @@ 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", () => { diff --git a/src/agents/clawdbot-tools.sessions.test.ts b/src/agents/clawdbot-tools.sessions.test.ts index bf57c73cf..c7964b75b 100644 --- a/src/agents/clawdbot-tools.sessions.test.ts +++ b/src/agents/clawdbot-tools.sessions.test.ts @@ -4,9 +4,6 @@ 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(); @@ -23,6 +20,7 @@ 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) => { diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts index 3733348d9..740e987ea 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-allows-cross-agent-spawning-configured.test.ts @@ -21,6 +21,7 @@ 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"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts index 814e021d8..27aff8c47 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-announces-agent-wait-lifecycle-events.test.ts @@ -21,6 +21,7 @@ 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"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts index 2eea23bf0..f9bd6a499 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-applies-model-child-session.test.ts @@ -21,6 +21,7 @@ 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"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts index b1b5b413b..3dbfb02b4 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-normalizes-allowlisted-agent-ids.test.ts @@ -22,6 +22,7 @@ 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"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts index c1afd211b..653384675 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-prefers-per-agent-subagent-model.test.ts @@ -21,6 +21,7 @@ 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"; diff --git a/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts b/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts index 8a094fb6d..18f5ab26b 100644 --- a/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts +++ b/src/agents/clawdbot-tools.subagents.sessions-spawn-resolves-main-announce-target-from.test.ts @@ -22,6 +22,7 @@ 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"; diff --git a/src/agents/pi-embedded-runner/runs.ts b/src/agents/pi-embedded-runner/runs.ts index 4fcefca12..dcbe56244 100644 --- a/src/agents/pi-embedded-runner/runs.ts +++ b/src/agents/pi-embedded-runner/runs.ts @@ -109,14 +109,18 @@ export function setActiveEmbeddedRun(sessionId: string, handle: EmbeddedPiQueueH state: "processing", reason: wasActive ? "run_replaced" : "run_started", }); - diag.info(`run registered: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + 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" }); - diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + if (!sessionId.startsWith("probe-")) { + diag.info(`run cleared: sessionId=${sessionId} totalActive=${ACTIVE_EMBEDDED_RUNS.size}`); + } notifyEmbeddedRunEnded(sessionId); } else { diag.debug(`run clear skipped: sessionId=${sessionId} reason=handle_mismatch`); diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts index e440ecaeb..e332c13eb 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-b.test.ts @@ -1,5 +1,6 @@ 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(); diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts index f493164cd..bd13aa25c 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts @@ -3,6 +3,7 @@ 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(); diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts index 35549a4d3..ed557b922 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts @@ -2,6 +2,7 @@ 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", () => { diff --git a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts index 8cb3a3522..221222338 100644 --- a/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ b/src/agents/pi-tools.create-clawdbot-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts @@ -3,6 +3,7 @@ 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"; diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts new file mode 100644 index 000000000..8f93bface --- /dev/null +++ b/src/agents/session-write-lock.test.ts @@ -0,0 +1,34 @@ +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 }); + } + }); +}); diff --git a/src/agents/session-write-lock.ts b/src/agents/session-write-lock.ts index 99478c2cd..54e61d965 100644 --- a/src/agents/session-write-lock.ts +++ b/src/agents/session-write-lock.ts @@ -45,20 +45,28 @@ export async function acquireSessionWriteLock(params: { }> { const timeoutMs = params.timeoutMs ?? 10_000; const staleMs = params.staleMs ?? 30 * 60 * 1000; - const sessionFile = params.sessionFile; - const lockPath = `${sessionFile}.lock`; - await fs.mkdir(path.dirname(lockPath), { recursive: true }); + 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 held = HELD_LOCKS.get(sessionFile); + const held = HELD_LOCKS.get(normalizedSessionFile); if (held) { held.count += 1; return { release: async () => { - const current = HELD_LOCKS.get(sessionFile); + const current = HELD_LOCKS.get(normalizedSessionFile); if (!current) return; current.count -= 1; if (current.count > 0) return; - HELD_LOCKS.delete(sessionFile); + HELD_LOCKS.delete(normalizedSessionFile); await current.handle.close(); await fs.rm(current.lockPath, { force: true }); }, @@ -75,14 +83,14 @@ export async function acquireSessionWriteLock(params: { JSON.stringify({ pid: process.pid, createdAt: new Date().toISOString() }, null, 2), "utf8", ); - HELD_LOCKS.set(sessionFile, { count: 1, handle, lockPath }); + HELD_LOCKS.set(normalizedSessionFile, { count: 1, handle, lockPath }); return { release: async () => { - const current = HELD_LOCKS.get(sessionFile); + const current = HELD_LOCKS.get(normalizedSessionFile); if (!current) return; current.count -= 1; if (current.count > 0) return; - HELD_LOCKS.delete(sessionFile); + HELD_LOCKS.delete(normalizedSessionFile); await current.handle.close(); await fs.rm(current.lockPath, { force: true }); }, diff --git a/src/agents/test-helpers/fast-coding-tools.ts b/src/agents/test-helpers/fast-coding-tools.ts new file mode 100644 index 000000000..99b4ab351 --- /dev/null +++ b/src/agents/test-helpers/fast-coding-tools.ts @@ -0,0 +1,22 @@ +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, +})); diff --git a/src/agents/test-helpers/fast-core-tools.ts b/src/agents/test-helpers/fast-core-tools.ts new file mode 100644 index 000000000..d459c8276 --- /dev/null +++ b/src/agents/test-helpers/fast-core-tools.ts @@ -0,0 +1,30 @@ +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, +})); diff --git a/src/auto-reply/command-auth.test.ts b/src/auto-reply/command-auth.test.ts deleted file mode 100644 index 0b6cf0826..000000000 --- a/src/auto-reply/command-auth.test.ts +++ /dev/null @@ -1,122 +0,0 @@ -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); - }); -}); diff --git a/src/auto-reply/command-detection.test.ts b/src/auto-reply/command-control.test.ts similarity index 56% rename from src/auto-reply/command-detection.test.ts rename to src/auto-reply/command-control.test.ts index 66f9d15c7..cb65e60ef 100644 --- a/src/auto-reply/command-detection.test.ts +++ b/src/auto-reply/command-control.test.ts @@ -1,10 +1,14 @@ 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 { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createTestRegistry } from "../test-utils/channel-plugins.js"; +import type { MsgContext } from "./templating.js"; beforeEach(() => { setActivePluginRegistry(createTestRegistry([])); @@ -14,6 +18,123 @@ 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({ diff --git a/src/auto-reply/commands-registry.args.test.ts b/src/auto-reply/commands-registry.args.test.ts deleted file mode 100644 index cee8cf5f3..000000000 --- a/src/auto-reply/commands-registry.args.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -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(); - }); -}); diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts index 4296e06cd..e1192c9cd 100644 --- a/src/auto-reply/commands-registry.test.ts +++ b/src/auto-reply/commands-registry.test.ts @@ -2,14 +2,19 @@ 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"; @@ -154,3 +159,150 @@ 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(); + }); +}); diff --git a/src/auto-reply/inbound-debounce.test.ts b/src/auto-reply/inbound-debounce.test.ts deleted file mode 100644 index a50d403b4..000000000 --- a/src/auto-reply/inbound-debounce.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 = []; - - 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 = []; - - 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(); - }); -}); diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts new file mode 100644 index 000000000..c58b98e54 --- /dev/null +++ b/src/auto-reply/inbound.test.ts @@ -0,0 +1,402 @@ +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; + 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).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: "", + 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 = []; + + 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 = []; + + 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); + }); +}); diff --git a/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts b/src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.test.ts rename to src/auto-reply/reply.directive.directive-behavior.accepts-thinking-xhigh-codex-models.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts rename to src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts rename to src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts b/src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.test.ts rename to src/auto-reply/reply.directive.directive-behavior.ignores-inline-model-uses-default-model.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts b/src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.test.ts rename to src/auto-reply/reply.directive.directive-behavior.lists-allowlisted-models-model-list.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts rename to src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts b/src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.test.ts rename to src/auto-reply/reply.directive.directive-behavior.requires-per-agent-allowlist-addition-global.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts b/src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.test.ts rename to src/auto-reply/reply.directive.directive-behavior.returns-status-alongside-directive-only-acks.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.test.ts rename to src/auto-reply/reply.directive.directive-behavior.shows-current-elevated-level-as-off-after.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.test.ts rename to src/auto-reply/reply.directive.directive-behavior.shows-current-verbose-level-verbose-has-no.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts b/src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.test.ts rename to src/auto-reply/reply.directive.directive-behavior.supports-fuzzy-model-matches-model-directive.e2e.test.ts diff --git a/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts b/src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.test.ts rename to src/auto-reply/reply.directive.directive-behavior.updates-tool-verbose-during-flight-run-toggle.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.group-intro-prompts.test.ts b/src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.group-intro-prompts.test.ts rename to src/auto-reply/reply.triggers.group-intro-prompts.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.allows-activation-from-allowfrom-groups.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.allows-approved-sender-toggle-elevated-mode.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts b/src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.allows-elevated-off-groups-without-mention.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts b/src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts b/src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.handles-inline-commands-strips-it-before-agent.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts b/src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.ignores-inline-elevated-directive-unapproved-sender.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts b/src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.includes-error-cause-embedded-agent-throws.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts b/src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.keeps-inline-status-unauthorized-senders.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts b/src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.reports-active-auth-profile-key-snippet-status.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.runs-greeting-prompt-bare-reset.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.shows-endpoint-default-model-status-not-configured.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts b/src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.shows-quick-model-picker-grouped-by-model.e2e.test.ts diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index 23e66f1a5..798ddb28b 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -2,174 +2,79 @@ 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"; -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), +const sandboxMocks = vi.hoisted(() => ({ + ensureSandboxWorkspaceForSession: vi.fn(), })); -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("../agents/sandbox.js", () => sandboxMocks); -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 { 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); +import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; async function withTempHome(fn: (home: string) => Promise): Promise { - 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") }, - }; + return withTempHomeBase(async (home) => await fn(home), { prefix: "clawdbot-triggers-" }); } afterEach(() => { vi.restoreAllMocks(); }); -describe("trigger handling", () => { - it("stages inbound media into the sandbox workspace", { timeout: 60_000 }, async () => { +describe("stageSandboxMedia", () => { + it("stages inbound media into the sandbox workspace", 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"); - vi.mocked(runEmbeddedPiAgent).mockResolvedValue({ - payloads: [{ text: "ok" }], - meta: { - durationMs: 1, - agentMeta: { sessionId: "s", provider: "p", model: "m" }, - }, + const sandboxDir = join(home, "sandboxes", "session"); + vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ + workspaceDir: sandboxDir, + containerWorkdir: "/work", }); - 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 = { + const ctx: MsgContext = { Body: "hi", From: "whatsapp:group:demo", To: "+2000", - ChatType: "group" as const, - Provider: "whatsapp" as const, + ChatType: "group", + Provider: "whatsapp", MediaPath: mediaPath, MediaType: "image/jpeg", MediaUrl: mediaPath, }; + const sessionCtx: TemplateContext = { ...ctx }; - 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(prompt).toContain(stagedPath); - expect(prompt).not.toContain(mediaPath); - - const sessionKey = resolveSessionKey( - cfg.session?.scope ?? "per-sender", + await stageSandboxMedia({ ctx, - cfg.session?.mainKey, - ); - const agentId = resolveAgentIdFromSessionKey(sessionKey); - const sandbox = await ensureSandboxWorkspaceForSession({ - config: cfg, - sessionKey, - workspaceDir: resolveAgentWorkspaceDir(cfg, agentId), + 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"), }); - expect(sandbox).not.toBeNull(); - if (!sandbox) { - throw new Error("Expected sandbox to be set"); - } - const stagedFullPath = join(sandbox.workspaceDir, "media", "inbound", basename(mediaPath)); + + 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); + + const stagedFullPath = join(sandboxDir, "media", "inbound", basename(mediaPath)); await expect(fs.stat(stagedFullPath)).resolves.toBeTruthy(); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts b/src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts similarity index 100% rename from src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.test.ts rename to src/auto-reply/reply.triggers.trigger-handling.targets-active-session-native-stop.e2e.test.ts diff --git a/src/auto-reply/reply/audio-tags.test.ts b/src/auto-reply/reply/audio-tags.test.ts deleted file mode 100644 index 48d952c15..000000000 --- a/src/auto-reply/reply/audio-tags.test.ts +++ /dev/null @@ -1,25 +0,0 @@ -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(""); - }); -}); diff --git a/src/auto-reply/reply/block-reply-coalescer.test.ts b/src/auto-reply/reply/block-reply-coalescer.test.ts deleted file mode 100644 index 06f7e42cc..000000000 --- a/src/auto-reply/reply/block-reply-coalescer.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/commands-allowlist.test.ts b/src/auto-reply/reply/commands-allowlist.test.ts deleted file mode 100644 index 60c6fdecd..000000000 --- a/src/auto-reply/reply/commands-allowlist.test.ts +++ /dev/null @@ -1,139 +0,0 @@ -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("../../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( - "../../pairing/pairing-store.js", - ); - return { - ...actual, - readChannelAllowFromStore: readChannelAllowFromStoreMock, - addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, - removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, - }; -}); - -vi.mock("../../channels/plugins/pairing.js", async () => { - const actual = await vi.importActual( - "../../channels/plugins/pairing.js", - ); - return { - ...actual, - listPairingChannels: () => ["telegram"], - }; -}); - -function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { - 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"); - }); -}); diff --git a/src/auto-reply/reply/commands-config-writes.test.ts b/src/auto-reply/reply/commands-config-writes.test.ts deleted file mode 100644 index 7c55c3a01..000000000 --- a/src/auto-reply/reply/commands-config-writes.test.ts +++ /dev/null @@ -1,57 +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 { parseInlineDirectives } from "./directive-handling.js"; - -function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Partial) { - 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"); - }); -}); diff --git a/src/auto-reply/reply/commands-parsing.test.ts b/src/auto-reply/reply/commands-parsing.test.ts new file mode 100644 index 000000000..1c60dc98a --- /dev/null +++ b/src/auto-reply/reply/commands-parsing.test.ts @@ -0,0 +1,125 @@ +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) { + 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"); + }); +}); diff --git a/src/auto-reply/reply/commands-models.test.ts b/src/auto-reply/reply/commands-policy.test.ts similarity index 59% rename from src/auto-reply/reply/commands-models.test.ts rename to src/auto-reply/reply/commands-policy.test.ts index c32abfc7d..5ba5026a7 100644 --- a/src/auto-reply/reply/commands-models.test.ts +++ b/src/auto-reply/reply/commands-policy.test.ts @@ -5,6 +5,47 @@ 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("../../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( + "../../pairing/pairing-store.js", + ); + return { + ...actual, + readChannelAllowFromStore: readChannelAllowFromStoreMock, + addChannelAllowFromStoreEntry: addChannelAllowFromStoreEntryMock, + removeChannelAllowFromStoreEntry: removeChannelAllowFromStoreEntryMock, + }; +}); + +vi.mock("../../channels/plugins/pairing.js", async () => { + const actual = await vi.importActual( + "../../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" }, @@ -46,17 +87,70 @@ function buildParams(commandBody: string, cfg: ClawdbotConfig, ctxOverrides?: Pa resolvedVerboseLevel: "off" as const, resolvedReasoningLevel: "off" as const, resolveDefaultThinkingLevel: async () => undefined, - provider: "anthropic", - model: "claude-opus-4-5", - contextTokens: 16000, + 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"); + }); +}); + 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; diff --git a/src/auto-reply/reply/commands-subagents.test.ts b/src/auto-reply/reply/commands-subagents.test.ts deleted file mode 100644 index eaf7c3026..000000000 --- a/src/auto-reply/reply/commands-subagents.test.ts +++ /dev/null @@ -1,23 +0,0 @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/config-commands.test.ts b/src/auto-reply/reply/config-commands.test.ts deleted file mode 100644 index a1d19f039..000000000 --- a/src/auto-reply/reply/config-commands.test.ts +++ /dev/null @@ -1,30 +0,0 @@ -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 } }); - }); -}); diff --git a/src/auto-reply/reply/debug-commands.test.ts b/src/auto-reply/reply/debug-commands.test.ts deleted file mode 100644 index 8c2094520..000000000 --- a/src/auto-reply/reply/debug-commands.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -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" }); - }); -}); diff --git a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts b/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts deleted file mode 100644 index c1e2ab7d9..000000000 --- a/src/auto-reply/reply/directive-handling.model.chat-ux.test.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 "); - }); - - 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(); - }); -}); diff --git a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts similarity index 66% rename from src/auto-reply/reply/directive-handling.impl.model-persist.test.ts rename to src/auto-reply/reply/directive-handling.model.test.ts index 847ff7030..abd2ff8ef 100644 --- a/src/auto-reply/reply/directive-handling.impl.model-persist.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -5,8 +5,12 @@ 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 +// Mock dependencies for directive handling persistence. vi.mock("../../agents/agent-scope.js", () => ({ resolveAgentConfig: vi.fn(() => ({})), resolveAgentDir: vi.fn(() => "/tmp/agent"), @@ -36,6 +40,55 @@ 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 "); + }); + + 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 = [ @@ -106,7 +159,6 @@ 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"); }); diff --git a/src/auto-reply/reply/followup-runner.compaction.test.ts b/src/auto-reply/reply/followup-runner.compaction.test.ts deleted file mode 100644 index 7ea021764..000000000 --- a/src/auto-reply/reply/followup-runner.compaction.test.ts +++ /dev/null @@ -1,105 +0,0 @@ -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; - }) => ({ - 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 = { - main: sessionEntry, - }; - const onBlockReply = vi.fn(async () => {}); - - runEmbeddedPiAgentMock.mockImplementationOnce( - async (params: { - onAgentEvent?: (evt: { stream: string; data: Record }) => 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); - }); -}); diff --git a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts b/src/auto-reply/reply/followup-runner.test.ts similarity index 60% rename from src/auto-reply/reply/followup-runner.messaging-tools.test.ts rename to src/auto-reply/reply/followup-runner.test.ts index dd080eedc..19213081d 100644 --- a/src/auto-reply/reply/followup-runner.messaging-tools.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1,5 +1,9 @@ +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"; @@ -57,6 +61,79 @@ 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 = { + main: sessionEntry, + }; + const onBlockReply = vi.fn(async () => {}); + + runEmbeddedPiAgentMock.mockImplementationOnce( + async (params: { + onAgentEvent?: (evt: { stream: string; data: Record }) => 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 () => {}); diff --git a/src/auto-reply/reply/formatting.test.ts b/src/auto-reply/reply/formatting.test.ts new file mode 100644 index 000000000..a7a9f6174 --- /dev/null +++ b/src/auto-reply/reply/formatting.test.ts @@ -0,0 +1,185 @@ +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); + }); +}); diff --git a/src/auto-reply/reply/get-reply.ts b/src/auto-reply/reply/get-reply.ts index e9903fdf1..20887c340 100644 --- a/src/auto-reply/reply/get-reply.ts +++ b/src/auto-reply/reply/get-reply.ts @@ -28,6 +28,7 @@ export async function getReplyFromConfig( opts?: GetReplyOptions, configOverride?: ClawdbotConfig, ): Promise { + const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1"; const cfg = configOverride ?? loadConfig(); const targetSessionKey = ctx.CommandSource === "native" ? ctx.CommandTargetSessionKey?.trim() : undefined; @@ -62,7 +63,7 @@ export async function getReplyFromConfig( const workspaceDirRaw = resolveAgentWorkspaceDir(cfg, agentId) ?? DEFAULT_AGENT_WORKSPACE_DIR; const workspace = await ensureAgentWorkspace({ dir: workspaceDirRaw, - ensureBootstrapFiles: !agentCfg?.skipBootstrap, + ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, }); const workspaceDir = workspace.dir; const agentDir = resolveAgentDir(cfg, agentId); @@ -81,12 +82,14 @@ export async function getReplyFromConfig( const finalized = finalizeInboundContext(ctx); - await applyMediaUnderstanding({ - ctx: finalized, - cfg, - agentDir, - activeModel: { provider, model }, - }); + if (!isFastTestEnv) { + await applyMediaUnderstanding({ + ctx: finalized, + cfg, + agentDir, + activeModel: { provider, model }, + }); + } const commandAuthorized = finalized.CommandAuthorized; resolveCommandAuthorization({ diff --git a/src/auto-reply/reply/groups.test.ts b/src/auto-reply/reply/groups.test.ts deleted file mode 100644 index 6ae069141..000000000 --- a/src/auto-reply/reply/groups.test.ts +++ /dev/null @@ -1,61 +0,0 @@ -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); - }); -}); diff --git a/src/auto-reply/reply/inbound-context.test.ts b/src/auto-reply/reply/inbound-context.test.ts deleted file mode 100644 index 58647176c..000000000 --- a/src/auto-reply/reply/inbound-context.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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: "", - CommandBody: "say hi", - From: "signal:+15550001111", - ChatType: "direct", - }; - - finalizeInboundContext(ctx, { forceBodyForCommands: true }); - expect(ctx.BodyForCommands).toBe("say hi"); - }); -}); diff --git a/src/auto-reply/reply/inbound-dedupe.test.ts b/src/auto-reply/reply/inbound-dedupe.test.ts deleted file mode 100644 index d9dbd148a..000000000 --- a/src/auto-reply/reply/inbound-dedupe.test.ts +++ /dev/null @@ -1,66 +0,0 @@ -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); - }); -}); diff --git a/src/auto-reply/reply/inbound-sender-meta.test.ts b/src/auto-reply/reply/inbound-sender-meta.test.ts deleted file mode 100644 index 2bc8d3d86..000000000 --- a/src/auto-reply/reply/inbound-sender-meta.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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", - ); - }); -}); diff --git a/src/auto-reply/reply/inbound-text.test.ts b/src/auto-reply/reply/inbound-text.test.ts deleted file mode 100644 index d1ac537d5..000000000 --- a/src/auto-reply/reply/inbound-text.test.ts +++ /dev/null @@ -1,18 +0,0 @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/mentions.test.ts b/src/auto-reply/reply/mentions.test.ts deleted file mode 100644 index d0c16977a..000000000 --- a/src/auto-reply/reply/mentions.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -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); - }); -}); diff --git a/src/auto-reply/reply/reply-reference.test.ts b/src/auto-reply/reply/reply-reference.test.ts deleted file mode 100644 index 57f29763c..000000000 --- a/src/auto-reply/reply/reply-reference.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -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); - }); -}); diff --git a/src/auto-reply/reply/reply-dispatcher.test.ts b/src/auto-reply/reply/reply-routing.test.ts similarity index 60% rename from src/auto-reply/reply/reply-dispatcher.test.ts rename to src/auto-reply/reply/reply-routing.test.ts index 3c4780505..3f369ec92 100644 --- a/src/auto-reply/reply/reply-dispatcher.test.ts +++ b/src/auto-reply/reply/reply-routing.test.ts @@ -1,6 +1,11 @@ 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 () => { @@ -150,3 +155,94 @@ 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(); + }); +}); diff --git a/src/auto-reply/reply/reply-threading.test.ts b/src/auto-reply/reply/reply-threading.test.ts deleted file mode 100644 index 2a4e9a7f3..000000000 --- a/src/auto-reply/reply/reply-threading.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/session-reset-model.test.ts b/src/auto-reply/reply/session-reset-model.test.ts deleted file mode 100644 index db840038c..000000000 --- a/src/auto-reply/reply/session-reset-model.test.ts +++ /dev/null @@ -1,107 +0,0 @@ -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"); - }); -}); diff --git a/src/auto-reply/reply/session-reset-group.test.ts b/src/auto-reply/reply/session-resets.test.ts similarity index 62% rename from src/auto-reply/reply/session-reset-group.test.ts rename to src/auto-reply/reply/session-resets.test.ts index ed08bd5a1..4f0903521 100644 --- a/src/auto-reply/reply/session-reset-group.test.ts +++ b/src/auto-reply/reply/session-resets.test.ts @@ -2,10 +2,21 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } 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 { @@ -54,7 +65,6 @@ 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", @@ -76,7 +86,6 @@ 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); @@ -99,7 +108,6 @@ 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", @@ -111,7 +119,7 @@ describe("initSessionState reset triggers in WhatsApp groups", () => { Provider: "whatsapp", Surface: "whatsapp", SenderName: "OtherPerson", - SenderE164: "+1555123456", // Different sender (not authorized) + SenderE164: "+1555123456", SenderId: "1555123456:0@s.whatsapp.net", }; @@ -121,9 +129,8 @@ 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); // Session should NOT change + expect(result.sessionId).toBe(existingSessionId); expect(result.isNewSession).toBe(false); }); @@ -143,9 +150,7 @@ 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", @@ -251,3 +256,124 @@ 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(); + }); +}); diff --git a/src/auto-reply/reply/session-updates.test.ts b/src/auto-reply/reply/session-updates.test.ts deleted file mode 100644 index d673e2b4f..000000000 --- a/src/auto-reply/reply/session-updates.test.ts +++ /dev/null @@ -1,31 +0,0 @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/session.sender-meta.test.ts b/src/auto-reply/reply/session.sender-meta.test.ts deleted file mode 100644 index 455cfbb11..000000000 --- a/src/auto-reply/reply/session.sender-meta.test.ts +++ /dev/null @@ -1,51 +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 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"); - }); -}); diff --git a/src/auto-reply/reply/streaming-directives.test.ts b/src/auto-reply/reply/streaming-directives.test.ts deleted file mode 100644 index 02d32ded8..000000000 --- a/src/auto-reply/reply/streaming-directives.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -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); - }); -}); diff --git a/src/auto-reply/reply/typing-mode.test.ts b/src/auto-reply/reply/typing-mode.test.ts deleted file mode 100644 index 064e58adf..000000000 --- a/src/auto-reply/reply/typing-mode.test.ts +++ /dev/null @@ -1,193 +0,0 @@ -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(); - }); -}); diff --git a/src/auto-reply/reply/typing.test.ts b/src/auto-reply/reply/typing.test.ts index da7033162..06e9003c5 100644 --- a/src/auto-reply/reply/typing.test.ts +++ b/src/auto-reply/reply/typing.test.ts @@ -1,5 +1,7 @@ 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", () => { @@ -91,3 +93,192 @@ 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(); + }); +}); diff --git a/src/auto-reply/templating.test.ts b/src/auto-reply/templating.test.ts deleted file mode 100644 index a4be64f4b..000000000 --- a/src/auto-reply/templating.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -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; - 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).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="); - }); -}); diff --git a/src/cli/models-cli.ts b/src/cli/models-cli.ts index a2674d94a..20a476f81 100644 --- a/src/cli/models-cli.ts +++ b/src/cli/models-cli.ts @@ -71,9 +71,36 @@ 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 ", "Only probe a single provider") + .option( + "--probe-profile ", + "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 ", "Per-probe timeout in ms") + .option("--probe-concurrency ", "Concurrent probes") + .option("--probe-max-tokens ", "Probe max tokens (best-effort)") .action(async (opts) => { await runModelsCommand(async () => { - await modelsStatusCommand(opts, defaultRuntime); + 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, + ); }); }); diff --git a/src/cli/program.smoke.test.ts b/src/cli/program.smoke.test.ts index 10ebc9188..3dc01fcb2 100644 --- a/src/cli/program.smoke.test.ts +++ b/src/cli/program.smoke.test.ts @@ -122,142 +122,62 @@ describe("cli program (smoke)", () => { expect(setupCommand).not.toHaveBeenCalled(); }); - 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, + it("passes auth api keys to onboard", async () => { + const cases = [ + { authChoice: "opencode-zen", - opencodeZenApiKey: "sk-opencode-zen-test", - }), - runtime, - ); - }); - - 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, + flag: "--opencode-zen-api-key", + key: "sk-opencode-zen-test", + field: "opencodeZenApiKey", + }, + { 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, + flag: "--openrouter-api-key", + key: "sk-openrouter-test", + field: "openrouterApiKey", + }, + { 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, + flag: "--moonshot-api-key", + key: "sk-moonshot-test", + field: "moonshotApiKey", + }, + { 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, + flag: "--kimi-code-api-key", + key: "sk-kimi-code-test", + field: "kimiCodeApiKey", + }, + { 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, + flag: "--synthetic-api-key", + key: "sk-synthetic-test", + field: "syntheticApiKey", + }, + { authChoice: "zai-api-key", - zaiApiKey: "sk-zai-test", - }), - runtime, - ); + flag: "--zai-api-key", + key: "sk-zai-test", + field: "zaiApiKey", + }, + ] as const; + + 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("runs channels login", async () => { diff --git a/src/commands/models.list.test.ts b/src/commands/models.list.test.ts index 47ebfe2f5..850f27246 100644 --- a/src/commands/models.list.test.ts +++ b/src/commands/models.list.test.ts @@ -17,6 +17,7 @@ const discoverModels = vi.fn(); vi.mock("../config/config.js", () => ({ CONFIG_PATH_CLAWDBOT: "/tmp/clawdbot.json", + STATE_DIR_CLAWDBOT: "/tmp/clawdbot-state", loadConfig, })); diff --git a/src/commands/models/list.probe.ts b/src/commands/models/list.probe.ts new file mode 100644 index 000000000..fbd172b57 --- /dev/null +++ b/src/commands/models/list.probe.ts @@ -0,0 +1,414 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; + +import { resolveClawdbotAgentDir } from "../../agents/agent-paths.js"; +import { + ensureAuthProfileStore, + listProfilesForProvider, + resolveAuthProfileDisplayLabel, +} from "../../agents/auth-profiles.js"; +import { runEmbeddedPiAgent } from "../../agents/pi-embedded.js"; +import { describeFailoverError } from "../../agents/failover-error.js"; +import { loadModelCatalog } from "../../agents/model-catalog.js"; +import { getCustomProviderApiKey, resolveEnvApiKey } from "../../agents/model-auth.js"; +import { normalizeProviderId, parseModelRef } from "../../agents/model-selection.js"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import { resolveDefaultAgentWorkspaceDir } from "../../agents/workspace.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { + resolveSessionTranscriptPath, + resolveSessionTranscriptsDirForAgent, +} from "../../config/sessions/paths.js"; +import { redactSecrets } from "../status-all/format.js"; +import { DEFAULT_PROVIDER, formatMs } from "./shared.js"; + +const PROBE_PROMPT = "Reply with OK. Do not use tools."; + +export type AuthProbeStatus = + | "ok" + | "auth" + | "rate_limit" + | "billing" + | "timeout" + | "format" + | "unknown" + | "no_model"; + +export type AuthProbeResult = { + provider: string; + model?: string; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; + status: AuthProbeStatus; + error?: string; + latencyMs?: number; +}; + +type AuthProbeTarget = { + provider: string; + model?: { provider: string; model: string } | null; + profileId?: string; + label: string; + source: "profile" | "env" | "models.json"; + mode?: string; +}; + +export type AuthProbeSummary = { + startedAt: number; + finishedAt: number; + durationMs: number; + totalTargets: number; + options: { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; + }; + results: AuthProbeResult[]; +}; + +export type AuthProbeOptions = { + provider?: string; + profileIds?: string[]; + timeoutMs: number; + concurrency: number; + maxTokens: number; +}; + +const toStatus = (reason?: string | null): AuthProbeStatus => { + if (!reason) return "unknown"; + if (reason === "auth") return "auth"; + if (reason === "rate_limit") return "rate_limit"; + if (reason === "billing") return "billing"; + if (reason === "timeout") return "timeout"; + if (reason === "format") return "format"; + return "unknown"; +}; + +function buildCandidateMap(modelCandidates: string[]): Map { + const map = new Map(); + for (const raw of modelCandidates) { + const parsed = parseModelRef(String(raw ?? ""), DEFAULT_PROVIDER); + if (!parsed) continue; + const list = map.get(parsed.provider) ?? []; + if (!list.includes(parsed.model)) list.push(parsed.model); + map.set(parsed.provider, list); + } + return map; +} + +function selectProbeModel(params: { + provider: string; + candidates: Map; + catalog: Array<{ provider: string; id: string }>; +}): { provider: string; model: string } | null { + const { provider, candidates, catalog } = params; + const direct = candidates.get(provider); + if (direct && direct.length > 0) { + return { provider, model: direct[0] }; + } + const fromCatalog = catalog.find((entry) => entry.provider === provider); + if (fromCatalog) return { provider: fromCatalog.provider, model: fromCatalog.id }; + return null; +} + +function buildProbeTargets(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; +}): Promise<{ targets: AuthProbeTarget[]; results: AuthProbeResult[] }> { + const { cfg, providers, modelCandidates, options } = params; + const store = ensureAuthProfileStore(); + const providerFilter = options.provider?.trim(); + const providerFilterKey = providerFilter ? normalizeProviderId(providerFilter) : null; + const profileFilter = new Set((options.profileIds ?? []).map((id) => id.trim()).filter(Boolean)); + + return loadModelCatalog({ config: cfg }).then((catalog) => { + const candidates = buildCandidateMap(modelCandidates); + const targets: AuthProbeTarget[] = []; + const results: AuthProbeResult[] = []; + + for (const provider of providers) { + const providerKey = normalizeProviderId(provider); + if (providerFilterKey && providerKey !== providerFilterKey) continue; + + const model = selectProbeModel({ + provider: providerKey, + candidates, + catalog, + }); + + const profileIds = listProfilesForProvider(store, providerKey); + const filteredProfiles = profileFilter.size + ? profileIds.filter((id) => profileFilter.has(id)) + : profileIds; + + if (filteredProfiles.length > 0) { + for (const profileId of filteredProfiles) { + const profile = store.profiles[profileId]; + const mode = profile?.type; + const label = resolveAuthProfileDisplayLabel({ cfg, store, profileId }); + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + profileId, + label, + source: "profile", + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + targets.push({ + provider: providerKey, + model, + profileId, + label, + source: "profile", + mode, + }); + } + continue; + } + + if (profileFilter.size > 0) continue; + + const envKey = resolveEnvApiKey(providerKey); + const customKey = getCustomProviderApiKey(cfg, providerKey); + if (!envKey && !customKey) continue; + + const label = envKey ? "env" : "models.json"; + const source = envKey ? "env" : "models.json"; + const mode = envKey?.source.includes("OAUTH_TOKEN") ? "oauth" : "api_key"; + + if (!model) { + results.push({ + provider: providerKey, + model: undefined, + label, + source, + mode, + status: "no_model", + error: "No model available for probe", + }); + continue; + } + + targets.push({ + provider: providerKey, + model, + label, + source, + mode, + }); + } + + return { targets, results }; + }); +} + +async function probeTarget(params: { + cfg: ClawdbotConfig; + agentId: string; + agentDir: string; + workspaceDir: string; + sessionDir: string; + target: AuthProbeTarget; + timeoutMs: number; + maxTokens: number; +}): Promise { + const { cfg, agentId, agentDir, workspaceDir, sessionDir, target, timeoutMs, maxTokens } = params; + if (!target.model) { + return { + provider: target.provider, + model: undefined, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "no_model", + error: "No model available for probe", + }; + } + + const sessionId = `probe-${target.provider}-${crypto.randomUUID()}`; + const sessionFile = resolveSessionTranscriptPath(sessionId, agentId); + await fs.mkdir(sessionDir, { recursive: true }); + + const start = Date.now(); + try { + await runEmbeddedPiAgent({ + sessionId, + sessionFile, + workspaceDir, + agentDir, + config: cfg, + prompt: PROBE_PROMPT, + provider: target.model.provider, + model: target.model.model, + authProfileId: target.profileId, + authProfileIdSource: target.profileId ? "user" : undefined, + timeoutMs, + runId: `probe-${crypto.randomUUID()}`, + lane: `auth-probe:${target.provider}:${target.profileId ?? target.source}`, + thinkLevel: "off", + reasoningLevel: "off", + verboseLevel: "off", + streamParams: { maxTokens }, + }); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: "ok", + latencyMs: Date.now() - start, + }; + } catch (err) { + const described = describeFailoverError(err); + return { + provider: target.provider, + model: `${target.model.provider}/${target.model.model}`, + profileId: target.profileId, + label: target.label, + source: target.source, + mode: target.mode, + status: toStatus(described.reason), + error: redactSecrets(described.message), + latencyMs: Date.now() - start, + }; + } +} + +async function runTargetsWithConcurrency(params: { + cfg: ClawdbotConfig; + targets: AuthProbeTarget[]; + timeoutMs: number; + maxTokens: number; + concurrency: number; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const { cfg, targets, timeoutMs, maxTokens, onProgress } = params; + const concurrency = Math.max(1, Math.min(targets.length || 1, params.concurrency)); + + const agentId = resolveDefaultAgentId(cfg); + const agentDir = resolveClawdbotAgentDir(); + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId) ?? resolveDefaultAgentWorkspaceDir(); + const sessionDir = resolveSessionTranscriptsDirForAgent(agentId); + + await fs.mkdir(workspaceDir, { recursive: true }); + + let completed = 0; + const results: Array = Array.from({ length: targets.length }); + let cursor = 0; + + const worker = async () => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= targets.length) return; + const target = targets[index]; + onProgress?.({ + completed, + total: targets.length, + label: `Probing ${target.provider}${target.profileId ? ` (${target.label})` : ""}`, + }); + const result = await probeTarget({ + cfg, + agentId, + agentDir, + workspaceDir, + sessionDir, + target, + timeoutMs, + maxTokens, + }); + results[index] = result; + completed += 1; + onProgress?.({ completed, total: targets.length }); + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + + return results.filter((entry): entry is AuthProbeResult => Boolean(entry)); +} + +export async function runAuthProbes(params: { + cfg: ClawdbotConfig; + providers: string[]; + modelCandidates: string[]; + options: AuthProbeOptions; + onProgress?: (update: { completed: number; total: number; label?: string }) => void; +}): Promise { + const startedAt = Date.now(); + const plan = await buildProbeTargets({ + cfg: params.cfg, + providers: params.providers, + modelCandidates: params.modelCandidates, + options: params.options, + }); + + const totalTargets = plan.targets.length; + params.onProgress?.({ completed: 0, total: totalTargets }); + + const results = totalTargets + ? await runTargetsWithConcurrency({ + cfg: params.cfg, + targets: plan.targets, + timeoutMs: params.options.timeoutMs, + maxTokens: params.options.maxTokens, + concurrency: params.options.concurrency, + onProgress: params.onProgress, + }) + : []; + + const finishedAt = Date.now(); + + return { + startedAt, + finishedAt, + durationMs: finishedAt - startedAt, + totalTargets, + options: params.options, + results: [...plan.results, ...results], + }; +} + +export function formatProbeLatency(latencyMs?: number | null) { + if (!latencyMs && latencyMs !== 0) return "-"; + return formatMs(latencyMs); +} + +export function groupProbeResults(results: AuthProbeResult[]): Map { + const map = new Map(); + for (const result of results) { + const list = map.get(result.provider) ?? []; + list.push(result); + map.set(result.provider, list); + } + return map; +} + +export function sortProbeResults(results: AuthProbeResult[]): AuthProbeResult[] { + return results.slice().sort((a, b) => { + const provider = a.provider.localeCompare(b.provider); + if (provider !== 0) return provider; + const aLabel = a.label || a.profileId || ""; + const bLabel = b.label || b.profileId || ""; + return aLabel.localeCompare(bLabel); + }); +} + +export function describeProbeSummary(summary: AuthProbeSummary): string { + if (summary.totalTargets === 0) return "No probe targets."; + return `Probed ${summary.totalTargets} target${summary.totalTargets === 1 ? "" : "s"} in ${formatMs(summary.durationMs)}`; +} diff --git a/src/commands/models/list.status-command.ts b/src/commands/models/list.status-command.ts index 0bd8f16e9..41c126460 100644 --- a/src/commands/models/list.status-command.ts +++ b/src/commands/models/list.status-command.ts @@ -11,9 +11,15 @@ import { resolveProfileUnusableUntilForDisplay, } from "../../agents/auth-profiles.js"; import { resolveEnvApiKey } from "../../agents/model-auth.js"; -import { parseModelRef, resolveConfiguredModelRef } from "../../agents/model-selection.js"; +import { + buildModelAliasIndex, + parseModelRef, + resolveConfiguredModelRef, + resolveModelRefFromString, +} from "../../agents/model-selection.js"; import { CONFIG_PATH_CLAWDBOT, loadConfig } from "../../config/config.js"; import { getShellEnvAppliedKeys, shouldEnableShellEnvFallback } from "../../infra/shell-env.js"; +import { withProgressTotals } from "../../cli/progress.js"; import { formatUsageWindowSummary, loadProviderUsageSummary, @@ -26,13 +32,34 @@ import { formatCliCommand } from "../../cli/command-format.js"; import { shortenHomePath } from "../../utils.js"; import { resolveProviderAuthOverview } from "./list.auth-overview.js"; import { isRich } from "./list.format.js"; +import { + describeProbeSummary, + formatProbeLatency, + groupProbeResults, + runAuthProbes, + sortProbeResults, + type AuthProbeSummary, +} from "./list.probe.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER, ensureFlagCompatibility } from "./shared.js"; export async function modelsStatusCommand( - opts: { json?: boolean; plain?: boolean; check?: boolean }, + opts: { + json?: boolean; + plain?: boolean; + check?: boolean; + probe?: boolean; + probeProvider?: string; + probeProfile?: string | string[]; + probeTimeout?: string; + probeConcurrency?: string; + probeMaxTokens?: string; + }, runtime: RuntimeEnv, ) { ensureFlagCompatibility(opts); + if (opts.plain && opts.probe) { + throw new Error("--probe cannot be used with --plain output."); + } const cfg = loadConfig(); const resolved = resolveConfiguredModelRef({ cfg, @@ -139,6 +166,69 @@ export async function modelsStatusCommand( .filter((provider) => !providerAuthMap.has(provider)) .sort((a, b) => a.localeCompare(b)); + const probeProfileIds = (() => { + if (!opts.probeProfile) return []; + const raw = Array.isArray(opts.probeProfile) ? opts.probeProfile : [opts.probeProfile]; + return raw + .flatMap((value) => String(value ?? "").split(",")) + .map((value) => value.trim()) + .filter(Boolean); + })(); + const probeTimeoutMs = opts.probeTimeout ? Number(opts.probeTimeout) : 8000; + if (!Number.isFinite(probeTimeoutMs) || probeTimeoutMs <= 0) { + throw new Error("--probe-timeout must be a positive number (ms)."); + } + const probeConcurrency = opts.probeConcurrency ? Number(opts.probeConcurrency) : 2; + if (!Number.isFinite(probeConcurrency) || probeConcurrency <= 0) { + throw new Error("--probe-concurrency must be > 0."); + } + const probeMaxTokens = opts.probeMaxTokens ? Number(opts.probeMaxTokens) : 8; + if (!Number.isFinite(probeMaxTokens) || probeMaxTokens <= 0) { + throw new Error("--probe-max-tokens must be > 0."); + } + + const aliasIndex = buildModelAliasIndex({ cfg, defaultProvider: DEFAULT_PROVIDER }); + const rawCandidates = [ + rawModel || resolvedLabel, + ...fallbacks, + imageModel, + ...imageFallbacks, + ...allowed, + ].filter(Boolean); + const resolvedCandidates = rawCandidates + .map( + (raw) => + resolveModelRefFromString({ + raw: String(raw ?? ""), + defaultProvider: DEFAULT_PROVIDER, + aliasIndex, + })?.ref, + ) + .filter((ref): ref is { provider: string; model: string } => Boolean(ref)); + const modelCandidates = resolvedCandidates.map((ref) => `${ref.provider}/${ref.model}`); + + let probeSummary: AuthProbeSummary | undefined; + if (opts.probe) { + probeSummary = await withProgressTotals( + { label: "Probing auth profiles…", total: 1 }, + async (update) => { + return await runAuthProbes({ + cfg, + providers, + modelCandidates, + options: { + provider: opts.probeProvider, + profileIds: probeProfileIds, + timeoutMs: probeTimeoutMs, + concurrency: probeConcurrency, + maxTokens: probeMaxTokens, + }, + onProgress: update, + }); + }, + ); + } + const providersWithOauth = providerAuth .filter( (entry) => @@ -228,6 +318,7 @@ export async function modelsStatusCommand( profiles: authHealth.profiles, providers: authHealth.providers, }, + probes: probeSummary, }, }, null, @@ -406,72 +497,113 @@ export async function modelsStatusCommand( runtime.log(colorize(rich, theme.heading, "OAuth/token status")); if (oauthProfiles.length === 0) { runtime.log(colorize(rich, theme.muted, "- none")); - return; - } - - const usageByProvider = new Map(); - const usageProviders = Array.from( - new Set( - oauthProfiles - .map((profile) => resolveUsageProviderId(profile.provider)) - .filter((provider): provider is UsageProviderId => Boolean(provider)), - ), - ); - if (usageProviders.length > 0) { - try { - const usageSummary = await loadProviderUsageSummary({ - providers: usageProviders, - agentDir, - timeoutMs: 3500, - }); - for (const snapshot of usageSummary.providers) { - const formatted = formatUsageWindowSummary(snapshot, { - now: Date.now(), - maxWindows: 2, - includeResets: true, + } else { + const usageByProvider = new Map(); + const usageProviders = Array.from( + new Set( + oauthProfiles + .map((profile) => resolveUsageProviderId(profile.provider)) + .filter((provider): provider is UsageProviderId => Boolean(provider)), + ), + ); + if (usageProviders.length > 0) { + try { + const usageSummary = await loadProviderUsageSummary({ + providers: usageProviders, + agentDir, + timeoutMs: 3500, }); - if (formatted) { - usageByProvider.set(snapshot.provider, formatted); + for (const snapshot of usageSummary.providers) { + const formatted = formatUsageWindowSummary(snapshot, { + now: Date.now(), + maxWindows: 2, + includeResets: true, + }); + if (formatted) { + usageByProvider.set(snapshot.provider, formatted); + } } + } catch { + // ignore usage failures + } + } + + const formatStatus = (status: string) => { + if (status === "ok") return colorize(rich, theme.success, "ok"); + if (status === "static") return colorize(rich, theme.muted, "static"); + if (status === "expiring") return colorize(rich, theme.warn, "expiring"); + if (status === "missing") return colorize(rich, theme.warn, "unknown"); + return colorize(rich, theme.error, "expired"); + }; + + const profilesByProvider = new Map(); + for (const profile of oauthProfiles) { + const current = profilesByProvider.get(profile.provider); + if (current) current.push(profile); + else profilesByProvider.set(profile.provider, [profile]); + } + + for (const [provider, profiles] of profilesByProvider) { + const usageKey = resolveUsageProviderId(provider); + const usage = usageKey ? usageByProvider.get(usageKey) : undefined; + const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; + runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); + for (const profile of profiles) { + const labelText = profile.label || profile.profileId; + const label = colorize(rich, theme.accent, labelText); + const status = formatStatus(profile.status); + const expiry = + profile.status === "static" + ? "" + : profile.expiresAt + ? ` expires in ${formatRemainingShort(profile.remainingMs)}` + : " expires unknown"; + const source = + profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; + runtime.log(` - ${label} ${status}${expiry}${source}`); } - } catch { - // ignore usage failures } } - const formatStatus = (status: string) => { - if (status === "ok") return colorize(rich, theme.success, "ok"); - if (status === "static") return colorize(rich, theme.muted, "static"); - if (status === "expiring") return colorize(rich, theme.warn, "expiring"); - if (status === "missing") return colorize(rich, theme.warn, "unknown"); - return colorize(rich, theme.error, "expired"); - }; - - const profilesByProvider = new Map(); - for (const profile of oauthProfiles) { - const current = profilesByProvider.get(profile.provider); - if (current) current.push(profile); - else profilesByProvider.set(profile.provider, [profile]); - } - - for (const [provider, profiles] of profilesByProvider) { - const usageKey = resolveUsageProviderId(provider); - const usage = usageKey ? usageByProvider.get(usageKey) : undefined; - const usageSuffix = usage ? colorize(rich, theme.muted, ` usage: ${usage}`) : ""; - runtime.log(`- ${colorize(rich, theme.heading, provider)}${usageSuffix}`); - for (const profile of profiles) { - const labelText = profile.label || profile.profileId; - const label = colorize(rich, theme.accent, labelText); - const status = formatStatus(profile.status); - const expiry = - profile.status === "static" - ? "" - : profile.expiresAt - ? ` expires in ${formatRemainingShort(profile.remainingMs)}` - : " expires unknown"; - const source = - profile.source !== "store" ? colorize(rich, theme.muted, ` (${profile.source})`) : ""; - runtime.log(` - ${label} ${status}${expiry}${source}`); + if (probeSummary) { + runtime.log(""); + runtime.log(colorize(rich, theme.heading, "Auth probes")); + if (probeSummary.results.length === 0) { + runtime.log(colorize(rich, theme.muted, "- none")); + } else { + const grouped = groupProbeResults(sortProbeResults(probeSummary.results)); + const statusColor = (status: string) => { + if (status === "ok") return theme.success; + if (status === "rate_limit") return theme.warn; + if (status === "timeout" || status === "billing") return theme.warn; + if (status === "auth" || status === "format") return theme.error; + if (status === "no_model") return theme.muted; + return theme.muted; + }; + for (const [provider, results] of grouped) { + const modelLabel = results.find((r) => r.model)?.model ?? "-"; + runtime.log( + `- ${theme.heading(provider)}${colorize( + rich, + theme.muted, + modelLabel ? ` (model: ${modelLabel})` : "", + )}`, + ); + for (const result of results) { + const status = colorize(rich, statusColor(result.status), result.status); + const latency = formatProbeLatency(result.latencyMs); + const mode = result.mode ? ` (${result.mode})` : ""; + const detail = result.error ? colorize(rich, theme.muted, ` - ${result.error}`) : ""; + runtime.log( + ` - ${colorize(rich, theme.accent, result.label)}${mode} ${status} ${colorize( + rich, + theme.muted, + latency, + )}${detail}`, + ); + } + } + runtime.log(colorize(rich, theme.muted, describeProbeSummary(probeSummary))); } } diff --git a/src/config/markdown-tables.ts b/src/config/markdown-tables.ts new file mode 100644 index 000000000..387ad6cab --- /dev/null +++ b/src/config/markdown-tables.ts @@ -0,0 +1,60 @@ +import { normalizeChannelId } from "../channels/plugins/index.js"; +import { normalizeAccountId } from "../routing/session-key.js"; +import type { ClawdbotConfig } from "./config.js"; +import type { MarkdownTableMode } from "./types.base.js"; + +type MarkdownConfigEntry = { + markdown?: { + tables?: MarkdownTableMode; + }; +}; + +type MarkdownConfigSection = MarkdownConfigEntry & { + accounts?: Record; +}; + +const DEFAULT_TABLE_MODES = new Map([ + ["signal", "bullets"], + ["whatsapp", "bullets"], +]); + +const isMarkdownTableMode = (value: unknown): value is MarkdownTableMode => + value === "off" || value === "bullets" || value === "code"; + +function resolveMarkdownModeFromSection( + section: MarkdownConfigSection | undefined, + accountId?: string | null, +): MarkdownTableMode | undefined { + if (!section) return undefined; + const normalizedAccountId = normalizeAccountId(accountId); + const accounts = section.accounts; + if (accounts && typeof accounts === "object") { + const direct = accounts[normalizedAccountId]; + const directMode = direct?.markdown?.tables; + if (isMarkdownTableMode(directMode)) return directMode; + const matchKey = Object.keys(accounts).find( + (key) => key.toLowerCase() === normalizedAccountId.toLowerCase(), + ); + const match = matchKey ? accounts[matchKey] : undefined; + const matchMode = match?.markdown?.tables; + if (isMarkdownTableMode(matchMode)) return matchMode; + } + const sectionMode = section.markdown?.tables; + return isMarkdownTableMode(sectionMode) ? sectionMode : undefined; +} + +export function resolveMarkdownTableMode(params: { + cfg?: Partial; + channel?: string | null; + accountId?: string | null; +}): MarkdownTableMode { + const channel = normalizeChannelId(params.channel); + const defaultMode = channel ? (DEFAULT_TABLE_MODES.get(channel) ?? "code") : "code"; + if (!channel || !params.cfg) return defaultMode; + const channelsConfig = params.cfg.channels as Record | undefined; + const section = (channelsConfig?.[channel] ?? + (params.cfg as Record | undefined)?.[channel]) as + | MarkdownConfigSection + | undefined; + return resolveMarkdownModeFromSection(section, params.accountId) ?? defaultMode; +} diff --git a/src/config/types.base.ts b/src/config/types.base.ts index 2fe689f95..a84736571 100644 --- a/src/config/types.base.ts +++ b/src/config/types.base.ts @@ -31,6 +31,13 @@ export type BlockStreamingChunkConfig = { breakPreference?: "paragraph" | "newline" | "sentence"; }; +export type MarkdownTableMode = "off" | "bullets" | "code"; + +export type MarkdownConfig = { + /** Table rendering mode (off|bullets|code). */ + tables?: MarkdownTableMode; +}; + export type HumanDelayConfig = { /** Delay style for block replies (off|natural|custom). */ mode?: "off" | "natural" | "custom"; diff --git a/src/config/types.discord.ts b/src/config/types.discord.ts index c8f0a38b3..cdedcb0d7 100644 --- a/src/config/types.discord.ts +++ b/src/config/types.discord.ts @@ -2,6 +2,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; @@ -70,6 +71,8 @@ export type DiscordAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Discord (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Allow channel-initiated config writes (default: true). */ diff --git a/src/config/types.imessage.ts b/src/config/types.imessage.ts index 37e4c5453..c166fee54 100644 --- a/src/config/types.imessage.ts +++ b/src/config/types.imessage.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type IMessageAccountConfig = { @@ -6,6 +11,8 @@ export type IMessageAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this iMessage account. Default: true. */ diff --git a/src/config/types.msteams.ts b/src/config/types.msteams.ts index f18dccb14..170c64e47 100644 --- a/src/config/types.msteams.ts +++ b/src/config/types.msteams.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type MSTeamsWebhookConfig = { @@ -34,6 +39,8 @@ export type MSTeamsConfig = { enabled?: boolean; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** Azure Bot App ID (from Azure Bot registration). */ diff --git a/src/config/types.signal.ts b/src/config/types.signal.ts index c71d97169..f46fb0f8f 100644 --- a/src/config/types.signal.ts +++ b/src/config/types.signal.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type SignalReactionNotificationMode = "off" | "own" | "all" | "allowlist"; @@ -8,6 +13,8 @@ export type SignalAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this Signal account. Default: true. */ diff --git a/src/config/types.slack.ts b/src/config/types.slack.ts index f0e9e1f21..e2ca63b3c 100644 --- a/src/config/types.slack.ts +++ b/src/config/types.slack.ts @@ -2,6 +2,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, ReplyToMode, } from "./types.base.js"; import type { DmConfig, ProviderCommandsConfig } from "./types.messages.js"; @@ -80,6 +81,8 @@ export type SlackAccountConfig = { webhookPath?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Slack (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Allow channel-initiated config writes (default: true). */ diff --git a/src/config/types.telegram.ts b/src/config/types.telegram.ts index 3533d6d4f..02a822c13 100644 --- a/src/config/types.telegram.ts +++ b/src/config/types.telegram.ts @@ -3,6 +3,7 @@ import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy, + MarkdownConfig, OutboundRetryConfig, ReplyToMode, } from "./types.base.js"; @@ -35,6 +36,8 @@ export type TelegramAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: TelegramCapabilitiesConfig; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Override native command registration for Telegram (bool or "auto"). */ commands?: ProviderCommandsConfig; /** Custom commands to register in Telegram's command menu (merged with native). */ diff --git a/src/config/types.whatsapp.ts b/src/config/types.whatsapp.ts index 28ed34c56..90b5497d4 100644 --- a/src/config/types.whatsapp.ts +++ b/src/config/types.whatsapp.ts @@ -1,4 +1,9 @@ -import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./types.base.js"; +import type { + BlockStreamingCoalesceConfig, + DmPolicy, + GroupPolicy, + MarkdownConfig, +} from "./types.base.js"; import type { DmConfig } from "./types.messages.js"; export type WhatsAppActionConfig = { @@ -12,6 +17,8 @@ export type WhatsAppConfig = { accounts?: Record; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** Send read receipts for incoming messages (default true). */ @@ -84,6 +91,8 @@ export type WhatsAppAccountConfig = { name?: string; /** Optional provider capability tags used for agent/runtime guidance. */ capabilities?: string[]; + /** Markdown formatting overrides (tables). */ + markdown?: MarkdownConfig; /** Allow channel-initiated config writes (default: true). */ configWrites?: boolean; /** If false, do not start this WhatsApp account provider. Default: true. */ diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts index 01427ab86..7bdf86bdf 100644 --- a/src/config/zod-schema.core.ts +++ b/src/config/zod-schema.core.ts @@ -133,6 +133,15 @@ export const BlockStreamingChunkSchema = z }) .strict(); +export const MarkdownTableModeSchema = z.enum(["off", "bullets", "code"]); + +export const MarkdownConfigSchema = z + .object({ + tables: MarkdownTableModeSchema.optional(), + }) + .strict() + .optional(); + export const HumanDelaySchema = z .object({ mode: z.union([z.literal("off"), z.literal("natural"), z.literal("custom")]).optional(), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 1f687253c..12f6cbb3d 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -7,6 +7,7 @@ import { DmPolicySchema, ExecutableTokenSchema, GroupPolicySchema, + MarkdownConfigSchema, MSTeamsReplyStyleSchema, ProviderCommandsSchema, ReplyToModeSchema, @@ -81,6 +82,7 @@ export const TelegramAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: TelegramCapabilitiesSchema.optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, customCommands: z.array(TelegramCustomCommandSchema).optional(), @@ -193,6 +195,7 @@ export const DiscordAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), @@ -296,6 +299,7 @@ export const SlackAccountSchema = z signingSecret: z.string().optional(), webhookPath: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), commands: ProviderCommandsSchema, configWrites: z.boolean().optional(), @@ -381,6 +385,7 @@ export const SignalAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), account: z.string().optional(), @@ -435,6 +440,7 @@ export const IMessageAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, enabled: z.boolean().optional(), configWrites: z.boolean().optional(), cliPath: ExecutableTokenSchema.optional(), @@ -521,6 +527,7 @@ export const BlueBubblesAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), enabled: z.boolean().optional(), serverUrl: z.string().optional(), @@ -585,6 +592,7 @@ export const MSTeamsConfigSchema = z .object({ enabled: z.boolean().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), appId: z.string().optional(), appPassword: z.string().optional(), diff --git a/src/config/zod-schema.providers-whatsapp.ts b/src/config/zod-schema.providers-whatsapp.ts index 6de67790d..de6cda2f8 100644 --- a/src/config/zod-schema.providers-whatsapp.ts +++ b/src/config/zod-schema.providers-whatsapp.ts @@ -5,12 +5,14 @@ import { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, } from "./zod-schema.core.js"; export const WhatsAppAccountSchema = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), enabled: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), @@ -66,6 +68,7 @@ export const WhatsAppConfigSchema = z .object({ accounts: z.record(z.string(), WhatsAppAccountSchema.optional()).optional(), capabilities: z.array(z.string()).optional(), + markdown: MarkdownConfigSchema, configWrites: z.boolean().optional(), sendReadReceipts: z.boolean().optional(), dmPolicy: DmPolicySchema.optional().default("pairing"), diff --git a/src/daemon/service-audit.test.ts b/src/daemon/service-audit.test.ts index 328c04a1a..e8e8d89ff 100644 --- a/src/daemon/service-audit.test.ts +++ b/src/daemon/service-audit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { auditGatewayServiceConfig, SERVICE_AUDIT_CODES } from "./service-audit.js"; +import { buildMinimalServicePath } from "./service-env.js"; describe("auditGatewayServiceConfig", () => { it("flags bun runtime", async () => { @@ -39,4 +40,24 @@ describe("auditGatewayServiceConfig", () => { audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs), ).toBe(true); }); + + it("accepts Linux minimal PATH with user directories", async () => { + const env = { HOME: "/home/testuser", PNPM_HOME: "/opt/pnpm" }; + const minimalPath = buildMinimalServicePath({ platform: "linux", env }); + const audit = await auditGatewayServiceConfig({ + env, + platform: "linux", + command: { + programArguments: ["/usr/bin/node", "gateway"], + environment: { PATH: minimalPath }, + }, + }); + + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathNonMinimal), + ).toBe(false); + expect( + audit.issues.some((issue) => issue.code === SERVICE_AUDIT_CODES.gatewayPathMissingDirs), + ).toBe(false); + }); }); diff --git a/src/daemon/service-audit.ts b/src/daemon/service-audit.ts index bf8ae8be3..20ddd4ff2 100644 --- a/src/daemon/service-audit.ts +++ b/src/daemon/service-audit.ts @@ -6,7 +6,7 @@ import { isVersionManagedNodePath, resolveSystemNodePath, } from "./runtime-paths.js"; -import { getMinimalServicePathParts } from "./service-env.js"; +import { getMinimalServicePathPartsFromEnv } from "./service-env.js"; import { resolveSystemdUserUnitPath } from "./systemd.js"; export type GatewayServiceCommand = { @@ -206,6 +206,7 @@ function normalizePathEntry(entry: string, platform: NodeJS.Platform): string { function auditGatewayServicePath( command: GatewayServiceCommand, issues: ServiceConfigIssue[], + env: Record, platform: NodeJS.Platform, ) { if (platform === "win32") return; @@ -219,12 +220,13 @@ function auditGatewayServicePath( return; } - const expected = getMinimalServicePathParts({ platform }); + const expected = getMinimalServicePathPartsFromEnv({ platform, env }); const parts = servicePath .split(getPathModule(platform).delimiter) .map((entry) => entry.trim()) .filter(Boolean); const normalizedParts = parts.map((entry) => normalizePathEntry(entry, platform)); + const normalizedExpected = new Set(expected.map((entry) => normalizePathEntry(entry, platform))); const missing = expected.filter((entry) => { const normalized = normalizePathEntry(entry, platform); return !normalizedParts.includes(normalized); @@ -239,6 +241,9 @@ function auditGatewayServicePath( const nonMinimal = parts.filter((entry) => { const normalized = normalizePathEntry(entry, platform); + if (normalizedExpected.has(normalized)) { + return false; + } return ( normalized.includes("/.nvm/") || normalized.includes("/.fnm/") || @@ -315,7 +320,7 @@ export async function auditGatewayServiceConfig(params: { const platform = params.platform ?? process.platform; auditGatewayCommand(params.command?.programArguments, issues); - auditGatewayServicePath(params.command, issues, platform); + auditGatewayServicePath(params.command, issues, params.env, platform); await auditGatewayRuntime(params.env, params.command, issues, platform); if (platform === "linux") { diff --git a/src/daemon/service-env.test.ts b/src/daemon/service-env.test.ts index aa7fbca5d..b87ab2ece 100644 --- a/src/daemon/service-env.test.ts +++ b/src/daemon/service-env.test.ts @@ -4,8 +4,123 @@ import { buildMinimalServicePath, buildNodeServiceEnvironment, buildServiceEnvironment, + getMinimalServicePathParts, + getMinimalServicePathPartsFromEnv, } from "./service-env.js"; +describe("getMinimalServicePathParts - Linux user directories", () => { + it("includes user bin directories when HOME is set on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + }); + + // Should include all common user bin directories + expect(result).toContain("/home/testuser/.local/bin"); + expect(result).toContain("/home/testuser/.npm-global/bin"); + expect(result).toContain("/home/testuser/bin"); + expect(result).toContain("/home/testuser/.nvm/current/bin"); + expect(result).toContain("/home/testuser/.fnm/current/bin"); + expect(result).toContain("/home/testuser/.volta/bin"); + expect(result).toContain("/home/testuser/.asdf/shims"); + expect(result).toContain("/home/testuser/.local/share/pnpm"); + expect(result).toContain("/home/testuser/.bun/bin"); + }); + + it("excludes user bin directories when HOME is undefined on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: undefined, + }); + + // Should only include system directories + expect(result).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]); + + // Should not include any user-specific paths + expect(result.some((p) => p.includes(".local"))).toBe(false); + expect(result.some((p) => p.includes(".npm-global"))).toBe(false); + expect(result.some((p) => p.includes(".nvm"))).toBe(false); + }); + + it("places user directories before system directories on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + }); + + const userDirIndex = result.indexOf("/home/testuser/.local/bin"); + const systemDirIndex = result.indexOf("/usr/bin"); + + expect(userDirIndex).toBeGreaterThan(-1); + expect(systemDirIndex).toBeGreaterThan(-1); + expect(userDirIndex).toBeLessThan(systemDirIndex); + }); + + it("places extraDirs before user directories on Linux", () => { + const result = getMinimalServicePathParts({ + platform: "linux", + home: "/home/testuser", + extraDirs: ["/custom/bin"], + }); + + const extraDirIndex = result.indexOf("/custom/bin"); + const userDirIndex = result.indexOf("/home/testuser/.local/bin"); + + expect(extraDirIndex).toBeGreaterThan(-1); + expect(userDirIndex).toBeGreaterThan(-1); + expect(extraDirIndex).toBeLessThan(userDirIndex); + }); + + it("includes env-configured bin roots when HOME is set on Linux", () => { + const result = getMinimalServicePathPartsFromEnv({ + platform: "linux", + env: { + HOME: "/home/testuser", + PNPM_HOME: "/opt/pnpm", + NPM_CONFIG_PREFIX: "/opt/npm", + BUN_INSTALL: "/opt/bun", + VOLTA_HOME: "/opt/volta", + ASDF_DATA_DIR: "/opt/asdf", + NVM_DIR: "/opt/nvm", + FNM_DIR: "/opt/fnm", + }, + }); + + expect(result).toContain("/opt/pnpm"); + expect(result).toContain("/opt/npm/bin"); + expect(result).toContain("/opt/bun/bin"); + expect(result).toContain("/opt/volta/bin"); + expect(result).toContain("/opt/asdf/shims"); + expect(result).toContain("/opt/nvm/current/bin"); + expect(result).toContain("/opt/fnm/current/bin"); + }); + + it("does not include Linux user directories on macOS", () => { + const result = getMinimalServicePathParts({ + platform: "darwin", + home: "/Users/testuser", + }); + + // Should not include Linux-specific user dirs even with HOME set + expect(result.some((p) => p.includes(".npm-global"))).toBe(false); + expect(result.some((p) => p.includes(".nvm"))).toBe(false); + + // Should only include macOS system directories + expect(result).toContain("/opt/homebrew/bin"); + expect(result).toContain("/usr/local/bin"); + }); + + it("does not include Linux user directories on Windows", () => { + const result = getMinimalServicePathParts({ + platform: "win32", + home: "C:\\Users\\testuser", + }); + + // Windows returns empty array (uses existing PATH) + expect(result).toEqual([]); + }); +}); + describe("buildMinimalServicePath", () => { it("includes Homebrew + system dirs on macOS", () => { const result = buildMinimalServicePath({ @@ -26,6 +141,51 @@ describe("buildMinimalServicePath", () => { expect(result).toBe("C:\\\\Windows\\\\System32"); }); + it("includes Linux user directories when HOME is set in env", () => { + const result = buildMinimalServicePath({ + platform: "linux", + env: { HOME: "/home/alice" }, + }); + const parts = result.split(path.delimiter); + + // Verify user directories are included + expect(parts).toContain("/home/alice/.local/bin"); + expect(parts).toContain("/home/alice/.npm-global/bin"); + expect(parts).toContain("/home/alice/.nvm/current/bin"); + + // Verify system directories are also included + expect(parts).toContain("/usr/local/bin"); + expect(parts).toContain("/usr/bin"); + expect(parts).toContain("/bin"); + }); + + it("excludes Linux user directories when HOME is not in env", () => { + const result = buildMinimalServicePath({ + platform: "linux", + env: {}, + }); + const parts = result.split(path.delimiter); + + // Should only have system directories + expect(parts).toEqual(["/usr/local/bin", "/usr/bin", "/bin"]); + + // No user-specific paths + expect(parts.some((p) => p.includes("home"))).toBe(false); + }); + + it("ensures user directories come before system directories on Linux", () => { + const result = buildMinimalServicePath({ + platform: "linux", + env: { HOME: "/home/bob" }, + }); + const parts = result.split(path.delimiter); + + const firstUserDirIdx = parts.indexOf("/home/bob/.local/bin"); + const firstSystemDirIdx = parts.indexOf("/usr/local/bin"); + + expect(firstUserDirIdx).toBeLessThan(firstSystemDirIdx); + }); + it("includes extra directories when provided", () => { const result = buildMinimalServicePath({ platform: "linux", diff --git a/src/daemon/service-env.ts b/src/daemon/service-env.ts index 8851cdb59..8c447c273 100644 --- a/src/daemon/service-env.ts +++ b/src/daemon/service-env.ts @@ -17,6 +17,8 @@ import { export type MinimalServicePathOptions = { platform?: NodeJS.Platform; extraDirs?: string[]; + home?: string; + env?: Record; }; type BuildServicePathOptions = MinimalServicePathOptions & { @@ -33,6 +35,51 @@ function resolveSystemPathDirs(platform: NodeJS.Platform): string[] { return []; } +/** + * Resolve common user bin directories for Linux. + * These are paths where npm global installs and node version managers typically place binaries. + */ +export function resolveLinuxUserBinDirs( + home: string | undefined, + env?: Record, +): string[] { + if (!home) return []; + + const dirs: string[] = []; + + const add = (dir: string | undefined) => { + if (dir) dirs.push(dir); + }; + const appendSubdir = (base: string | undefined, subdir: string) => { + if (!base) return undefined; + return base.endsWith(`/${subdir}`) ? base : path.posix.join(base, subdir); + }; + + // Env-configured bin roots (override defaults when present). + add(env?.PNPM_HOME); + add(appendSubdir(env?.NPM_CONFIG_PREFIX, "bin")); + add(appendSubdir(env?.BUN_INSTALL, "bin")); + add(appendSubdir(env?.VOLTA_HOME, "bin")); + add(appendSubdir(env?.ASDF_DATA_DIR, "shims")); + add(appendSubdir(env?.NVM_DIR, "current/bin")); + add(appendSubdir(env?.FNM_DIR, "current/bin")); + + // Common user bin directories + dirs.push(`${home}/.local/bin`); // XDG standard, pip, etc. + dirs.push(`${home}/.npm-global/bin`); // npm custom prefix (recommended for non-root) + dirs.push(`${home}/bin`); // User's personal bin + + // Node version managers + dirs.push(`${home}/.nvm/current/bin`); // nvm with current symlink + dirs.push(`${home}/.fnm/current/bin`); // fnm + dirs.push(`${home}/.volta/bin`); // Volta + dirs.push(`${home}/.asdf/shims`); // asdf + dirs.push(`${home}/.local/share/pnpm`); // pnpm global bin + dirs.push(`${home}/.bun/bin`); // Bun + + return dirs; +} + export function getMinimalServicePathParts(options: MinimalServicePathOptions = {}): string[] { const platform = options.platform ?? process.platform; if (platform === "win32") return []; @@ -41,17 +88,32 @@ export function getMinimalServicePathParts(options: MinimalServicePathOptions = const extraDirs = options.extraDirs ?? []; const systemDirs = resolveSystemPathDirs(platform); + // Add Linux user bin directories (npm global, nvm, fnm, volta, etc.) + const linuxUserDirs = + platform === "linux" ? resolveLinuxUserBinDirs(options.home, options.env) : []; + const add = (dir: string) => { if (!dir) return; if (!parts.includes(dir)) parts.push(dir); }; for (const dir of extraDirs) add(dir); + // User dirs first so user-installed binaries take precedence + for (const dir of linuxUserDirs) add(dir); for (const dir of systemDirs) add(dir); return parts; } +export function getMinimalServicePathPartsFromEnv(options: BuildServicePathOptions = {}): string[] { + const env = options.env ?? process.env; + return getMinimalServicePathParts({ + ...options, + home: options.home ?? env.HOME, + env, + }); +} + export function buildMinimalServicePath(options: BuildServicePathOptions = {}): string { const env = options.env ?? process.env; const platform = options.platform ?? process.platform; @@ -59,7 +121,7 @@ export function buildMinimalServicePath(options: BuildServicePathOptions = {}): return env.PATH ?? ""; } - return getMinimalServicePathParts(options).join(path.delimiter); + return getMinimalServicePathPartsFromEnv({ ...options, env }).join(path.delimiter); } export function buildServiceEnvironment(params: { diff --git a/src/discord/monitor.test.ts b/src/discord/monitor.test.ts index be0c8aa65..bc85e5764 100644 --- a/src/discord/monitor.test.ts +++ b/src/discord/monitor.test.ts @@ -377,12 +377,63 @@ describe("discord mention gating", () => { resolveDiscordShouldRequireMention({ isGuildMessage: true, isThread: true, + botId: "bot123", + threadOwnerId: "bot123", channelConfig, guildInfo, }), ).toBe(false); }); + it("requires mention inside user-created threads with autoThread enabled", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + threadOwnerId: "user456", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + + it("requires mention when thread owner is unknown", () => { + const guildInfo: DiscordGuildEntryResolved = { + requireMention: true, + channels: { + general: { allow: true, autoThread: true }, + }, + }; + const channelConfig = resolveDiscordChannelConfig({ + guildInfo, + channelId: "1", + channelName: "General", + channelSlug: "general", + }); + expect( + resolveDiscordShouldRequireMention({ + isGuildMessage: true, + isThread: true, + botId: "bot123", + channelConfig, + guildInfo, + }), + ).toBe(true); + }); + it("inherits parent channel mention rules for threads", () => { const guildInfo: DiscordGuildEntryResolved = { requireMention: true, diff --git a/src/discord/monitor/allow-list.ts b/src/discord/monitor/allow-list.ts index 7d495af66..12c2d1d39 100644 --- a/src/discord/monitor/allow-list.ts +++ b/src/discord/monitor/allow-list.ts @@ -282,14 +282,33 @@ export function resolveDiscordChannelConfigWithFallback(params: { export function resolveDiscordShouldRequireMention(params: { isGuildMessage: boolean; isThread: boolean; + botId?: string | null; + threadOwnerId?: string | null; channelConfig?: DiscordChannelConfigResolved | null; guildInfo?: DiscordGuildEntryResolved | null; + /** Pass pre-computed value to avoid redundant checks. */ + isAutoThreadOwnedByBot?: boolean; }): boolean { if (!params.isGuildMessage) return false; - if (params.isThread && params.channelConfig?.autoThread) return false; + // Only skip mention requirement in threads created by the bot (when autoThread is enabled). + const isBotThread = params.isAutoThreadOwnedByBot ?? isDiscordAutoThreadOwnedByBot(params); + if (isBotThread) return false; return params.channelConfig?.requireMention ?? params.guildInfo?.requireMention ?? true; } +export function isDiscordAutoThreadOwnedByBot(params: { + isThread: boolean; + channelConfig?: DiscordChannelConfigResolved | null; + botId?: string | null; + threadOwnerId?: string | null; +}): boolean { + if (!params.isThread) return false; + if (!params.channelConfig?.autoThread) return false; + const botId = params.botId?.trim(); + const threadOwnerId = params.threadOwnerId?.trim(); + return Boolean(botId && threadOwnerId && botId === threadOwnerId); +} + export function isDiscordGroupAllowedByPolicy(params: { groupPolicy: "open" | "disabled" | "allowlist"; guildAllowlisted: boolean; diff --git a/src/discord/monitor/message-handler.preflight.ts b/src/discord/monitor/message-handler.preflight.ts index 6df141e35..607b02cdd 100644 --- a/src/discord/monitor/message-handler.preflight.ts +++ b/src/discord/monitor/message-handler.preflight.ts @@ -328,9 +328,12 @@ export async function preflightDiscordMessage( } satisfies HistoryEntry) : undefined; + const threadOwnerId = threadChannel ? (threadChannel.ownerId ?? channelInfo?.ownerId) : undefined; const shouldRequireMention = resolveDiscordShouldRequireMention({ isGuildMessage, isThread: Boolean(threadChannel), + botId, + threadOwnerId, channelConfig, guildInfo, }); diff --git a/src/discord/monitor/message-handler.process.test.ts b/src/discord/monitor/message-handler.process.test.ts new file mode 100644 index 000000000..351f46f74 --- /dev/null +++ b/src/discord/monitor/message-handler.process.test.ts @@ -0,0 +1,123 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const reactMessageDiscord = vi.fn(async () => {}); +const removeReactionDiscord = vi.fn(async () => {}); + +vi.mock("../send.js", () => ({ + reactMessageDiscord: (...args: unknown[]) => reactMessageDiscord(...args), + removeReactionDiscord: (...args: unknown[]) => removeReactionDiscord(...args), +})); + +vi.mock("../../auto-reply/reply/dispatch-from-config.js", () => ({ + dispatchReplyFromConfig: vi.fn(async () => ({ + queuedFinal: false, + counts: { final: 0, tool: 0, block: 0 }, + })), +})); + +vi.mock("../../auto-reply/reply/reply-dispatcher.js", () => ({ + createReplyDispatcherWithTyping: vi.fn(() => ({ + dispatcher: {}, + replyOptions: {}, + markDispatchIdle: vi.fn(), + })), +})); + +import { processDiscordMessage } from "./message-handler.process.js"; + +async function createBaseContext(overrides: Record = {}) { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-discord-")); + const storePath = path.join(dir, "sessions.json"); + return { + cfg: { messages: { ackReaction: "👀" }, session: { store: storePath } }, + discordConfig: {}, + accountId: "default", + token: "token", + runtime: { log: () => {}, error: () => {} }, + guildHistories: new Map(), + historyLimit: 0, + mediaMaxBytes: 1024, + textLimit: 4000, + replyToMode: "off", + ackReactionScope: "group-mentions", + groupPolicy: "open", + data: { guild: { id: "g1", name: "Guild" } }, + client: { rest: {} }, + message: { + id: "m1", + channelId: "c1", + timestamp: new Date().toISOString(), + attachments: [], + }, + author: { + id: "U1", + username: "alice", + discriminator: "0", + globalName: "Alice", + }, + channelInfo: { name: "general" }, + channelName: "general", + isGuildMessage: true, + isDirectMessage: false, + isGroupDm: false, + commandAuthorized: true, + baseText: "hi", + messageText: "hi", + wasMentioned: false, + shouldRequireMention: true, + canDetectMention: true, + effectiveWasMentioned: true, + shouldBypassMention: false, + threadChannel: null, + threadParentId: undefined, + threadParentName: undefined, + threadParentType: undefined, + threadName: undefined, + displayChannelSlug: "general", + guildInfo: null, + guildSlug: "guild", + channelConfig: null, + baseSessionKey: "agent:main:discord:guild:g1", + route: { + agentId: "main", + channel: "discord", + accountId: "default", + sessionKey: "agent:main:discord:guild:g1", + mainSessionKey: "agent:main:main", + }, + ...overrides, + }; +} + +beforeEach(() => { + reactMessageDiscord.mockClear(); + removeReactionDiscord.mockClear(); +}); + +describe("processDiscordMessage ack reactions", () => { + it("skips ack reactions for group-mentions when mentions are not required", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: false, + effectiveWasMentioned: false, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).not.toHaveBeenCalled(); + }); + + it("sends ack reactions for mention-gated guild messages when mentioned", async () => { + const ctx = await createBaseContext({ + shouldRequireMention: true, + effectiveWasMentioned: true, + }); + + await processDiscordMessage(ctx as any); + + expect(reactMessageDiscord).toHaveBeenCalledWith("c1", "m1", "👀", { rest: {} }); + }); +}); diff --git a/src/discord/monitor/message-handler.process.ts b/src/discord/monitor/message-handler.process.ts index 4838c9d44..ad1e4baea 100644 --- a/src/discord/monitor/message-handler.process.ts +++ b/src/discord/monitor/message-handler.process.ts @@ -27,6 +27,7 @@ import { resolveStorePath, updateLastRoute, } from "../../config/sessions.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose, shouldLogVerbose } from "../../globals.js"; import { buildAgentSessionKey } from "../../routing/resolve-route.js"; import { resolveThreadSessionKeys } from "../../routing/session-key.js"; @@ -323,6 +324,11 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), }; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "discord", + accountId, + }); const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({ responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix, @@ -340,6 +346,7 @@ export async function processDiscordMessage(ctx: DiscordMessagePreflightContext) replyToId, textLimit, maxLinesPerMessage: discordConfig?.maxLinesPerMessage, + tableMode, }); replyReference.markSent(); }, diff --git a/src/discord/monitor/message-utils.ts b/src/discord/monitor/message-utils.ts index a681afa16..2647e5113 100644 --- a/src/discord/monitor/message-utils.ts +++ b/src/discord/monitor/message-utils.ts @@ -16,6 +16,7 @@ export type DiscordChannelInfo = { name?: string; topic?: string; parentId?: string; + ownerId?: string; }; type DiscordSnapshotAuthor = { @@ -69,11 +70,13 @@ export async function resolveDiscordChannelInfo( const name = "name" in channel ? (channel.name ?? undefined) : undefined; const topic = "topic" in channel ? (channel.topic ?? undefined) : undefined; const parentId = "parentId" in channel ? (channel.parentId ?? undefined) : undefined; + const ownerId = "ownerId" in channel ? (channel.ownerId ?? undefined) : undefined; const payload: DiscordChannelInfo = { type: channel.type, name, topic, parentId, + ownerId, }; DISCORD_CHANNEL_INFO_CACHE.set(channelId, { value: payload, diff --git a/src/discord/monitor/reply-delivery.ts b/src/discord/monitor/reply-delivery.ts index e7713af1e..f54efb1b9 100644 --- a/src/discord/monitor/reply-delivery.ts +++ b/src/discord/monitor/reply-delivery.ts @@ -1,6 +1,8 @@ import type { RequestClient } from "@buape/carbon"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { RuntimeEnv } from "../../runtime.js"; import { chunkDiscordText } from "../chunk.js"; import { sendMessageDiscord } from "../send.js"; @@ -15,11 +17,14 @@ export async function deliverDiscordReply(params: { textLimit: number; maxLinesPerMessage?: number; replyToId?: string; + tableMode?: MarkdownTableMode; }) { const chunkLimit = Math.min(params.textLimit, 2000); for (const payload of params.replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const rawText = payload.text ?? ""; + const tableMode = params.tableMode ?? "code"; + const text = convertMarkdownTables(rawText, tableMode); if (!text && mediaList.length === 0) continue; const replyTo = params.replyToId?.trim() || undefined; diff --git a/src/discord/monitor/threading.ts b/src/discord/monitor/threading.ts index bae4ef1c5..71af6408f 100644 --- a/src/discord/monitor/threading.ts +++ b/src/discord/monitor/threading.ts @@ -14,6 +14,7 @@ export type DiscordThreadChannel = { name?: string | null; parentId?: string | null; parent?: { id?: string; name?: string }; + ownerId?: string | null; }; export type DiscordThreadStarter = { @@ -63,6 +64,7 @@ export function resolveDiscordThreadChannel(params: { name: channelInfo?.name ?? undefined, parentId: channelInfo?.parentId ?? undefined, parent: undefined, + ownerId: channelInfo?.ownerId ?? undefined, }; } diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index 51d5742e0..3c83f7b94 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -1,7 +1,9 @@ import type { RequestClient } from "@buape/carbon"; import { Routes } from "discord-api-types/v10"; import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { recordChannelActivity } from "../infra/channel-activity.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import type { RetryConfig } from "../infra/retry.js"; import type { PollInput } from "../polls.js"; import { resolveDiscordAccount } from "./accounts.js"; @@ -38,6 +40,12 @@ export async function sendMessageDiscord( cfg, accountId: opts.accountId, }); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "discord", + accountId: accountInfo.accountId, + }); + const textWithTables = convertMarkdownTables(text ?? "", tableMode); const { token, rest, request } = createDiscordClient(opts, cfg); const recipient = parseRecipient(to); const { channelId } = await resolveChannelId(rest, recipient, request); @@ -47,7 +55,7 @@ export async function sendMessageDiscord( result = await sendDiscordMedia( rest, channelId, - text, + textWithTables, opts.mediaUrl, opts.replyTo, request, @@ -58,7 +66,7 @@ export async function sendMessageDiscord( result = await sendDiscordText( rest, channelId, - text, + textWithTables, opts.replyTo, request, accountInfo.config.maxLinesPerMessage, diff --git a/src/gateway/protocol/schema/exec-approvals.ts b/src/gateway/protocol/schema/exec-approvals.ts index 9f1a25d38..e6f7ce906 100644 --- a/src/gateway/protocol/schema/exec-approvals.ts +++ b/src/gateway/protocol/schema/exec-approvals.ts @@ -4,6 +4,7 @@ import { NonEmptyString } from "./primitives.js"; export const ExecApprovalsAllowlistEntrySchema = Type.Object( { + id: Type.Optional(NonEmptyString), pattern: Type.String(), lastUsedAt: Type.Optional(Type.Integer({ minimum: 0 })), lastUsedCommand: Type.Optional(Type.String()), @@ -91,13 +92,13 @@ export const ExecApprovalRequestParamsSchema = Type.Object( { id: Type.Optional(NonEmptyString), command: NonEmptyString, - cwd: Type.Optional(Type.String()), - host: Type.Optional(Type.String()), - security: Type.Optional(Type.String()), - ask: Type.Optional(Type.String()), - agentId: Type.Optional(Type.String()), - resolvedPath: Type.Optional(Type.String()), - sessionKey: Type.Optional(Type.String()), + cwd: Type.Optional(Type.Union([Type.String(), Type.Null()])), + host: Type.Optional(Type.Union([Type.String(), Type.Null()])), + security: Type.Optional(Type.Union([Type.String(), Type.Null()])), + ask: Type.Optional(Type.Union([Type.String(), Type.Null()])), + agentId: Type.Optional(Type.Union([Type.String(), Type.Null()])), + resolvedPath: Type.Optional(Type.Union([Type.String(), Type.Null()])), + sessionKey: Type.Optional(Type.Union([Type.String(), Type.Null()])), timeoutMs: Type.Optional(Type.Integer({ minimum: 1 })), }, { additionalProperties: false }, diff --git a/src/gateway/server-methods/chat.ts b/src/gateway/server-methods/chat.ts index 8c71dca75..0e55b45f5 100644 --- a/src/gateway/server-methods/chat.ts +++ b/src/gateway/server-methods/chat.ts @@ -2,9 +2,25 @@ import { randomUUID } from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { resolveSessionAgentId, resolveAgentWorkspaceDir } from "../../agents/agent-scope.js"; import { resolveThinkingDefault } from "../../agents/model-selection.js"; import { resolveAgentTimeoutMs } from "../../agents/timeout.js"; +import { ensureAgentWorkspace } from "../../agents/workspace.js"; +import { isControlCommandMessage } from "../../auto-reply/command-detection.js"; +import { normalizeCommandBody } from "../../auto-reply/commands-registry.js"; import { formatInboundEnvelope, resolveEnvelopeFormatOptions } from "../../auto-reply/envelope.js"; +import { buildCommandContext, handleCommands } from "../../auto-reply/reply/commands.js"; +import { parseInlineDirectives } from "../../auto-reply/reply/directive-handling.js"; +import { defaultGroupActivation } from "../../auto-reply/reply/groups.js"; +import { resolveContextTokens } from "../../auto-reply/reply/model-selection.js"; +import { resolveElevatedPermissions } from "../../auto-reply/reply/reply-elevated.js"; +import { + normalizeElevatedLevel, + normalizeReasoningLevel, + normalizeThinkLevel, + normalizeVerboseLevel, +} from "../../auto-reply/thinking.js"; +import type { MsgContext } from "../../auto-reply/templating.js"; import { agentCommand } from "../../commands/agent.js"; import { mergeSessionEntry, updateSessionStore } from "../../config/sessions.js"; import { registerAgentRunContext } from "../../infra/agent-events.js"; @@ -212,7 +228,7 @@ export const chatHandlers: GatewayRequestHandlers = { return; } } - const { cfg, storePath, entry, canonicalKey } = loadSessionEntry(p.sessionKey); + const { cfg, storePath, entry, canonicalKey, store } = loadSessionEntry(p.sessionKey); const timeoutMs = resolveAgentTimeoutMs({ cfg, overrideMs: p.timeoutMs, @@ -223,6 +239,7 @@ export const chatHandlers: GatewayRequestHandlers = { sessionId, updatedAt: now, }); + store[canonicalKey] = sessionEntry; const clientRunId = p.idempotencyKey; registerAgentRunContext(clientRunId, { sessionKey: p.sessionKey }); @@ -303,6 +320,141 @@ export const chatHandlers: GatewayRequestHandlers = { }; respond(true, ackPayload, undefined, { runId: clientRunId }); + if (isControlCommandMessage(parsedMessage, cfg)) { + try { + const isFastTestEnv = process.env.CLAWDBOT_TEST_FAST === "1"; + const agentId = resolveSessionAgentId({ sessionKey: p.sessionKey, config: cfg }); + const agentCfg = cfg.agents?.defaults; + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + const workspace = await ensureAgentWorkspace({ + dir: workspaceDir, + ensureBootstrapFiles: !agentCfg?.skipBootstrap && !isFastTestEnv, + }); + const ctx: MsgContext = { + Body: parsedMessage, + CommandBody: parsedMessage, + BodyForCommands: parsedMessage, + CommandSource: "text", + CommandAuthorized: true, + Provider: INTERNAL_MESSAGE_CHANNEL, + Surface: "tui", + From: p.sessionKey, + To: INTERNAL_MESSAGE_CHANNEL, + SessionKey: p.sessionKey, + ChatType: "direct", + }; + const command = buildCommandContext({ + ctx, + cfg, + agentId, + sessionKey: p.sessionKey, + isGroup: false, + triggerBodyNormalized: normalizeCommandBody(parsedMessage), + commandAuthorized: true, + }); + const directives = parseInlineDirectives(parsedMessage); + const { provider, model } = resolveSessionModelRef(cfg, sessionEntry); + const contextTokens = resolveContextTokens({ agentCfg, model }); + const resolveDefaultThinkingLevel = async () => { + const configured = agentCfg?.thinkingDefault; + if (configured) return configured; + const catalog = await context.loadGatewayModelCatalog(); + return resolveThinkingDefault({ cfg, provider, model, catalog }); + }; + const resolvedThinkLevel = + normalizeThinkLevel(sessionEntry?.thinkingLevel ?? agentCfg?.thinkingDefault) ?? + (await resolveDefaultThinkingLevel()); + const resolvedVerboseLevel = + normalizeVerboseLevel(sessionEntry?.verboseLevel ?? agentCfg?.verboseDefault) ?? "off"; + const resolvedReasoningLevel = + normalizeReasoningLevel(sessionEntry?.reasoningLevel) ?? "off"; + const resolvedElevatedLevel = normalizeElevatedLevel( + sessionEntry?.elevatedLevel ?? agentCfg?.elevatedDefault, + ); + const elevated = resolveElevatedPermissions({ + cfg, + agentId, + ctx, + provider: INTERNAL_MESSAGE_CHANNEL, + }); + const commandResult = await handleCommands({ + ctx, + cfg, + command, + agentId, + directives, + elevated, + sessionEntry, + previousSessionEntry: entry, + sessionStore: store, + sessionKey: p.sessionKey, + storePath, + sessionScope: (cfg.session?.scope ?? "per-sender") as "per-sender" | "global", + workspaceDir: workspace.dir, + defaultGroupActivation: () => defaultGroupActivation(true), + resolvedThinkLevel, + resolvedVerboseLevel, + resolvedReasoningLevel, + resolvedElevatedLevel, + resolveDefaultThinkingLevel, + provider, + model, + contextTokens, + isGroup: false, + }); + if (!commandResult.shouldContinue) { + const text = commandResult.reply?.text ?? ""; + const message = { + role: "assistant", + content: text.trim() ? [{ type: "text", text }] : [], + timestamp: Date.now(), + command: true, + }; + const payload = { + runId: clientRunId, + sessionKey: p.sessionKey, + seq: 0, + state: "final" as const, + message, + }; + context.broadcast("chat", payload); + context.nodeSendToSession(p.sessionKey, "chat", payload); + context.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: true, + payload: { runId: clientRunId, status: "ok" as const }, + }); + context.chatAbortControllers.delete(clientRunId); + context.removeChatRun(clientRunId, clientRunId, p.sessionKey); + return; + } + } catch (err) { + const payload = { + runId: clientRunId, + sessionKey: p.sessionKey, + seq: 0, + state: "error" as const, + errorMessage: formatForLog(err), + }; + const error = errorShape(ErrorCodes.UNAVAILABLE, String(err)); + context.broadcast("chat", payload); + context.nodeSendToSession(p.sessionKey, "chat", payload); + context.dedupe.set(`chat:${clientRunId}`, { + ts: Date.now(), + ok: false, + payload: { + runId: clientRunId, + status: "error" as const, + summary: String(err), + }, + error, + }); + context.chatAbortControllers.delete(clientRunId); + context.removeChatRun(clientRunId, clientRunId, p.sessionKey); + return; + } + } + const envelopeOptions = resolveEnvelopeFormatOptions(cfg); const envelopedMessage = formatInboundEnvelope({ channel: "WebChat", diff --git a/src/gateway/server-methods/exec-approval.test.ts b/src/gateway/server-methods/exec-approval.test.ts index 0b1da93f3..71a63e5a3 100644 --- a/src/gateway/server-methods/exec-approval.test.ts +++ b/src/gateway/server-methods/exec-approval.test.ts @@ -36,16 +36,16 @@ describe("exec approval handlers", () => { expect(validateExecApprovalRequestParams(params)).toBe(true); }); - // This documents the TypeBox/AJV behavior that caused the Discord exec bug: - // Type.Optional(Type.String()) does NOT accept null, only string or undefined. - it("rejects request with resolvedPath as null", () => { + // Fixed: null is now accepted (Type.Union([Type.String(), Type.Null()])) + // This matches the calling code in bash-tools.exec.ts which passes null. + it("accepts request with resolvedPath as null", () => { const params = { command: "echo hi", cwd: "/tmp", host: "node", resolvedPath: null, }; - expect(validateExecApprovalRequestParams(params)).toBe(false); + expect(validateExecApprovalRequestParams(params)).toBe(true); }); }); diff --git a/src/gateway/server.agent.gateway-server-agent-a.test.ts b/src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts similarity index 100% rename from src/gateway/server.agent.gateway-server-agent-a.test.ts rename to src/gateway/server.agent.gateway-server-agent-a.e2e.test.ts diff --git a/src/gateway/server.agent.gateway-server-agent-b.test.ts b/src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts similarity index 100% rename from src/gateway/server.agent.gateway-server-agent-b.test.ts rename to src/gateway/server.agent.gateway-server-agent-b.e2e.test.ts diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.e2e.test.ts similarity index 100% rename from src/gateway/server.auth.test.ts rename to src/gateway/server.auth.e2e.test.ts diff --git a/src/gateway/server.channels.test.ts b/src/gateway/server.channels.e2e.test.ts similarity index 100% rename from src/gateway/server.channels.test.ts rename to src/gateway/server.channels.e2e.test.ts diff --git a/src/gateway/server.chat.gateway-server-chat-b.test.ts b/src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts similarity index 100% rename from src/gateway/server.chat.gateway-server-chat-b.test.ts rename to src/gateway/server.chat.gateway-server-chat-b.e2e.test.ts diff --git a/src/gateway/server.chat.gateway-server-chat.test.ts b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts similarity index 91% rename from src/gateway/server.chat.gateway-server-chat.test.ts rename to src/gateway/server.chat.gateway-server-chat.e2e.test.ts index 75f541f39..d4035037b 100644 --- a/src/gateway/server.chat.gateway-server-chat.test.ts +++ b/src/gateway/server.chat.gateway-server-chat.e2e.test.ts @@ -259,6 +259,45 @@ describe("gateway server chat", () => { } }); + test("routes chat.send slash commands without agent runs", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); + try { + testState.sessionStorePath = path.join(dir, "sessions.json"); + await writeSessionStore({ + entries: { + main: { + sessionId: "sess-main", + updatedAt: Date.now(), + }, + }, + }); + + const spy = vi.mocked(agentCommand); + const callsBefore = spy.mock.calls.length; + const eventPromise = onceMessage( + ws, + (o) => + o.type === "event" && + o.event === "chat" && + o.payload?.state === "final" && + o.payload?.runId === "idem-command-1", + 8000, + ); + const res = await rpcReq(ws, "chat.send", { + sessionKey: "main", + message: "/context list", + idempotencyKey: "idem-command-1", + }); + expect(res.ok).toBe(true); + const evt = await eventPromise; + expect(evt.payload?.message?.command).toBe(true); + expect(spy.mock.calls.length).toBe(callsBefore); + } finally { + testState.sessionStorePath = undefined; + await fs.rm(dir, { recursive: true, force: true }); + } + }); + test("agent events include sessionKey and agent.wait covers lifecycle flows", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-gw-")); testState.sessionStorePath = path.join(dir, "sessions.json"); diff --git a/src/gateway/server.config-apply.test.ts b/src/gateway/server.config-apply.e2e.test.ts similarity index 100% rename from src/gateway/server.config-apply.test.ts rename to src/gateway/server.config-apply.e2e.test.ts diff --git a/src/gateway/server.config-patch.test.ts b/src/gateway/server.config-patch.e2e.test.ts similarity index 100% rename from src/gateway/server.config-patch.test.ts rename to src/gateway/server.config-patch.e2e.test.ts diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.e2e.test.ts similarity index 100% rename from src/gateway/server.cron.test.ts rename to src/gateway/server.cron.e2e.test.ts diff --git a/src/gateway/server.health.test.ts b/src/gateway/server.health.e2e.test.ts similarity index 100% rename from src/gateway/server.health.test.ts rename to src/gateway/server.health.e2e.test.ts diff --git a/src/gateway/server.hooks.test.ts b/src/gateway/server.hooks.e2e.test.ts similarity index 100% rename from src/gateway/server.hooks.test.ts rename to src/gateway/server.hooks.e2e.test.ts diff --git a/src/gateway/server.ios-client-id.test.ts b/src/gateway/server.ios-client-id.e2e.test.ts similarity index 100% rename from src/gateway/server.ios-client-id.test.ts rename to src/gateway/server.ios-client-id.e2e.test.ts diff --git a/src/gateway/server.models-voicewake-misc.test.ts b/src/gateway/server.models-voicewake-misc.e2e.test.ts similarity index 100% rename from src/gateway/server.models-voicewake-misc.test.ts rename to src/gateway/server.models-voicewake-misc.e2e.test.ts diff --git a/src/gateway/server.reload.test.ts b/src/gateway/server.reload.e2e.test.ts similarity index 100% rename from src/gateway/server.reload.test.ts rename to src/gateway/server.reload.e2e.test.ts diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.e2e.test.ts similarity index 100% rename from src/gateway/server.roles-allowlist-update.test.ts rename to src/gateway/server.roles-allowlist-update.e2e.test.ts diff --git a/src/gateway/server.sessions-send.test.ts b/src/gateway/server.sessions-send.e2e.test.ts similarity index 100% rename from src/gateway/server.sessions-send.test.ts rename to src/gateway/server.sessions-send.e2e.test.ts diff --git a/src/gateway/server.sessions.gateway-server-sessions-a.test.ts b/src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts similarity index 100% rename from src/gateway/server.sessions.gateway-server-sessions-a.test.ts rename to src/gateway/server.sessions.gateway-server-sessions-a.e2e.test.ts diff --git a/src/imessage/monitor/deliver.ts b/src/imessage/monitor/deliver.ts index 779cbd3e5..aa3c6dbb1 100644 --- a/src/imessage/monitor/deliver.ts +++ b/src/imessage/monitor/deliver.ts @@ -1,4 +1,7 @@ import { chunkText } from "../../auto-reply/chunk.js"; +import { loadConfig } from "../../config/config.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { createIMessageRpcClient } from "../client.js"; @@ -14,9 +17,16 @@ export async function deliverReplies(params: { textLimit: number; }) { const { replies, target, client, runtime, maxBytes, textLimit, accountId } = params; + const cfg = loadConfig(); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId, + }); for (const payload of replies) { const mediaList = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); - const text = payload.text ?? ""; + const rawText = payload.text ?? ""; + const text = convertMarkdownTables(rawText, tableMode); if (!text && mediaList.length === 0) continue; if (mediaList.length === 0) { for (const chunk of chunkText(text, textLimit)) { diff --git a/src/imessage/send.ts b/src/imessage/send.ts index 32e963bc8..30972ef09 100644 --- a/src/imessage/send.ts +++ b/src/imessage/send.ts @@ -1,7 +1,9 @@ import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; import { loadWebMedia } from "../web/media.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import { resolveIMessageAccount } from "./accounts.js"; import { createIMessageRpcClient, type IMessageRpcClient } from "./client.js"; import { formatIMessageChatTarget, type IMessageService, parseIMessageTarget } from "./targets.js"; @@ -88,6 +90,14 @@ export async function sendMessageIMessage( if (!message.trim() && !filePath) { throw new Error("iMessage send requires text or media"); } + if (message.trim()) { + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "imessage", + accountId: account.accountId, + }); + message = convertMarkdownTables(message, tableMode); + } const params: Record = { text: message, diff --git a/src/infra/exec-approvals.ts b/src/infra/exec-approvals.ts index 65dc4f024..0830ed89a 100644 --- a/src/infra/exec-approvals.ts +++ b/src/infra/exec-approvals.ts @@ -18,6 +18,7 @@ export type ExecApprovalsDefaults = { }; export type ExecAllowlistEntry = { + id?: string; pattern: string; lastUsedAt?: number; lastUsedCommand?: string; @@ -120,6 +121,19 @@ function ensureDir(filePath: string) { fs.mkdirSync(dir, { recursive: true }); } +function ensureAllowlistIds( + allowlist: ExecAllowlistEntry[] | undefined, +): ExecAllowlistEntry[] | undefined { + if (!Array.isArray(allowlist) || allowlist.length === 0) return allowlist; + let changed = false; + const next = allowlist.map((entry) => { + if (entry.id) return entry; + changed = true; + return { ...entry, id: crypto.randomUUID() }; + }); + return changed ? next : allowlist; +} + export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile { const socketPath = file.socket?.path?.trim(); const token = file.socket?.token?.trim(); @@ -130,6 +144,12 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault; delete agents.default; } + for (const [key, agent] of Object.entries(agents)) { + const allowlist = ensureAllowlistIds(agent.allowlist); + if (allowlist !== agent.allowlist) { + agents[key] = { ...agent, allowlist }; + } + } const normalized: ExecApprovalsFile = { version: 1, socket: { @@ -1145,6 +1165,7 @@ export function recordAllowlistUse( item.pattern === entry.pattern ? { ...item, + id: item.id ?? crypto.randomUUID(), lastUsedAt: Date.now(), lastUsedCommand: command, lastResolvedPath: resolvedPath, @@ -1168,7 +1189,7 @@ export function addAllowlistEntry( const trimmed = pattern.trim(); if (!trimmed) return; if (allowlist.some((entry) => entry.pattern === trimmed)) return; - allowlist.push({ pattern: trimmed, lastUsedAt: Date.now() }); + allowlist.push({ id: crypto.randomUUID(), pattern: trimmed, lastUsedAt: Date.now() }); agents[target] = { ...existing, allowlist }; approvals.agents = agents; saveExecApprovals(approvals); diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 21fffe807..73f5550e0 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -4,6 +4,7 @@ import { resolveChannelMediaMaxBytes } from "../../channels/plugins/media-limits import { loadChannelOutboundAdapter } from "../../channels/plugins/outbound/load.js"; import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js"; import type { ClawdbotConfig } from "../../config/config.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import type { sendMessageDiscord } from "../../discord/send.js"; import type { sendMessageIMessage } from "../../imessage/send.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; @@ -192,6 +193,9 @@ export async function deliverOutboundPayloads(params: { }) : undefined; const isSignalChannel = channel === "signal"; + const signalTableMode = isSignalChannel + ? resolveMarkdownTableMode({ cfg, channel: "signal", accountId }) + : "code"; const signalMaxBytes = isSignalChannel ? resolveChannelMediaMaxBytes({ cfg, @@ -231,8 +235,10 @@ export async function deliverOutboundPayloads(params: { throwIfAborted(abortSignal); let signalChunks = textLimit === undefined - ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY) - : markdownToSignalTextChunks(text, textLimit); + ? markdownToSignalTextChunks(text, Number.POSITIVE_INFINITY, { + tableMode: signalTableMode, + }) + : markdownToSignalTextChunks(text, textLimit, { tableMode: signalTableMode }); if (signalChunks.length === 0 && text) { signalChunks = [{ text, styles: [] }]; } @@ -244,7 +250,9 @@ export async function deliverOutboundPayloads(params: { const sendSignalMedia = async (caption: string, mediaUrl: string) => { throwIfAborted(abortSignal); - const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY)[0] ?? { + const formatted = markdownToSignalTextChunks(caption, Number.POSITIVE_INFINITY, { + tableMode: signalTableMode, + })[0] ?? { text: caption, styles: [], }; diff --git a/src/infra/provider-usage.format.ts b/src/infra/provider-usage.format.ts index f5a1b6995..d10879008 100644 --- a/src/infra/provider-usage.format.ts +++ b/src/infra/provider-usage.format.ts @@ -39,7 +39,7 @@ export function formatUsageWindowSummary( snapshot: ProviderUsageSnapshot, opts?: { now?: number; maxWindows?: number; includeResets?: boolean }, ): string | null { - if (snapshot.error) return `error: ${snapshot.error}`; + if (snapshot.error) return null; if (snapshot.windows.length === 0) return null; const now = opts?.now ?? Date.now(); const maxWindows = diff --git a/src/logging/diagnostic.ts b/src/logging/diagnostic.ts index ba6239184..adcb93eca 100644 --- a/src/logging/diagnostic.ts +++ b/src/logging/diagnostic.ts @@ -197,17 +197,20 @@ export function logSessionStateChange( }, ) { const state = getSessionState(params); + const isProbeSession = state.sessionId?.startsWith("probe-") ?? false; const prevState = state.state; state.state = params.state; state.lastActivity = Date.now(); if (params.state === "idle") state.queueDepth = Math.max(0, state.queueDepth - 1); - diag.info( - `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ - state.sessionKey ?? "unknown" - } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ - state.queueDepth - }`, - ); + if (!isProbeSession) { + diag.info( + `session state: sessionId=${state.sessionId ?? "unknown"} sessionKey=${ + state.sessionKey ?? "unknown" + } prev=${prevState} new=${params.state} reason="${params.reason ?? ""}" queueDepth=${ + state.queueDepth + }`, + ); + } emitDiagnosticEvent({ type: "session.state", sessionId: state.sessionId, diff --git a/src/markdown/ir.table-bullets.test.ts b/src/markdown/ir.table-bullets.test.ts new file mode 100644 index 000000000..358cb7eac --- /dev/null +++ b/src/markdown/ir.table-bullets.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from "vitest"; +import { markdownToIR } from "./ir.js"; + +describe("markdownToIR tableMode bullets", () => { + it("converts simple table to bullets", () => { + const md = ` +| Name | Value | +|------|-------| +| A | 1 | +| B | 2 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // Should contain bullet points with header:value format + expect(ir.text).toContain("• Value: 1"); + expect(ir.text).toContain("• Value: 2"); + // Should use first column as labels + expect(ir.text).toContain("A"); + expect(ir.text).toContain("B"); + }); + + it("handles table with multiple columns", () => { + const md = ` +| Feature | SQLite | Postgres | +|---------|--------|----------| +| Speed | Fast | Medium | +| Scale | Small | Large | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // First column becomes row label + expect(ir.text).toContain("Speed"); + expect(ir.text).toContain("Scale"); + // Other columns become bullet points + expect(ir.text).toContain("• SQLite: Fast"); + expect(ir.text).toContain("• Postgres: Medium"); + expect(ir.text).toContain("• SQLite: Small"); + expect(ir.text).toContain("• Postgres: Large"); + }); + + it("leaves table syntax untouched by default", () => { + const md = ` +| A | B | +|---|---| +| 1 | 2 | +`.trim(); + + const ir = markdownToIR(md); + + // No table conversion by default + expect(ir.text).toContain("| A | B |"); + expect(ir.text).toContain("| 1 | 2 |"); + expect(ir.text).not.toContain("•"); + expect(ir.styles.some((style) => style.style === "code_block")).toBe(false); + }); + + it("handles empty cells gracefully", () => { + const md = ` +| Name | Value | +|------|-------| +| A | | +| B | 2 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // Should handle empty cell without crashing + expect(ir.text).toContain("B"); + expect(ir.text).toContain("• Value: 2"); + }); + + it("bolds row labels in bullets mode", () => { + const md = ` +| Name | Value | +|------|-------| +| Row1 | Data1 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + // Should have bold style for row label + const hasRowLabelBold = ir.styles.some( + (s) => s.style === "bold" && ir.text.slice(s.start, s.end) === "Row1", + ); + expect(hasRowLabelBold).toBe(true); + }); + + it("renders tables as code blocks in code mode", () => { + const md = ` +| A | B | +|---|---| +| 1 | 2 | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "code" }); + + expect(ir.text).toContain("| A | B |"); + expect(ir.text).toContain("| 1 | 2 |"); + expect(ir.styles.some((style) => style.style === "code_block")).toBe(true); + }); + + it("preserves inline styles and links in bullets mode", () => { + const md = ` +| Name | Value | +|------|-------| +| _Row_ | [Link](https://example.com) | +`.trim(); + + const ir = markdownToIR(md, { tableMode: "bullets" }); + + const hasItalic = ir.styles.some( + (s) => s.style === "italic" && ir.text.slice(s.start, s.end) === "Row", + ); + expect(hasItalic).toBe(true); + expect(ir.links.some((link) => link.href === "https://example.com")).toBe(true); + }); +}); diff --git a/src/markdown/ir.ts b/src/markdown/ir.ts index c823381d8..186abeda0 100644 --- a/src/markdown/ir.ts +++ b/src/markdown/ir.ts @@ -1,6 +1,7 @@ import MarkdownIt from "markdown-it"; import { chunkText } from "../auto-reply/chunk.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; type ListState = { type: "bullet" | "ordered"; @@ -14,7 +15,6 @@ type LinkState = { type RenderEnv = { listStack: ListState[]; - linkStack: LinkState[]; }; type MarkdownToken = { @@ -50,15 +50,36 @@ type OpenStyle = { start: number; }; -type RenderState = { +type RenderTarget = { text: string; styles: MarkdownStyleSpan[]; openStyles: OpenStyle[]; links: MarkdownLinkSpan[]; + linkStack: LinkState[]; +}; + +type TableCell = { + text: string; + styles: MarkdownStyleSpan[]; + links: MarkdownLinkSpan[]; +}; + +type TableState = { + headers: TableCell[]; + rows: TableCell[][]; + currentRow: TableCell[]; + currentCell: RenderTarget | null; + inHeader: boolean; +}; + +type RenderState = RenderTarget & { env: RenderEnv; headingStyle: "none" | "bold"; blockquotePrefix: string; enableSpoilers: boolean; + tableMode: MarkdownTableMode; + table: TableState | null; + hasTables: boolean; }; export type MarkdownParseOptions = { @@ -67,6 +88,8 @@ export type MarkdownParseOptions = { headingStyle?: "none" | "bold"; blockquotePrefix?: string; autolink?: boolean; + /** How to render tables (off|bullets|code). Default: off. */ + tableMode?: MarkdownTableMode; }; function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { @@ -77,6 +100,11 @@ function createMarkdownIt(options: MarkdownParseOptions): MarkdownIt { typographer: false, }); md.enable("strikethrough"); + if (options.tableMode && options.tableMode !== "off") { + md.enable("table"); + } else { + md.disable("table"); + } if (options.autolink === false) { md.disable("autolink"); } @@ -144,23 +172,40 @@ function injectSpoilersIntoInline(tokens: MarkdownToken[]): MarkdownToken[] { return result; } +function initRenderTarget(): RenderTarget { + return { + text: "", + styles: [], + openStyles: [], + links: [], + linkStack: [], + }; +} + +function resolveRenderTarget(state: RenderState): RenderTarget { + return state.table?.currentCell ?? state; +} + function appendText(state: RenderState, value: string) { if (!value) return; - state.text += value; + const target = resolveRenderTarget(state); + target.text += value; } function openStyle(state: RenderState, style: MarkdownStyle) { - state.openStyles.push({ style, start: state.text.length }); + const target = resolveRenderTarget(state); + target.openStyles.push({ style, start: target.text.length }); } function closeStyle(state: RenderState, style: MarkdownStyle) { - for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { - if (state.openStyles[i]?.style === style) { - const start = state.openStyles[i].start; - state.openStyles.splice(i, 1); - const end = state.text.length; + const target = resolveRenderTarget(state); + for (let i = target.openStyles.length - 1; i >= 0; i -= 1) { + if (target.openStyles[i]?.style === style) { + const start = target.openStyles[i].start; + target.openStyles.splice(i, 1); + const end = target.text.length; if (end > start) { - state.styles.push({ start, end, style }); + target.styles.push({ start, end, style }); } return; } @@ -169,7 +214,8 @@ function closeStyle(state: RenderState, style: MarkdownStyle) { function appendParagraphSeparator(state: RenderState) { if (state.env.listStack.length > 0) return; - appendText(state, "\n\n"); + if (state.table) return; // Don't add paragraph separators inside tables + state.text += "\n\n"; } function appendListPrefix(state: RenderState) { @@ -179,39 +225,232 @@ function appendListPrefix(state: RenderState) { top.index += 1; const indent = " ".repeat(Math.max(0, stack.length - 1)); const prefix = top.type === "ordered" ? `${top.index}. ` : "• "; - appendText(state, `${indent}${prefix}`); + state.text += `${indent}${prefix}`; } function renderInlineCode(state: RenderState, content: string) { if (!content) return; - const start = state.text.length; - appendText(state, content); - state.styles.push({ start, end: start + content.length, style: "code" }); + const target = resolveRenderTarget(state); + const start = target.text.length; + target.text += content; + target.styles.push({ start, end: start + content.length, style: "code" }); } function renderCodeBlock(state: RenderState, content: string) { let code = content ?? ""; if (!code.endsWith("\n")) code = `${code}\n`; - const start = state.text.length; - appendText(state, code); - state.styles.push({ start, end: start + code.length, style: "code_block" }); + const target = resolveRenderTarget(state); + const start = target.text.length; + target.text += code; + target.styles.push({ start, end: start + code.length, style: "code_block" }); if (state.env.listStack.length === 0) { - appendText(state, "\n"); + target.text += "\n"; } } function handleLinkClose(state: RenderState) { - const link = state.env.linkStack.pop(); + const target = resolveRenderTarget(state); + const link = target.linkStack.pop(); if (!link?.href) return; const href = link.href.trim(); if (!href) return; const start = link.labelStart; - const end = state.text.length; + const end = target.text.length; if (end <= start) { - state.links.push({ start, end, href }); + target.links.push({ start, end, href }); return; } - state.links.push({ start, end, href }); + target.links.push({ start, end, href }); +} + +function initTableState(): TableState { + return { + headers: [], + rows: [], + currentRow: [], + currentCell: null, + inHeader: false, + }; +} + +function finishTableCell(cell: RenderTarget): TableCell { + closeRemainingStyles(cell); + return { + text: cell.text, + styles: cell.styles, + links: cell.links, + }; +} + +function trimCell(cell: TableCell): TableCell { + const text = cell.text; + let start = 0; + let end = text.length; + while (start < end && /\s/.test(text[start] ?? "")) start += 1; + while (end > start && /\s/.test(text[end - 1] ?? "")) end -= 1; + if (start === 0 && end === text.length) return cell; + const trimmedText = text.slice(start, end); + const trimmedLength = trimmedText.length; + const trimmedStyles: MarkdownStyleSpan[] = []; + for (const span of cell.styles) { + const sliceStart = Math.max(0, span.start - start); + const sliceEnd = Math.min(trimmedLength, span.end - start); + if (sliceEnd > sliceStart) { + trimmedStyles.push({ start: sliceStart, end: sliceEnd, style: span.style }); + } + } + const trimmedLinks: MarkdownLinkSpan[] = []; + for (const span of cell.links) { + const sliceStart = Math.max(0, span.start - start); + const sliceEnd = Math.min(trimmedLength, span.end - start); + if (sliceEnd > sliceStart) { + trimmedLinks.push({ start: sliceStart, end: sliceEnd, href: span.href }); + } + } + return { text: trimmedText, styles: trimmedStyles, links: trimmedLinks }; +} + +function appendCell(state: RenderState, cell: TableCell) { + if (!cell.text) return; + const start = state.text.length; + state.text += cell.text; + for (const span of cell.styles) { + state.styles.push({ + start: start + span.start, + end: start + span.end, + style: span.style, + }); + } + for (const link of cell.links) { + state.links.push({ + start: start + link.start, + end: start + link.end, + href: link.href, + }); + } +} + +function renderTableAsBullets(state: RenderState) { + if (!state.table) return; + const headers = state.table.headers.map(trimCell); + const rows = state.table.rows.map((row) => row.map(trimCell)); + + // If no headers or rows, skip + if (headers.length === 0 && rows.length === 0) return; + + // Determine if first column should be used as row labels + // (common pattern: first column is category/feature name) + const useFirstColAsLabel = headers.length > 1 && rows.length > 0; + + if (useFirstColAsLabel) { + // Format: each row becomes a section with header as row[0], then key:value pairs + for (const row of rows) { + if (row.length === 0) continue; + + const rowLabel = row[0]; + if (rowLabel?.text) { + const labelStart = state.text.length; + appendCell(state, rowLabel); + const labelEnd = state.text.length; + if (labelEnd > labelStart) { + state.styles.push({ start: labelStart, end: labelEnd, style: "bold" }); + } + state.text += "\n"; + } + + // Add each column as a bullet point + for (let i = 1; i < row.length; i++) { + const header = headers[i]; + const value = row[i]; + if (!value?.text) continue; + state.text += "• "; + if (header?.text) { + appendCell(state, header); + state.text += ": "; + } else { + state.text += `Column ${i}: `; + } + appendCell(state, value); + state.text += "\n"; + } + state.text += "\n"; + } + } else { + // Simple table: just list headers and values + for (const row of rows) { + for (let i = 0; i < row.length; i++) { + const header = headers[i]; + const value = row[i]; + if (!value?.text) continue; + state.text += "• "; + if (header?.text) { + appendCell(state, header); + state.text += ": "; + } + appendCell(state, value); + state.text += "\n"; + } + state.text += "\n"; + } + } +} + +function renderTableAsCode(state: RenderState) { + if (!state.table) return; + const headers = state.table.headers.map(trimCell); + const rows = state.table.rows.map((row) => row.map(trimCell)); + + const columnCount = Math.max(headers.length, ...rows.map((row) => row.length)); + if (columnCount === 0) return; + + const widths = Array.from({ length: columnCount }, () => 0); + const updateWidths = (cells: TableCell[]) => { + for (let i = 0; i < columnCount; i += 1) { + const cell = cells[i]; + const width = cell?.text.length ?? 0; + if (widths[i] < width) widths[i] = width; + } + }; + updateWidths(headers); + for (const row of rows) updateWidths(row); + + const codeStart = state.text.length; + + const appendRow = (cells: TableCell[]) => { + state.text += "|"; + for (let i = 0; i < columnCount; i += 1) { + state.text += " "; + const cell = cells[i]; + if (cell) appendCell(state, cell); + const pad = widths[i] - (cell?.text.length ?? 0); + if (pad > 0) state.text += " ".repeat(pad); + state.text += " |"; + } + state.text += "\n"; + }; + + const appendDivider = () => { + state.text += "|"; + for (let i = 0; i < columnCount; i += 1) { + const dashCount = Math.max(3, widths[i]); + state.text += ` ${"-".repeat(dashCount)} |`; + } + state.text += "\n"; + }; + + appendRow(headers); + appendDivider(); + for (const row of rows) { + appendRow(row); + } + + const codeEnd = state.text.length; + if (codeEnd > codeStart) { + state.styles.push({ start: codeStart, end: codeEnd, style: "code_block" }); + } + if (state.env.listStack.length === 0) { + state.text += "\n"; + } } function renderTokens(tokens: MarkdownToken[], state: RenderState): void { @@ -252,7 +491,8 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { break; case "link_open": { const href = getAttr(token, "href") ?? ""; - state.env.linkStack.push({ href, labelStart: state.text.length }); + const target = resolveRenderTarget(state); + target.linkStack.push({ href, labelStart: target.text.length }); break; } case "link_close": @@ -276,10 +516,10 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { appendParagraphSeparator(state); break; case "blockquote_open": - if (state.blockquotePrefix) appendText(state, state.blockquotePrefix); + if (state.blockquotePrefix) state.text += state.blockquotePrefix; break; case "blockquote_close": - appendText(state, "\n"); + state.text += "\n"; break; case "bullet_list_open": state.env.listStack.push({ type: "bullet", index: 0 }); @@ -299,7 +539,7 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { appendListPrefix(state); break; case "list_item_close": - appendText(state, "\n"); + state.text += "\n"; break; case "code_block": case "fence": @@ -309,22 +549,68 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { case "html_inline": appendText(state, token.content ?? ""); break; + + // Table handling case "table_open": + if (state.tableMode !== "off") { + state.table = initTableState(); + state.hasTables = true; + } + break; case "table_close": + if (state.table) { + if (state.tableMode === "bullets") { + renderTableAsBullets(state); + } else if (state.tableMode === "code") { + renderTableAsCode(state); + } + } + state.table = null; + break; case "thead_open": + if (state.table) { + state.table.inHeader = true; + } + break; case "thead_close": + if (state.table) { + state.table.inHeader = false; + } + break; case "tbody_open": case "tbody_close": break; + case "tr_open": + if (state.table) { + state.table.currentRow = []; + } + break; case "tr_close": - appendText(state, "\n"); + if (state.table) { + if (state.table.inHeader) { + state.table.headers = state.table.currentRow; + } else { + state.table.rows.push(state.table.currentRow); + } + state.table.currentRow = []; + } + break; + case "th_open": + case "td_open": + if (state.table) { + state.table.currentCell = initRenderTarget(); + } break; case "th_close": case "td_close": - appendText(state, "\t"); + if (state.table?.currentCell) { + state.table.currentRow.push(finishTableCell(state.table.currentCell)); + state.table.currentCell = null; + } break; + case "hr": - appendText(state, "\n"); + state.text += "\n"; break; default: if (token.children) renderTokens(token.children, state); @@ -333,19 +619,19 @@ function renderTokens(tokens: MarkdownToken[], state: RenderState): void { } } -function closeRemainingStyles(state: RenderState) { - for (let i = state.openStyles.length - 1; i >= 0; i -= 1) { - const open = state.openStyles[i]; - const end = state.text.length; +function closeRemainingStyles(target: RenderTarget) { + for (let i = target.openStyles.length - 1; i >= 0; i -= 1) { + const open = target.openStyles[i]; + const end = target.text.length; if (end > open.start) { - state.styles.push({ + target.styles.push({ start: open.start, end, style: open.style, }); } } - state.openStyles = []; + target.openStyles = []; } function clampStyleSpans(spans: MarkdownStyleSpan[], maxLength: number): MarkdownStyleSpan[] { @@ -426,22 +712,35 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number): } export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR { - const env: RenderEnv = { listStack: [], linkStack: [] }; + return markdownToIRWithMeta(markdown, options).ir; +} + +export function markdownToIRWithMeta( + markdown: string, + options: MarkdownParseOptions = {}, +): { ir: MarkdownIR; hasTables: boolean } { + const env: RenderEnv = { listStack: [] }; const md = createMarkdownIt(options); const tokens = md.parse(markdown ?? "", env as unknown as object); if (options.enableSpoilers) { applySpoilerTokens(tokens as MarkdownToken[]); } + const tableMode = options.tableMode ?? "off"; + const state: RenderState = { text: "", styles: [], openStyles: [], links: [], + linkStack: [], env, headingStyle: options.headingStyle ?? "none", blockquotePrefix: options.blockquotePrefix ?? "", enableSpoilers: options.enableSpoilers ?? false, + tableMode, + table: null, + hasTables: false, }; renderTokens(tokens as MarkdownToken[], state); @@ -459,9 +758,12 @@ export function markdownToIR(markdown: string, options: MarkdownParseOptions = { finalLength === state.text.length ? state.text : state.text.slice(0, finalLength); return { - text: finalText, - styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), - links: clampLinkSpans(state.links, finalLength), + ir: { + text: finalText, + styles: mergeStyleSpans(clampStyleSpans(state.styles, finalLength)), + links: clampLinkSpans(state.links, finalLength), + }, + hasTables: state.hasTables, }; } diff --git a/src/markdown/tables.ts b/src/markdown/tables.ts new file mode 100644 index 000000000..9ae2b750e --- /dev/null +++ b/src/markdown/tables.ts @@ -0,0 +1,34 @@ +import type { MarkdownTableMode } from "../config/types.base.js"; +import { markdownToIRWithMeta } from "./ir.js"; +import { renderMarkdownWithMarkers } from "./render.js"; + +const MARKDOWN_STYLE_MARKERS = { + bold: { open: "**", close: "**" }, + italic: { open: "_", close: "_" }, + strikethrough: { open: "~~", close: "~~" }, + code: { open: "`", close: "`" }, + code_block: { open: "```\n", close: "```" }, +} as const; + +export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string { + if (!markdown || mode === "off") return markdown; + const { ir, hasTables } = markdownToIRWithMeta(markdown, { + linkify: false, + autolink: false, + headingStyle: "none", + blockquotePrefix: "", + tableMode: mode, + }); + if (!hasTables) return markdown; + return renderMarkdownWithMarkers(ir, { + styleMarkers: MARKDOWN_STYLE_MARKERS, + escapeText: (text) => text, + buildLink: (link, text) => { + const href = link.href.trim(); + if (!href) return null; + const label = text.slice(link.start, link.end); + if (!label) return null; + return { start: link.start, end: link.end, open: "[", close: `](${href})` }; + }, + }); +} diff --git a/src/media/server.test.ts b/src/media/server.test.ts index 875088cbf..693ba5940 100644 --- a/src/media/server.test.ts +++ b/src/media/server.test.ts @@ -14,6 +14,19 @@ vi.mock("./store.js", () => ({ const { startMediaServer } = await import("./server.js"); +const waitForFileRemoval = async (file: string, timeoutMs = 200) => { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + try { + await fs.stat(file); + } catch { + return; + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error(`timed out waiting for ${file} removal`); +}; + describe("media server", () => { beforeAll(async () => { await fs.rm(MEDIA_DIR, { recursive: true, force: true }); @@ -32,8 +45,7 @@ describe("media server", () => { const res = await fetch(`http://localhost:${port}/media/file1`); expect(res.status).toBe(200); expect(await res.text()).toBe("hello"); - await new Promise((r) => setTimeout(r, 600)); - await expect(fs.stat(file)).rejects.toThrow(); + await waitForFileRemoval(file); await new Promise((r) => server.close(r)); }); diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index 45a4681c7..72bb72422 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -73,6 +73,8 @@ export type { DmPolicy, DmConfig, GroupPolicy, + MarkdownConfig, + MarkdownTableMode, MSTeamsChannelConfig, MSTeamsConfig, MSTeamsReplyStyle, @@ -92,6 +94,8 @@ export { DmConfigSchema, DmPolicySchema, GroupPolicySchema, + MarkdownConfigSchema, + MarkdownTableModeSchema, normalizeAllowFrom, requireOpenAllowFrom, } from "../config/zod-schema.core.js"; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 4765c71c7..6bb10984b 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -34,6 +34,7 @@ import { resolveChannelGroupPolicy, resolveChannelGroupRequireMention, } from "../../config/group-policy.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { resolveStateDir } from "../../config/paths.js"; import { loadConfig, writeConfigFile } from "../../config/config.js"; import { @@ -58,6 +59,7 @@ import { monitorIMessageProvider } from "../../imessage/monitor.js"; import { probeIMessage } from "../../imessage/probe.js"; import { sendMessageIMessage } from "../../imessage/send.js"; import { shouldLogVerbose } from "../../globals.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import { getChildLogger } from "../../logging.js"; import { normalizeLogLevel } from "../../logging/levels.js"; import { isVoiceCompatibleAudio } from "../../media/audio.js"; @@ -156,6 +158,8 @@ export function createPluginRuntime(): PluginRuntime { chunkText, resolveTextChunkLimit, hasControlCommand, + resolveMarkdownTableMode, + convertMarkdownTables, }, reply: { dispatchReplyWithBufferedBlockDispatcher, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 089e20c37..1f321d04b 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -32,6 +32,9 @@ type ResolveCommandAuthorizedFromAuthorizers = type ResolveTextChunkLimit = typeof import("../../auto-reply/chunk.js").resolveTextChunkLimit; type ChunkMarkdownText = typeof import("../../auto-reply/chunk.js").chunkMarkdownText; type ChunkText = typeof import("../../auto-reply/chunk.js").chunkText; +type ResolveMarkdownTableMode = + typeof import("../../config/markdown-tables.js").resolveMarkdownTableMode; +type ConvertMarkdownTables = typeof import("../../markdown/tables.js").convertMarkdownTables; type HasControlCommand = typeof import("../../auto-reply/command-detection.js").hasControlCommand; type IsControlCommandMessage = typeof import("../../auto-reply/command-detection.js").isControlCommandMessage; @@ -168,6 +171,8 @@ export type PluginRuntime = { chunkText: ChunkText; resolveTextChunkLimit: ResolveTextChunkLimit; hasControlCommand: HasControlCommand; + resolveMarkdownTableMode: ResolveMarkdownTableMode; + convertMarkdownTables: ConvertMarkdownTables; }; reply: { dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher; diff --git a/src/process/command-queue.ts b/src/process/command-queue.ts index 9b203c938..2f2857130 100644 --- a/src/process/command-queue.ts +++ b/src/process/command-queue.ts @@ -68,9 +68,12 @@ function drainLane(lane: string) { entry.resolve(result); } catch (err) { state.active -= 1; - diag.error( - `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, - ); + const isProbeLane = lane.startsWith("auth-probe:") || lane.startsWith("session:probe-"); + if (!isProbeLane) { + diag.error( + `lane task error: lane=${lane} durationMs=${Date.now() - startTime} error="${String(err)}"`, + ); + } pump(); entry.reject(err); } diff --git a/src/signal/format.ts b/src/signal/format.ts index 0890ce608..127884e89 100644 --- a/src/signal/format.ts +++ b/src/signal/format.ts @@ -4,6 +4,7 @@ import { type MarkdownIR, type MarkdownStyle, } from "../markdown/ir.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; type SignalTextStyle = "BOLD" | "ITALIC" | "STRIKETHROUGH" | "MONOSPACE" | "SPOILER"; @@ -18,6 +19,10 @@ export type SignalFormattedText = { styles: SignalTextStyleRange[]; }; +type SignalMarkdownOptions = { + tableMode?: MarkdownTableMode; +}; + type SignalStyleSpan = { start: number; end: number; @@ -188,22 +193,31 @@ function renderSignalText(ir: MarkdownIR): SignalFormattedText { }; } -export function markdownToSignalText(markdown: string): SignalFormattedText { +export function markdownToSignalText( + markdown: string, + options: SignalMarkdownOptions = {}, +): SignalFormattedText { const ir = markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); return renderSignalText(ir); } -export function markdownToSignalTextChunks(markdown: string, limit: number): SignalFormattedText[] { +export function markdownToSignalTextChunks( + markdown: string, + limit: number, + options: SignalMarkdownOptions = {}, +): SignalFormattedText[] { const ir = markdownToIR(markdown ?? "", { linkify: true, enableSpoilers: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => renderSignalText(chunk)); diff --git a/src/signal/send.ts b/src/signal/send.ts index dce4cda7a..32ca09094 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -1,4 +1,5 @@ import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { mediaKindFromMime } from "../media/constants.js"; import { saveMediaBuffer } from "../media/store.js"; import { loadWebMedia } from "../web/media.js"; @@ -164,7 +165,12 @@ export async function sendMessageSignal( if (textMode === "plain") { textStyles = opts.textStyles ?? []; } else { - const formatted = markdownToSignalText(message); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "signal", + accountId: accountInfo.accountId, + }); + const formatted = markdownToSignalText(message, { tableMode }); message = formatted.text; textStyles = formatted.styles; } diff --git a/src/slack/format.ts b/src/slack/format.ts index 575841921..7f44b5df2 100644 --- a/src/slack/format.ts +++ b/src/slack/format.ts @@ -1,4 +1,5 @@ import { chunkMarkdownIR, markdownToIR, type MarkdownLinkSpan } from "../markdown/ir.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js"; // Escape special characters for Slack mrkdwn format. @@ -83,12 +84,20 @@ function buildSlackLink(link: MarkdownLinkSpan, text: string) { }; } -export function markdownToSlackMrkdwn(markdown: string): string { +type SlackMarkdownOptions = { + tableMode?: MarkdownTableMode; +}; + +export function markdownToSlackMrkdwn( + markdown: string, + options: SlackMarkdownOptions = {}, +): string { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", + tableMode: options.tableMode, }); return renderMarkdownWithMarkers(ir, { styleMarkers: { @@ -103,12 +112,17 @@ export function markdownToSlackMrkdwn(markdown: string): string { }); } -export function markdownToSlackMrkdwnChunks(markdown: string, limit: number): string[] { +export function markdownToSlackMrkdwnChunks( + markdown: string, + limit: number, + options: SlackMarkdownOptions = {}, +): string[] { const ir = markdownToIR(markdown ?? "", { linkify: false, autolink: false, headingStyle: "bold", blockquotePrefix: "> ", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => diff --git a/src/slack/monitor/replies.ts b/src/slack/monitor/replies.ts index 59c9d8164..ca4635123 100644 --- a/src/slack/monitor/replies.ts +++ b/src/slack/monitor/replies.ts @@ -1,6 +1,7 @@ import { createReplyReferencePlanner } from "../../auto-reply/reply/reply-reference.js"; import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; import type { RuntimeEnv } from "../../runtime.js"; import { markdownToSlackMrkdwnChunks } from "../format.js"; import { sendMessageSlack } from "../send.js"; @@ -116,6 +117,7 @@ export async function deliverSlackSlashReplies(params: { respond: SlackRespondFn; ephemeral: boolean; textLimit: number; + tableMode?: MarkdownTableMode; }) { const messages: string[] = []; const chunkLimit = Math.min(params.textLimit, 4000); @@ -127,7 +129,9 @@ export async function deliverSlackSlashReplies(params: { .filter(Boolean) .join("\n"); if (!combined) continue; - for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit)) { + for (const chunk of markdownToSlackMrkdwnChunks(combined, chunkLimit, { + tableMode: params.tableMode, + })) { messages.push(chunk); } } diff --git a/src/slack/monitor/slash.ts b/src/slack/monitor/slash.ts index d8e97dd43..8f290d892 100644 --- a/src/slack/monitor/slash.ts +++ b/src/slack/monitor/slash.ts @@ -12,6 +12,7 @@ import { listSkillCommandsForAgents } from "../../auto-reply/skill-commands.js"; import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.js"; import { resolveNativeCommandsEnabled, resolveNativeSkillsEnabled } from "../../config/commands.js"; +import { resolveMarkdownTableMode } from "../../config/markdown-tables.js"; import { danger, logVerbose } from "../../globals.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { @@ -424,6 +425,11 @@ export function registerSlackMonitorSlashCommands(params: { respond, ephemeral: slashCommand.ephemeral, textLimit: ctx.textLimit, + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: route.accountId, + }), }); }, onError: (err, info) => { @@ -438,6 +444,11 @@ export function registerSlackMonitorSlashCommands(params: { respond, ephemeral: slashCommand.ephemeral, textLimit: ctx.textLimit, + tableMode: resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: route.accountId, + }), }); } } catch (err) { diff --git a/src/slack/send.ts b/src/slack/send.ts index 06de9770d..3759b2826 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -8,6 +8,7 @@ import type { SlackTokenSource } from "./accounts.js"; import { resolveSlackAccount } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; import { markdownToSlackMrkdwnChunks } from "./format.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { parseSlackTarget } from "./targets.js"; import { resolveSlackBotToken } from "./token.js"; @@ -143,7 +144,12 @@ export async function sendMessageSlack( const { channelId } = await resolveChannelId(client, recipient); const textLimit = resolveTextChunkLimit(cfg, "slack", account.accountId); const chunkLimit = Math.min(textLimit, SLACK_TEXT_LIMIT); - const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "slack", + accountId: account.accountId, + }); + const chunks = markdownToSlackMrkdwnChunks(trimmedMessage, chunkLimit, { tableMode }); const mediaMaxBytes = typeof account.config.mediaMaxMb === "number" ? account.config.mediaMaxMb * 1024 * 1024 diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 79f57a28c..4afbaa653 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -8,6 +8,7 @@ import { EmbeddedBlockChunker } from "../agents/pi-embedded-block-chunker.js"; import { clearHistoryEntries } from "../auto-reply/reply/history.js"; import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { danger, logVerbose } from "../globals.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { deliverReplies } from "./bot/delivery.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { createTelegramDraftStream } from "./draft-stream.js"; @@ -123,6 +124,11 @@ export const dispatchTelegramMessage = async ({ let prefixContext: ResponsePrefixContext = { identityName: resolveIdentityName(cfg, route.agentId), }; + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); const { queuedFinal } = await dispatchReplyWithBufferedBlockDispatcher({ ctx: ctxPayload, @@ -144,6 +150,7 @@ export const dispatchTelegramMessage = async ({ replyToMode, textLimit, messageThreadId: resolvedThreadId, + tableMode, onVoiceRecording: sendRecordVoice, }); }, diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 11e83dcc3..c3d3a7b74 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -15,6 +15,7 @@ import { resolveTelegramCustomCommands } from "../config/telegram-custom-command import { dispatchReplyWithBufferedBlockDispatcher } from "../auto-reply/reply/provider-dispatcher.js"; import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js"; import { danger, logVerbose } from "../globals.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveAgentRoute } from "../routing/resolve-route.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js"; import type { ChannelGroupPolicy } from "../config/group-policy.js"; @@ -269,6 +270,11 @@ export const registerTelegramNativeCommands = ({ id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), }, }); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: route.accountId, + }); const skillFilter = firstDefined(topicConfig?.skills, groupConfig?.skills); const systemPromptParts = [ groupConfig?.systemPrompt?.trim() || null, @@ -327,6 +333,7 @@ export const registerTelegramNativeCommands = ({ replyToMode, textLimit, messageThreadId: resolvedThreadId, + tableMode, }); }, onError: (err, info) => { diff --git a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts index 4fea3521a..7024a2e52 100644 --- a/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts +++ b/src/telegram/bot.create-telegram-bot.accepts-group-messages-mentionpatterns-match-without-botusername.test.ts @@ -1,7 +1,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -111,7 +112,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -121,7 +122,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts index 2afe8cd1c..1a10ca94c 100644 --- a/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts +++ b/src/telegram/bot.create-telegram-bot.applies-topic-skill-filters-system-prompts.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts index 6c712ca1d..7937c1064 100644 --- a/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.blocks-all-group-messages-grouppolicy-is.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts index 9ed0ed677..5e8a2dcfa 100644 --- a/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts +++ b/src/telegram/bot.create-telegram-bot.dedupes-duplicate-callback-query-updates-by-update.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts index ab43c4269..05aac6388 100644 --- a/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts +++ b/src/telegram/bot.create-telegram-bot.installs-grammy-throttler.test.ts @@ -1,9 +1,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; import { resolveTelegramFetch } from "./fetch.js"; +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; + const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), })); @@ -114,7 +116,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -125,7 +127,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts index dfdcf43e3..2c4dfa472 100644 --- a/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-tg-prefixed-allowfrom-entries-case-insensitively.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts index 1e1174fbf..2281fb407 100644 --- a/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts +++ b/src/telegram/bot.create-telegram-bot.matches-usernames-case-insensitively-grouppolicy-is.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts index 6e83c61c3..829391727 100644 --- a/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts +++ b/src/telegram/bot.create-telegram-bot.routes-dms-by-telegram-accountid-binding.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -110,7 +111,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -119,7 +120,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts index 74f87d63b..164095a9c 100644 --- a/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts +++ b/src/telegram/bot.create-telegram-bot.sends-replies-without-native-reply-threading.test.ts @@ -2,8 +2,9 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import { createTelegramBot } from "./bot.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; const { loadWebMedia } = vi.hoisted(() => ({ loadWebMedia: vi.fn(), @@ -113,7 +114,7 @@ vi.mock("../auto-reply/reply.js", () => { return { getReplyFromConfig: replySpy, __replySpy: replySpy }; }); -const replyModule = await import("../auto-reply/reply.js"); +let replyModule: typeof import("../auto-reply/reply.js"); const getOnHandler = (event: string) => { const handler = onSpy.mock.calls.find((call) => call[0] === event)?.[1]; @@ -122,7 +123,11 @@ const getOnHandler = (event: string) => { }; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); resetInboundDedupe(); loadConfig.mockReturnValue({ channels: { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index 51beb4f4b..da67c2e38 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -6,18 +6,20 @@ import { listNativeCommandSpecs, listNativeCommandSpecsForConfig, } from "../auto-reply/commands-registry.js"; +import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; +import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; +import { resolveTelegramFetch } from "./fetch.js"; + +let createTelegramBot: typeof import("./bot.js").createTelegramBot; +let getTelegramSequentialKey: typeof import("./bot.js").getTelegramSequentialKey; +let resetInboundDedupe: typeof import("../auto-reply/reply/inbound-dedupe.js").resetInboundDedupe; +let replyModule: typeof import("../auto-reply/reply.js"); const { listSkillCommandsForAgents } = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); vi.mock("../auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); -import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js"; -import * as replyModule from "../auto-reply/reply.js"; -import { expectInboundContextContract } from "../../test/helpers/inbound-contract.js"; -import { escapeRegExp, formatEnvelopeTimestamp } from "../../test/helpers/envelope-timestamp.js"; -import { createTelegramBot, getTelegramSequentialKey } from "./bot.js"; -import { resolveTelegramFetch } from "./fetch.js"; function resolveSkillCommands(config: Parameters[0]) { return listSkillCommandsForAgents({ cfg: config }); @@ -155,7 +157,11 @@ const getOnHandler = (event: string) => { const ORIGINAL_TZ = process.env.TZ; describe("createTelegramBot", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + ({ resetInboundDedupe } = await import("../auto-reply/reply/inbound-dedupe.js")); + ({ createTelegramBot, getTelegramSequentialKey } = await import("./bot.js")); + replyModule = await import("../auto-reply/reply.js"); process.env.TZ = "UTC"; resetInboundDedupe(); loadConfig.mockReturnValue({ diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index cb6356061..e05b224da 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -3,6 +3,7 @@ import { markdownToTelegramChunks, markdownToTelegramHtml } from "../format.js"; import { splitTelegramCaption } from "../caption.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import type { ReplyToMode } from "../../config/config.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; import { danger, logVerbose } from "../../globals.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { mediaKindFromMime } from "../../media/constants.js"; @@ -26,6 +27,7 @@ export async function deliverReplies(params: { replyToMode: ReplyToMode; textLimit: number; messageThreadId?: number; + tableMode?: MarkdownTableMode; /** Callback invoked before sending a voice message to switch typing indicator. */ onVoiceRecording?: () => Promise | void; }) { @@ -49,7 +51,9 @@ export async function deliverReplies(params: { ? [reply.mediaUrl] : []; if (mediaList.length === 0) { - const chunks = markdownToTelegramChunks(reply.text || "", textLimit); + const chunks = markdownToTelegramChunks(reply.text || "", textLimit, { + tableMode: params.tableMode, + }); for (const chunk of chunks) { await sendTelegramText(bot, chatId, chunk.html, runtime, { replyToMessageId: @@ -139,7 +143,9 @@ export async function deliverReplies(params: { // Send deferred follow-up text right after the first media item. // Chunk it in case it's extremely long (same logic as text-only replies). if (pendingFollowUpText && isFirstMedia) { - const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit); + const chunks = markdownToTelegramChunks(pendingFollowUpText, textLimit, { + tableMode: params.tableMode, + }); for (const chunk of chunks) { const replyToMessageIdFollowup = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; diff --git a/src/telegram/format.ts b/src/telegram/format.ts index 7894d67f0..b0472c69c 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -5,6 +5,7 @@ import { type MarkdownIR, } from "../markdown/ir.js"; import { renderMarkdownWithMarkers } from "../markdown/render.js"; +import type { MarkdownTableMode } from "../config/types.base.js"; export type TelegramFormattedChunk = { html: string; @@ -46,11 +47,15 @@ function renderTelegramHtml(ir: MarkdownIR): string { }); } -export function markdownToTelegramHtml(markdown: string): string { +export function markdownToTelegramHtml( + markdown: string, + options: { tableMode?: MarkdownTableMode } = {}, +): string { const ir = markdownToIR(markdown ?? "", { linkify: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); return renderTelegramHtml(ir); } @@ -58,11 +63,13 @@ export function markdownToTelegramHtml(markdown: string): string { export function markdownToTelegramChunks( markdown: string, limit: number, + options: { tableMode?: MarkdownTableMode } = {}, ): TelegramFormattedChunk[] { const ir = markdownToIR(markdown ?? "", { linkify: true, headingStyle: "none", blockquotePrefix: "", + tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); return chunks.map((chunk) => ({ diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 253db203e..01120d354 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -17,6 +17,7 @@ import { loadWebMedia } from "../web/media.js"; import { resolveTelegramAccount } from "./accounts.js"; import { resolveTelegramFetch } from "./fetch.js"; import { markdownToTelegramHtml } from "./format.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { splitTelegramCaption } from "./caption.js"; import { recordSentMessage } from "./sent-message-cache.js"; import { parseTelegramTarget, stripTelegramInternalPrefixes } from "./targets.js"; @@ -310,7 +311,12 @@ export async function sendMessageTelegram( throw new Error("Message must be non-empty for Telegram sends"); } const textMode = opts.textMode ?? "markdown"; - const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "telegram", + accountId: account.accountId, + }); + const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text, { tableMode }); const textParams = hasThreadParams ? { parse_mode: "HTML" as const, diff --git a/src/tui/commands.test.ts b/src/tui/commands.test.ts index 2c0fde55d..43be20733 100644 --- a/src/tui/commands.test.ts +++ b/src/tui/commands.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { parseCommand } from "./commands.js"; +import { getSlashCommands, parseCommand } from "./commands.js"; describe("tui slash commands", () => { it("treats /elev as an alias for /elevated", () => { @@ -13,4 +13,10 @@ describe("tui slash commands", () => { args: "off", }); }); + + it("includes gateway text commands", () => { + const commands = getSlashCommands({}); + expect(commands.some((command) => command.name === "context")).toBe(true); + expect(commands.some((command) => command.name === "commands")).toBe(true); + }); }); diff --git a/src/tui/commands.ts b/src/tui/commands.ts index 59806cfbd..04f40bd2c 100644 --- a/src/tui/commands.ts +++ b/src/tui/commands.ts @@ -1,5 +1,7 @@ import type { SlashCommand } from "@mariozechner/pi-tui"; +import { listChatCommands, listChatCommandsForConfig } from "../auto-reply/commands-registry.js"; import { formatThinkingLevels, listThinkingLevelLabels } from "../auto-reply/thinking.js"; +import type { ClawdbotConfig } from "../config/types.js"; const VERBOSE_LEVELS = ["on", "off"]; const REASONING_LEVELS = ["on", "off"]; @@ -13,6 +15,7 @@ export type ParsedCommand = { }; export type SlashCommandOptions = { + cfg?: ClawdbotConfig; provider?: string; model?: string; }; @@ -34,7 +37,7 @@ export function parseCommand(input: string): ParsedCommand { export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] { const thinkLevels = listThinkingLevelLabels(options.provider, options.model); - return [ + const commands: SlashCommand[] = [ { name: "help", description: "Show slash command help" }, { name: "status", description: "Show gateway status summary" }, { name: "agent", description: "Switch agent (or open picker)" }, @@ -115,6 +118,20 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman { name: "exit", description: "Exit the TUI" }, { name: "quit", description: "Exit the TUI" }, ]; + + const seen = new Set(commands.map((command) => command.name)); + const gatewayCommands = options.cfg ? listChatCommandsForConfig(options.cfg) : listChatCommands(); + for (const command of gatewayCommands) { + const aliases = command.textAliases.length > 0 ? command.textAliases : [`/${command.key}`]; + for (const alias of aliases) { + const name = alias.replace(/^\//, "").trim(); + if (!name || seen.has(name)) continue; + seen.add(name); + commands.push({ name, description: command.description }); + } + } + + return commands; } export function helpText(options: SlashCommandOptions = {}): string { @@ -122,6 +139,7 @@ export function helpText(options: SlashCommandOptions = {}): string { return [ "Slash commands:", "/help", + "/commands", "/status", "/agent (or /agents)", "/session (or /sessions)", diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts new file mode 100644 index 000000000..fc2ac4fa6 --- /dev/null +++ b/src/tui/tui-command-handlers.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { createCommandHandlers } from "./tui-command-handlers.js"; + +describe("tui command handlers", () => { + it("forwards unknown slash commands to the gateway", async () => { + const sendChat = vi.fn().mockResolvedValue({ runId: "r1" }); + const addUser = vi.fn(); + const addSystem = vi.fn(); + const requestRender = vi.fn(); + const setActivityStatus = vi.fn(); + + const { handleCommand } = createCommandHandlers({ + client: { sendChat } as never, + chatLog: { addUser, addSystem } as never, + tui: { requestRender } as never, + opts: {}, + state: { + currentSessionKey: "agent:main:main", + activeChatRunId: null, + sessionInfo: {}, + } as never, + deliverDefault: false, + openOverlay: vi.fn(), + closeOverlay: vi.fn(), + refreshSessionInfo: vi.fn(), + loadHistory: vi.fn(), + setSession: vi.fn(), + refreshAgents: vi.fn(), + abortActive: vi.fn(), + setActivityStatus, + formatSessionKey: vi.fn(), + }); + + await handleCommand("/context"); + + expect(addSystem).not.toHaveBeenCalled(); + expect(addUser).toHaveBeenCalledWith("/context"); + expect(sendChat).toHaveBeenCalledWith( + expect.objectContaining({ + sessionKey: "agent:main:main", + message: "/context", + }), + ); + expect(requestRender).toHaveBeenCalled(); + }); +}); diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 79765b5fc..7bedb4d62 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -428,7 +428,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { process.exit(0); break; default: - chatLog.addSystem(`unknown command: /${name}`); + await sendMessage(raw); break; } tui.requestRender(); diff --git a/src/tui/tui-event-handlers.ts b/src/tui/tui-event-handlers.ts index 3f8e2befd..148dca67a 100644 --- a/src/tui/tui-event-handlers.ts +++ b/src/tui/tui-event-handlers.ts @@ -1,6 +1,6 @@ import type { TUI } from "@mariozechner/pi-tui"; import type { ChatLog } from "./components/chat-log.js"; -import { asString } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import { TuiStreamAssembler } from "./tui-stream-assembler.js"; import type { AgentEvent, ChatEvent, TuiStateAccess } from "./tui-types.js"; @@ -49,6 +49,17 @@ export function createEventHandlers(context: EventHandlerContext) { setActivityStatus("streaming"); } if (evt.state === "final") { + if (isCommandMessage(evt.message)) { + const text = extractTextFromMessage(evt.message); + if (text) chatLog.addSystem(text); + streamAssembler.drop(evt.runId); + noteFinalizedRun(evt.runId); + state.activeChatRunId = null; + setActivityStatus("idle"); + void refreshSessionInfo?.(); + tui.requestRender(); + return; + } const stopReason = evt.message && typeof evt.message === "object" && !Array.isArray(evt.message) ? typeof (evt.message as Record).stopReason === "string" diff --git a/src/tui/tui-formatters.test.ts b/src/tui/tui-formatters.test.ts index 541c58727..3200b237a 100644 --- a/src/tui/tui-formatters.test.ts +++ b/src/tui/tui-formatters.test.ts @@ -4,6 +4,7 @@ import { extractContentFromMessage, extractTextFromMessage, extractThinkingFromMessage, + isCommandMessage, } from "./tui-formatters.js"; describe("extractTextFromMessage", () => { @@ -98,3 +99,11 @@ describe("extractContentFromMessage", () => { expect(text).toContain("HTTP 429"); }); }); + +describe("isCommandMessage", () => { + it("detects command-marked messages", () => { + expect(isCommandMessage({ command: true })).toBe(true); + expect(isCommandMessage({ command: false })).toBe(false); + expect(isCommandMessage({})).toBe(false); + }); +}); diff --git a/src/tui/tui-formatters.ts b/src/tui/tui-formatters.ts index 11e8e68c9..f77eb9ff1 100644 --- a/src/tui/tui-formatters.ts +++ b/src/tui/tui-formatters.ts @@ -140,6 +140,11 @@ export function extractTextFromMessage( return formatRawAssistantErrorForUi(errorMessage); } +export function isCommandMessage(message: unknown): boolean { + if (!message || typeof message !== "object") return false; + return (message as Record).command === true; +} + export function formatTokens(total?: number | null, context?: number | null) { if (total == null && context == null) return "tokens ?"; const totalLabel = total == null ? "?" : formatTokenCount(total); diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts index 327363653..5dc6696ad 100644 --- a/src/tui/tui-session-actions.ts +++ b/src/tui/tui-session-actions.ts @@ -6,7 +6,7 @@ import { } from "../routing/session-key.js"; import type { ChatLog } from "./components/chat-log.js"; import type { GatewayAgentsList, GatewayChatClient } from "./gateway-chat.js"; -import { asString, extractTextFromMessage } from "./tui-formatters.js"; +import { asString, extractTextFromMessage, isCommandMessage } from "./tui-formatters.js"; import type { TuiOptions, TuiStateAccess } from "./tui-types.js"; type SessionActionContext = { @@ -161,6 +161,11 @@ export function createSessionActions(context: SessionActionContext) { for (const entry of record.messages ?? []) { if (!entry || typeof entry !== "object") continue; const message = entry as Record; + if (isCommandMessage(message)) { + const text = extractTextFromMessage(message); + if (text) chatLog.addSystem(text); + continue; + } if (message.role === "user") { const text = extractTextFromMessage(message); if (text) chatLog.addUser(text); diff --git a/src/tui/tui.ts b/src/tui/tui.ts index 37e5752e8..cf8341d59 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -245,6 +245,7 @@ export async function runTui(opts: TuiOptions) { editor.setAutocompleteProvider( new CombinedAutocompleteProvider( getSlashCommands({ + cfg: config, provider: sessionInfo.modelProvider, model: sessionInfo.model, }), diff --git a/src/web/auto-reply/deliver-reply.ts b/src/web/auto-reply/deliver-reply.ts index 294589548..2204a9e8f 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/src/web/auto-reply/deliver-reply.ts @@ -1,4 +1,6 @@ import { chunkMarkdownText } from "../../auto-reply/chunk.js"; +import type { MarkdownTableMode } from "../../config/types.base.js"; +import { convertMarkdownTables } from "../../markdown/tables.js"; import type { ReplyPayload } from "../../auto-reply/types.js"; import { logVerbose, shouldLogVerbose } from "../../globals.js"; import { loadWebMedia } from "../media.js"; @@ -19,10 +21,13 @@ export async function deliverWebReply(params: { }; connectionId?: string; skipLog?: boolean; + tableMode?: MarkdownTableMode; }) { const { replyResult, msg, maxMediaBytes, textLimit, replyLogger, connectionId, skipLog } = params; const replyStarted = Date.now(); - const textChunks = chunkMarkdownText(replyResult.text || "", textLimit); + const tableMode = params.tableMode ?? "code"; + const convertedText = convertMarkdownTables(replyResult.text || "", tableMode); + const textChunks = chunkMarkdownText(convertedText, textLimit); const mediaList = replyResult.mediaUrls?.length ? replyResult.mediaUrls : replyResult.mediaUrl diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index ea9895853..c1d280a65 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -28,6 +28,7 @@ import { recordSessionMetaFromInbound, resolveStorePath, } from "../../../config/sessions.js"; +import { resolveMarkdownTableMode } from "../../../config/markdown-tables.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; import type { getChildLogger } from "../../../logging.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; @@ -235,6 +236,11 @@ export async function processMessage(params: { : undefined; const textLimit = params.maxMediaTextChunkLimit ?? resolveTextChunkLimit(params.cfg, "whatsapp"); + const tableMode = resolveMarkdownTableMode({ + cfg: params.cfg, + channel: "whatsapp", + accountId: params.route.accountId, + }); let didLogHeartbeatStrip = false; let didSendReply = false; const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg) @@ -345,6 +351,7 @@ export async function processMessage(params: { connectionId: params.connectionId, // Tool + block updates are noisy; skip their log lines. skipLog: info.kind !== "final", + tableMode, }); didSendReply = true; if (info.kind === "tool") { diff --git a/src/web/outbound.ts b/src/web/outbound.ts index d67abb2a1..0ca867961 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -4,6 +4,9 @@ import { getChildLogger } from "../logging/logger.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { normalizePollInput, type PollInput } from "../polls.js"; import { toWhatsappJid } from "../utils.js"; +import { loadConfig } from "../config/config.js"; +import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; +import { convertMarkdownTables } from "../markdown/tables.js"; import { type ActiveWebSendOptions, requireActiveWebListener } from "./active-listener.js"; import { loadWebMedia } from "./media.js"; @@ -25,6 +28,13 @@ export async function sendMessageWhatsApp( const { listener: active, accountId: resolvedAccountId } = requireActiveWebListener( options.accountId, ); + const cfg = loadConfig(); + const tableMode = resolveMarkdownTableMode({ + cfg, + channel: "whatsapp", + accountId: resolvedAccountId ?? options.accountId, + }); + text = convertMarkdownTables(text ?? "", tableMode); const logger = getChildLogger({ module: "web-outbound", correlationId, diff --git a/test/global-setup.ts b/test/global-setup.ts new file mode 100644 index 000000000..289fd877b --- /dev/null +++ b/test/global-setup.ts @@ -0,0 +1,6 @@ +import { installTestEnv } from "./test-env"; + +export default async () => { + const { cleanup } = installTestEnv(); + return () => cleanup(); +}; diff --git a/test/setup.ts b/test/setup.ts index 6c532b0c2..02cd85ef1 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, vi } from "vitest"; +import { afterAll, afterEach, beforeEach, vi } from "vitest"; import type { ChannelId, @@ -9,11 +9,10 @@ import type { ClawdbotConfig } from "../src/config/config.js"; import type { OutboundSendDeps } from "../src/infra/outbound/deliver.js"; import { setActivePluginRegistry } from "../src/plugins/runtime.js"; import { createTestRegistry } from "../src/test-utils/channel-plugins.js"; -import { installTestEnv } from "./test-env"; - -const { cleanup } = installTestEnv(); -process.on("exit", cleanup); +import { withIsolatedTestHome } from "./test-env"; +const testEnv = withIsolatedTestHome(); +afterAll(() => testEnv.cleanup()); const pickSendFn = (id: ChannelId, deps?: OutboundSendDeps) => { switch (id) { case "discord": diff --git a/test/test-env.ts b/test/test-env.ts index deda32178..838713c52 100644 --- a/test/test-env.ts +++ b/test/test-env.ts @@ -54,6 +54,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { } const restore: RestoreEntry[] = [ + { key: "CLAWDBOT_TEST_FAST", value: process.env.CLAWDBOT_TEST_FAST }, { key: "HOME", value: process.env.HOME }, { key: "USERPROFILE", value: process.env.USERPROFILE }, { key: "XDG_CONFIG_HOME", value: process.env.XDG_CONFIG_HOME }, @@ -84,6 +85,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { process.env.HOME = tempHome; process.env.USERPROFILE = tempHome; process.env.CLAWDBOT_TEST_HOME = tempHome; + process.env.CLAWDBOT_TEST_FAST = "1"; // Ensure test runs never touch the developer's real config/state, even if they have overrides set. delete process.env.CLAWDBOT_CONFIG_PATH; @@ -128,3 +130,7 @@ export function installTestEnv(): { cleanup: () => void; tempHome: string } { return { cleanup, tempHome }; } + +export function withIsolatedTestHome(): { cleanup: () => void; tempHome: string } { + return installTestEnv(); +} diff --git a/ui/src/ui/controllers/exec-approvals.ts b/ui/src/ui/controllers/exec-approvals.ts index 4f59caae2..ba938b9f3 100644 --- a/ui/src/ui/controllers/exec-approvals.ts +++ b/ui/src/ui/controllers/exec-approvals.ts @@ -9,6 +9,7 @@ export type ExecApprovalsDefaults = { }; export type ExecApprovalsAllowlistEntry = { + id?: string; pattern: string; lastUsedAt?: number; lastUsedCommand?: string; diff --git a/vitest.config.ts b/vitest.config.ts index 6628e33f8..210c4092b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -34,6 +34,7 @@ export default defineConfig({ "**/vendor/**", "dist/Clawdbot.app/**", "**/*.live.test.ts", + "**/*.e2e.test.ts", ], coverage: { provider: "v8", diff --git a/vitest.e2e.config.ts b/vitest.e2e.config.ts index 3531e7fe5..ff6d8e94e 100644 --- a/vitest.e2e.config.ts +++ b/vitest.e2e.config.ts @@ -9,7 +9,7 @@ export default defineConfig({ test: { pool: "forks", maxWorkers: e2eWorkers, - include: ["test/**/*.e2e.test.ts"], + include: ["test/**/*.e2e.test.ts", "src/**/*.e2e.test.ts"], setupFiles: ["test/setup.ts"], exclude: [ "dist/**",