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:
Darren2030
2026-07-06 03:05:01 +08:00
committed by GitHub
parent c730d8f1f1
commit 39b5bf38f7
9 changed files with 252 additions and 24 deletions

View File

@@ -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.

View File

@@ -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 {

View File

@@ -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 });
}

View File

@@ -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 });
});
});

View File

@@ -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;
}

View File

@@ -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 },

View File

@@ -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).
*/

View File

@@ -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",
});
});
});

View File

@@ -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);
}