diff --git a/CHANGELOG.md b/CHANGELOG.md index 6963942864..2a157fe19b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Voice-call: auto-end calls when media streams disconnect to prevent stuck active calls. (#18435) Thanks @JayMishra-source. - Gateway/Channels: wire `gateway.channelHealthCheckMinutes` into strict config validation, treat implicit account status as managed for health checks, and harden channel auto-restart flow (preserve restart-attempt caps across crash loops, propagate enabled/configured runtime flags, and stop pending restart backoff after manual stop). Thanks @steipete. - Gateway/WebChat: hard-cap `chat.history` oversized payloads by truncating high-cost fields and replacing over-budget entries with placeholders, so history fetches stay within configured byte limits and avoid chat UI freezes. (#18505) - UI/Usage: replace lingering undefined `var(--text-muted)` usage with `var(--muted)` in usage date-range and chart styles to keep muted text visible across themes. (#17975) Thanks @jogelin. @@ -52,6 +53,7 @@ Docs: https://docs.openclaw.ai - CLI/Doctor: auto-repair `dmPolicy="open"` configs missing wildcard allowlists and write channel-correct repair paths (including `channels.googlechat.dm.allowFrom`) so `openclaw doctor --fix` no longer leaves Google Chat configs invalid after attempted repair. (#18544) - CLI/Doctor: detect gateway service token drift when the gateway token is only provided via environment variables, keeping service repairs aligned after token rotation. - CLI/Status: fix `openclaw status --all` token summaries for bot-token-only channels so Mattermost/Zalo no longer show a bot+app warning. (#18527) Thanks @echo931. +- Voice Call: add an optional stale call reaper (`staleCallReaperSeconds`) to end stuck calls when enabled. (#18437) - Auto-reply/Subagents: propagate group context (`groupId`, `groupChannel`, `space`) when spawning via `/subagents spawn`, matching tool-triggered subagent spawn behavior. - Agents/Tools/exec: add a preflight guard that detects likely shell env var injection (e.g. `$DM_JSON`, `$TMPDIR`) in Python/Node scripts before execution, preventing recurring cron failures and wasted tokens when models emit mixed shell+language source. (#12836) - Agents/Tools: make loop detection progress-aware and phased by hard-blocking known `process(action=poll|log)` no-progress loops, warning on generic identical-call repeats, warning + no-progress-blocking ping-pong alternation loops (10/20), coalescing repeated warning spam into threshold buckets (including canonical ping-pong pairs), adding a global circuit breaker at 30 no-progress repeats, and emitting structured diagnostic `tool.loop` warning/error events for loop actions. (#16808) Thanks @akramcodez and @beca-oc. @@ -66,6 +68,7 @@ Docs: https://docs.openclaw.ai - Gateway/Config: prevent `config.patch` object-array merges from falling back to full-array replacement when some patch entries lack `id`, so partial `agents.list` updates no longer drop unrelated agents. (#17989) Thanks @stakeswky. - Config/Discord: require string IDs in Discord allowlists, keep onboarding inputs string-only, and add doctor repair for numeric entries. (#18220) Thanks @thewilloftheshadow. - Security/Sessions: create new session transcript JSONL files with user-only (`0o600`) permissions and extend `openclaw security audit --fix` to remediate existing transcript file permissions. +- Sessions/Maintenance: archive transcripts when pruning stale sessions, clean expired media in subdirectories, and purge old deleted transcript archives to prevent disk leaks. (#18538) - Infra/Fetch: ensure foreign abort-signal listener cleanup never masks original fetch successes/failures, while still preventing detached-finally unhandled rejection noise in `wrapFetchWithAbortSignal`. Thanks @Jackten. - Heartbeat: allow suppressing tool error warning payloads during heartbeat runs via a new heartbeat config flag. (#18497) Thanks @thewilloftheshadow. - Heartbeat/Telegram: strip configured `responsePrefix` before heartbeat ack detection (with boundary-safe matching) so prefixed `HEARTBEAT_OK` replies are correctly suppressed instead of leaking into DMs. (#18602) diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts new file mode 100644 index 0000000000..e925872c16 --- /dev/null +++ b/extensions/voice-call/src/webhook.test.ts @@ -0,0 +1,100 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { CallManager } from "./manager.js"; +import type { VoiceCallProvider } from "./providers/base.js"; +import type { CallRecord } from "./types.js"; +import { VoiceCallConfigSchema, type VoiceCallConfig } from "./config.js"; +import { VoiceCallWebhookServer } from "./webhook.js"; + +const provider: VoiceCallProvider = { + name: "mock", + verifyWebhook: () => ({ ok: true }), + parseWebhookEvent: () => ({ events: [] }), + initiateCall: async () => ({ providerCallId: "provider-call" }), + hangupCall: async () => {}, + playTts: async () => {}, + startListening: async () => {}, + stopListening: async () => {}, +}; + +const createConfig = (overrides: Partial = {}): VoiceCallConfig => { + const base = VoiceCallConfigSchema.parse({}); + base.serve.port = 0; + + return { + ...base, + ...overrides, + serve: { + ...base.serve, + ...(overrides.serve ?? {}), + }, + }; +}; + +const createCall = (startedAt: number): CallRecord => ({ + callId: "call-1", + providerCallId: "provider-call-1", + provider: "mock", + direction: "outbound", + state: "initiated", + from: "+15550001234", + to: "+15550005678", + startedAt, + transcript: [], + processedEventIds: [], +}); + +const createManager = (calls: CallRecord[]) => { + const endCall = vi.fn(async () => ({ success: true })); + const manager = { + getActiveCalls: () => calls, + endCall, + } as unknown as CallManager; + + return { manager, endCall }; +}; + +describe("VoiceCallWebhookServer stale call reaper", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("ends calls older than staleCallReaperSeconds", async () => { + const now = new Date("2026-02-16T00:00:00Z"); + vi.setSystemTime(now); + + const call = createCall(now.getTime() - 120_000); + const { manager, endCall } = createManager([call]); + const config = createConfig({ staleCallReaperSeconds: 60 }); + const server = new VoiceCallWebhookServer(config, manager, provider); + + try { + await server.start(); + await vi.advanceTimersByTimeAsync(30_000); + expect(endCall).toHaveBeenCalledWith(call.callId); + } finally { + await server.stop(); + } + }); + + it("does not run when staleCallReaperSeconds is disabled", async () => { + const now = new Date("2026-02-16T00:00:00Z"); + vi.setSystemTime(now); + + const call = createCall(now.getTime() - 120_000); + const { manager, endCall } = createManager([call]); + const config = createConfig({ staleCallReaperSeconds: 0 }); + const server = new VoiceCallWebhookServer(config, manager, provider); + + try { + await server.start(); + await vi.advanceTimersByTimeAsync(60_000); + expect(endCall).not.toHaveBeenCalled(); + } finally { + await server.stop(); + } + }); +});