diff --git a/CHANGELOG.md b/CHANGELOG.md index cb35302e54c1..e445375e4f3f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai - **Unicode and plugin package verification:** match native slice semantics for reversed UTF-16 bounds, and reject published plugin packages that omit `openclaw.plugin.json`. (#100014, #99904) Thanks @Simon-XYDT and @849261680. - **Android invoke cancellation:** preserve coroutine cancellation through camera handlers and the Gateway invoke boundary so cancelled work cannot emit a stale result. (#99916) Thanks @xialonglee. - **Codex native hook relay diagnostics:** avoid bridge registry writes before the local relay server begins listening. (#100300) Thanks @nankingjing. +- **Voice Call completed status:** resolve finalized calls from the full retained event store across Gateway, tool, and CLI status paths while preserving active-call lookup performance. (#99797) Thanks @Darren2030. - **Agent stop recovery:** prevent late-aborting prompts from reacquiring orphaned session locks after teardown, so `/stop` leaves the conversation ready for the next turn. - **Message delivery status:** report failed and partially failed best-effort channel delivery instead of returning a success-shaped message-tool result. (#99928) Thanks @masatohoshino. - **WhatsApp credential recovery:** restore malformed primary auth state from a valid backup during startup. (#99070) Thanks @LeonidasLux. diff --git a/extensions/voice-call/index.test.ts b/extensions/voice-call/index.test.ts index 2bef4003b90b..78c85456bac8 100644 --- a/extensions/voice-call/index.test.ts +++ b/extensions/voice-call/index.test.ts @@ -86,6 +86,7 @@ function createRuntimeStub(callId = "call-1"): VoiceCallRuntime { endCall: vi.fn(async () => ({ success: true })), getCall: vi.fn((id: string) => (id === callId ? call : undefined)), getCallByProviderCallId: vi.fn(() => undefined), + getCallFromMemoryOrStore: vi.fn(async (id: string) => (id === callId ? call : undefined)), getActiveCalls: vi.fn(() => [call]), getCallHistory: vi.fn(async () => []), } as unknown as VoiceCallRuntime["manager"], @@ -621,7 +622,7 @@ describe("voice-call plugin", () => { it("reports ended call history when speaking to a stale call", async () => { runtimeStub.manager.getCall = vi.fn(() => undefined); runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined); - runtimeStub.manager.getCallHistory = vi.fn(async () => [ + runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => createCallRecord({ callId: "call-1", providerCallId: "CA123", @@ -629,7 +630,7 @@ describe("voice-call plugin", () => { endReason: "completed", endedAt: Date.UTC(2026, 4, 2, 9, 18, 23), }), - ]); + ); const { methods } = setup({ provider: "mock" }); const handler = methods.get("voicecall.speak") as | ((ctx: { @@ -652,7 +653,7 @@ describe("voice-call plugin", () => { it("reports stale call history with invalid ended timestamps", async () => { runtimeStub.manager.getCall = vi.fn(() => undefined); runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined); - runtimeStub.manager.getCallHistory = vi.fn(async () => [ + runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => createCallRecord({ callId: "call-1", providerCallId: "CA123", @@ -660,7 +661,7 @@ describe("voice-call plugin", () => { endReason: "completed", endedAt: Number.POSITIVE_INFINITY, }), - ]); + ); const { methods } = setup({ provider: "mock" }); const handler = methods.get("voicecall.speak") as | ((ctx: { @@ -741,6 +742,41 @@ describe("voice-call plugin", () => { expectRedactedVoiceCallStatus(result.details.call); }); + it("tool get_status uses the manager's persisted fallback", async () => { + const completed = createCallRecord({ + callId: "call-1", + providerCallId: "CA123", + state: "completed", + endReason: "completed", + endedAt: Date.UTC(2026, 4, 2, 9, 18, 23), + }); + runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => completed); + const { tools } = setup({ provider: "mock" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise; + }; + const result = (await tool.execute("id", { + action: "get_status", + callId: "call-1", + })) as { details: { found?: boolean; call?: { state?: string } } }; + expect(runtimeStub.manager["getCallFromMemoryOrStore"]).toHaveBeenCalledWith("call-1"); + expect(result.details.found).toBe(true); + expect(result.details.call?.state).toBe("completed"); + }); + + it("tool get_status reports found:false when the call is neither active nor persisted", async () => { + runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => undefined); + const { tools } = setup({ provider: "mock" }); + const tool = tools[0] as { + execute: (id: string, params: unknown) => Promise; + }; + const result = (await tool.execute("id", { + action: "get_status", + callId: "call-1", + })) as { details: { found?: boolean } }; + expect(result.details.found).toBe(false); + }); + it("tool send_dtmf returns json payload", async () => { const { tools } = setup({ provider: "mock" }); const tool = tools[0] as { diff --git a/extensions/voice-call/index.ts b/extensions/voice-call/index.ts index 6a41ddf3de8b..f5a5306f02e3 100644 --- a/extensions/voice-call/index.ts +++ b/extensions/voice-call/index.ts @@ -372,10 +372,7 @@ export default definePluginEntry({ }; const describeHistoricalCall = async (rt: VoiceCallRuntime, callId: string) => { - const history = await rt.manager.getCallHistory(100); - const call = history - .toReversed() - .find((candidate) => candidate.callId === callId || candidate.providerCallId === callId); + const call = await rt.manager.getCallFromMemoryOrStore(callId); if (!call) { return undefined; } @@ -658,7 +655,7 @@ export default definePluginEntry({ }); return; } - const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw); + const call = await rt.manager.getCallFromMemoryOrStore(raw); if (!call) { respond(true, { found: false }); return; @@ -790,8 +787,7 @@ export default definePluginEntry({ if (!callId) { throw new Error("callId required"); } - const call = - rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId); + const call = await rt.manager.getCallFromMemoryOrStore(callId); return json( call ? { found: true, call: toVoiceCallStatus(call) } : { found: false }, ); @@ -805,7 +801,7 @@ export default definePluginEntry({ if (!sid) { throw new Error("sid required for status"); } - const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid); + const call = await rt.manager.getCallFromMemoryOrStore(sid); return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false }); } diff --git a/extensions/voice-call/src/cli.test.ts b/extensions/voice-call/src/cli.test.ts index 3215f60f7927..0bff81a3c46f 100644 --- a/extensions/voice-call/src/cli.test.ts +++ b/extensions/voice-call/src/cli.test.ts @@ -1,7 +1,8 @@ // Voice Call tests cover cli plugin behavior. +import { Command } from "commander"; import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime"; -import { describe, expect, it } from "vitest"; -import { testing } from "./cli.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { registerVoiceCallCli, testing } from "./cli.js"; describe("voice-call CLI gateway fallback", () => { it("treats abnormal local gateway closes as standalone-runtime fallback candidates", () => { @@ -79,3 +80,74 @@ describe("voice-call CLI timeout helpers", () => { expect(testing.readGatewayPollTimeoutMs({ pollTimeoutMs: Number.NaN }, 45_000)).toBe(45_000); }); }); + +function captureStdout() { + let output = ""; + const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + output += String(chunk); + return true; + }) as typeof process.stdout.write); + return { + output: () => output, + restore: () => writeSpy.mockRestore(), + }; +} + +describe("voice-call CLI status fallback", () => { + afterEach(() => { + testing.setCallGatewayFromCliForTests(undefined); + }); + + function buildProgram(manager: Record): Command { + const program = new Command(); + registerVoiceCallCli({ + program, + config: {} as never, + ensureRuntime: async () => ({ manager }) as never, + logger: { info() {}, warn() {}, error() {}, debug() {} } as never, + }); + return program; + } + + async function runStatusWithUnavailableGateway( + manager: Record, + ): Promise { + testing.setCallGatewayFromCliForTests( + vi.fn(async () => { + throw new Error("connect ECONNREFUSED 127.0.0.1:18789"); + }) as never, + ); + const program = buildProgram(manager); + const capturer = captureStdout(); + try { + await program.parseAsync(["voicecall", "status", "--call-id", "call-1", "--json"], { + from: "user", + }); + } finally { + capturer.restore(); + } + return JSON.parse(capturer.output().trim()); + } + + it("uses the manager's persisted fallback when the gateway is unavailable", async () => { + const result = await runStatusWithUnavailableGateway({ + getActiveCalls: () => [], + getCallFromMemoryOrStore: async () => ({ + callId: "call-1", + providerCallId: "CA123", + state: "completed", + endReason: "completed", + endedAt: 1, + }), + }); + expect(result).toMatchObject({ callId: "call-1", state: "completed" }); + }); + + it("reports found:false when the call is neither active nor persisted", async () => { + const result = await runStatusWithUnavailableGateway({ + getActiveCalls: () => [], + getCallFromMemoryOrStore: async () => undefined, + }); + expect(result).toEqual({ found: false }); + }); +}); diff --git a/extensions/voice-call/src/cli.ts b/extensions/voice-call/src/cli.ts index dfabad569cf1..d0920790fae2 100644 --- a/extensions/voice-call/src/cli.ts +++ b/extensions/voice-call/src/cli.ts @@ -733,7 +733,7 @@ export function registerVoiceCallCli(params: { } const rt = await ensureRuntime(); if (options.callId) { - const call = rt.manager.getCall(options.callId); + const call = await rt.manager.getCallFromMemoryOrStore(options.callId); writeStdoutJson(call ?? { found: false }); return; } diff --git a/extensions/voice-call/src/manager.restore.test.ts b/extensions/voice-call/src/manager.restore.test.ts index a7c7a064fab8..babf5913421d 100644 --- a/extensions/voice-call/src/manager.restore.test.ts +++ b/extensions/voice-call/src/manager.restore.test.ts @@ -103,6 +103,53 @@ describe("CallManager verification on restore", () => { expect(manager.getActiveCalls()).toHaveLength(0); }); + it("resolves a terminal call from persisted state after restore", async () => { + const { call, manager } = await initializeManager({ + callOverrides: { state: "completed", endReason: "completed", endedAt: Date.now() }, + }); + + expect(manager.getCall(call.callId as string)).toBeUndefined(); + expect(await manager.getCallFromMemoryOrStore(call.callId as string)).toMatchObject({ + callId: call.callId, + state: "completed", + }); + expect(await manager.getCallFromMemoryOrStore(call.providerCallId as string)).toMatchObject({ + callId: call.callId, + state: "completed", + }); + }); + + it("prefers active provider state before persisted fallback", async () => { + const storePath = createTestStorePath(); + writeCallsToStore(storePath, [ + makePersistedCall({ + callId: "call-target", + providerCallId: "provider-completed", + state: "completed", + endReason: "completed", + endedAt: Date.now(), + }), + makePersistedCall({ + callId: "call-active", + providerCallId: "call-target", + state: "answered", + }), + ]); + const config = VoiceCallConfigSchema.parse({ + enabled: true, + provider: "plivo", + fromNumber: "+15550000000", + }); + const manager = new CallManager(config, storePath); + await manager.initialize(new FakeProvider(), "https://example.com/voice/webhook"); + + expect(manager.getCallByProviderCallId("call-target")?.callId).toBe("call-active"); + expect(await manager.getCallFromMemoryOrStore("call-target")).toMatchObject({ + callId: "call-active", + state: "answered", + }); + }); + it("keeps calls reported active by provider", async () => { const { call, manager } = await initializeManager({ providerResult: { status: "in-progress", isTerminal: false }, diff --git a/extensions/voice-call/src/manager.ts b/extensions/voice-call/src/manager.ts index 2283e691a4c5..505a8ac64614 100644 --- a/extensions/voice-call/src/manager.ts +++ b/extensions/voice-call/src/manager.ts @@ -18,6 +18,7 @@ import { type SpeakOptions, } from "./manager/outbound.js"; import { + findCallMatchesInStore, getCallHistoryFromStore, loadActiveCallsFromStore, persistCallRecord, @@ -460,6 +461,18 @@ export class CallManager { return Array.from(this.activeCalls.values()); } + /** Resolve a status record from active state or the retained event store. */ + async getCallFromMemoryOrStore(callId: CallId): Promise { + const active = this.getCall(callId) ?? this.getCallByProviderCallId(callId); + if (active) { + return active; + } + const persisted = await findCallMatchesInStore(this.storePath, callId); + // Active indexes are canonical for live calls and keep provider-id status + // lookups off the retained-store path. Persisted ids are fallback-only. + return persisted.byCallId ?? persisted.byProviderCallId; + } + /** * Get call history (from persisted logs). */ diff --git a/extensions/voice-call/src/manager/store.test.ts b/extensions/voice-call/src/manager/store.test.ts index ba24f00fff85..dbc59a5699e1 100644 --- a/extensions/voice-call/src/manager/store.test.ts +++ b/extensions/voice-call/src/manager/store.test.ts @@ -15,6 +15,7 @@ import { import { clearVoiceCallStateRuntime, setVoiceCallStateRuntime } from "../runtime-state.js"; import { CallRecordSchema } from "../types.js"; import { + findCallMatchesInStore, flushPendingCallRecordWritesForTest, getCallHistoryFromStore, loadActiveCallsFromStore, @@ -151,4 +152,50 @@ describe("voice-call call record store", () => { const restored = loadActiveCallsFromStore(storePath); expect(restored.activeCalls.get("call-order")?.state).toBe("answered"); }); + + it("finds retained snapshots outside recent history and preserves internal-id precedence", async () => { + const storePath = createTestStorePath(); + persistCallRecord( + storePath, + CallRecordSchema.parse( + makePersistedCall({ callId: "call-target", providerCallId: "provider-target" }), + ), + ); + persistCallRecord( + storePath, + CallRecordSchema.parse( + makePersistedCall({ + callId: "call-target", + providerCallId: "provider-target", + state: "completed", + }), + ), + ); + for (let index = 0; index < 101; index += 1) { + persistCallRecord( + storePath, + CallRecordSchema.parse( + makePersistedCall({ + callId: `noise-${index}`, + providerCallId: index === 100 ? "call-target" : `provider-noise-${index}`, + }), + ), + ); + } + await flushPendingCallRecordWritesForTest(); + + expect(await getCallHistoryFromStore(storePath, 100)).toHaveLength(100); + const internalMatches = await findCallMatchesInStore(storePath, "call-target"); + expect(internalMatches.byCallId).toMatchObject({ + callId: "call-target", + state: "completed", + }); + expect(internalMatches.byProviderCallId).toMatchObject({ callId: "noise-100" }); + + const providerMatches = await findCallMatchesInStore(storePath, "provider-target"); + expect(providerMatches.byProviderCallId).toMatchObject({ + callId: "call-target", + state: "completed", + }); + }); }); diff --git a/extensions/voice-call/src/manager/store.ts b/extensions/voice-call/src/manager/store.ts index abe5fd3e2e00..1e84eb1b4763 100644 --- a/extensions/voice-call/src/manager/store.ts +++ b/extensions/voice-call/src/manager/store.ts @@ -376,6 +376,30 @@ export function loadActiveCallsFromStore(storePath: string): { return { activeCalls, providerCallIdMap, processedEventIds, rejectedProviderCallIds }; } +function readCallHistoryFromStore(storePath: string): CallRecord[] { + const stores = tryCreateCallRecordStateStores(storePath); + if (stores) { + try { + return readCallRecordEvents(stores); + } catch (err) { + console.error("[voice-call] Failed to read SQLite call history:", err); + } + } + return []; +} + +/** Find the newest retained snapshots matching each call identifier namespace. */ +export async function findCallMatchesInStore( + storePath: string, + callId: string, +): Promise<{ byCallId?: CallRecord; byProviderCallId?: CallRecord }> { + const calls = readCallHistoryFromStore(storePath); + return { + byCallId: calls.findLast((call) => call.callId === callId), + byProviderCallId: calls.findLast((call) => call.providerCallId === callId), + }; +} + /** Return the newest persisted call history rows up to the requested limit. */ export async function getCallHistoryFromStore( storePath: string, @@ -384,13 +408,5 @@ export async function getCallHistoryFromStore( if (limit <= 0) { return []; } - const stores = tryCreateCallRecordStateStores(storePath); - if (stores) { - try { - return readCallRecordEvents(stores).slice(-limit); - } catch (err) { - console.error("[voice-call] Failed to read SQLite call history:", err); - } - } - return []; + return readCallHistoryFromStore(storePath).slice(-limit); }