mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 22:17:00 +00:00
fix(voice-call): resolve completed calls from the persisted store on status misses (#99797)
* fix(voice-call): resolve completed calls from the persisted store on status misses
get_status, the legacy status mode, and the voicecall.status gateway method
only consulted the in-memory call manager. Once a call was evicted (finalize,
gateway restart, or max-duration expiry) they reported { found: false } even
though the full record remained on disk. Fall back to the persisted call
history and resolve the NEWEST matching snapshot — history is oldest-first, so
a forward find() returns a stale record (the regression in the prior attempt).
Closes #96586
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* test(voice-call): use bracket access for mocked getCallHistory assertion
Avoids the typescript(unbound-method) lint rule that flags referencing a
typed method (`runtimeStub.manager.getCallHistory`) as an unbound value.
Bracket access matches the existing mock-assertion pattern in this file
(e.g. `runtimeStub.manager["sendDtmf"]`).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* ci: re-trigger QA Smoke (transient build-OOM, also failing main run 28695162780)
* fix(voice-call): resolve persisted calls in the CLI status fallback too
The local CLI `voicecall status` gateway-unavailable fallback only consulted
the in-memory manager, so a completed/evicted call still returned
{ found: false } even though the gateway/tool/legacy status paths now fall
back to the persisted store. Align this fourth status reader: consult
getCallByProviderCallId in addition to getCall, and on an active miss resolve
the NEWEST matching persisted snapshot via getCallHistory(100) +
toReversed().find(...) (history is oldest-first), mirroring the gateway/tool
paths. Return shape is unchanged.
Per review on #96586 (align CLI status before merge).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* refactor(voice-call): centralize persisted status lookup
Co-authored-by: 曾文锋0668000834 <zeng.wenfeng@xydigit.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<unknown>;
|
||||
};
|
||||
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<unknown>;
|
||||
};
|
||||
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 {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, unknown>): 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<string, unknown>,
|
||||
): Promise<unknown> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<CallRecord | undefined> {
|
||||
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).
|
||||
*/
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user