Skip to content

Commit 39b5bf3

Browse files
Darren2030claudesteipete
authored
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 <[email protected]> * 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 <[email protected]> * 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 <[email protected]> * refactor(voice-call): centralize persisted status lookup Co-authored-by: 曾文锋0668000834 <[email protected]> --------- Co-authored-by: Claude Fable 5 <[email protected]> Co-authored-by: Peter Steinberger <[email protected]>
1 parent c730d8f commit 39b5bf3

9 files changed

Lines changed: 252 additions & 24 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Docs: https://docs.openclaw.ai
2121
- **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.
2222
- **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.
2323
- **Codex native hook relay diagnostics:** avoid bridge registry writes before the local relay server begins listening. (#100300) Thanks @nankingjing.
24+
- **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.
2425
- **Agent stop recovery:** prevent late-aborting prompts from reacquiring orphaned session locks after teardown, so `/stop` leaves the conversation ready for the next turn.
2526
- **Message delivery status:** report failed and partially failed best-effort channel delivery instead of returning a success-shaped message-tool result. (#99928) Thanks @masatohoshino.
2627
- **WhatsApp credential recovery:** restore malformed primary auth state from a valid backup during startup. (#99070) Thanks @LeonidasLux.

extensions/voice-call/index.test.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ function createRuntimeStub(callId = "call-1"): VoiceCallRuntime {
8686
endCall: vi.fn(async () => ({ success: true })),
8787
getCall: vi.fn((id: string) => (id === callId ? call : undefined)),
8888
getCallByProviderCallId: vi.fn(() => undefined),
89+
getCallFromMemoryOrStore: vi.fn(async (id: string) => (id === callId ? call : undefined)),
8990
getActiveCalls: vi.fn(() => [call]),
9091
getCallHistory: vi.fn(async () => []),
9192
} as unknown as VoiceCallRuntime["manager"],
@@ -621,15 +622,15 @@ describe("voice-call plugin", () => {
621622
it("reports ended call history when speaking to a stale call", async () => {
622623
runtimeStub.manager.getCall = vi.fn(() => undefined);
623624
runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined);
624-
runtimeStub.manager.getCallHistory = vi.fn(async () => [
625+
runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () =>
625626
createCallRecord({
626627
callId: "call-1",
627628
providerCallId: "CA123",
628629
state: "completed",
629630
endReason: "completed",
630631
endedAt: Date.UTC(2026, 4, 2, 9, 18, 23),
631632
}),
632-
]);
633+
);
633634
const { methods } = setup({ provider: "mock" });
634635
const handler = methods.get("voicecall.speak") as
635636
| ((ctx: {
@@ -652,15 +653,15 @@ describe("voice-call plugin", () => {
652653
it("reports stale call history with invalid ended timestamps", async () => {
653654
runtimeStub.manager.getCall = vi.fn(() => undefined);
654655
runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined);
655-
runtimeStub.manager.getCallHistory = vi.fn(async () => [
656+
runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () =>
656657
createCallRecord({
657658
callId: "call-1",
658659
providerCallId: "CA123",
659660
state: "completed",
660661
endReason: "completed",
661662
endedAt: Number.POSITIVE_INFINITY,
662663
}),
663-
]);
664+
);
664665
const { methods } = setup({ provider: "mock" });
665666
const handler = methods.get("voicecall.speak") as
666667
| ((ctx: {
@@ -741,6 +742,41 @@ describe("voice-call plugin", () => {
741742
expectRedactedVoiceCallStatus(result.details.call);
742743
});
743744

745+
it("tool get_status uses the manager's persisted fallback", async () => {
746+
const completed = createCallRecord({
747+
callId: "call-1",
748+
providerCallId: "CA123",
749+
state: "completed",
750+
endReason: "completed",
751+
endedAt: Date.UTC(2026, 4, 2, 9, 18, 23),
752+
});
753+
runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => completed);
754+
const { tools } = setup({ provider: "mock" });
755+
const tool = tools[0] as {
756+
execute: (id: string, params: unknown) => Promise<unknown>;
757+
};
758+
const result = (await tool.execute("id", {
759+
action: "get_status",
760+
callId: "call-1",
761+
})) as { details: { found?: boolean; call?: { state?: string } } };
762+
expect(runtimeStub.manager["getCallFromMemoryOrStore"]).toHaveBeenCalledWith("call-1");
763+
expect(result.details.found).toBe(true);
764+
expect(result.details.call?.state).toBe("completed");
765+
});
766+
767+
it("tool get_status reports found:false when the call is neither active nor persisted", async () => {
768+
runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => undefined);
769+
const { tools } = setup({ provider: "mock" });
770+
const tool = tools[0] as {
771+
execute: (id: string, params: unknown) => Promise<unknown>;
772+
};
773+
const result = (await tool.execute("id", {
774+
action: "get_status",
775+
callId: "call-1",
776+
})) as { details: { found?: boolean } };
777+
expect(result.details.found).toBe(false);
778+
});
779+
744780
it("tool send_dtmf returns json payload", async () => {
745781
const { tools } = setup({ provider: "mock" });
746782
const tool = tools[0] as {

extensions/voice-call/index.ts

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -372,10 +372,7 @@ export default definePluginEntry({
372372
};
373373

374374
const describeHistoricalCall = async (rt: VoiceCallRuntime, callId: string) => {
375-
const history = await rt.manager.getCallHistory(100);
376-
const call = history
377-
.toReversed()
378-
.find((candidate) => candidate.callId === callId || candidate.providerCallId === callId);
375+
const call = await rt.manager.getCallFromMemoryOrStore(callId);
379376
if (!call) {
380377
return undefined;
381378
}
@@ -658,7 +655,7 @@ export default definePluginEntry({
658655
});
659656
return;
660657
}
661-
const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
658+
const call = await rt.manager.getCallFromMemoryOrStore(raw);
662659
if (!call) {
663660
respond(true, { found: false });
664661
return;
@@ -790,8 +787,7 @@ export default definePluginEntry({
790787
if (!callId) {
791788
throw new Error("callId required");
792789
}
793-
const call =
794-
rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
790+
const call = await rt.manager.getCallFromMemoryOrStore(callId);
795791
return json(
796792
call ? { found: true, call: toVoiceCallStatus(call) } : { found: false },
797793
);
@@ -805,7 +801,7 @@ export default definePluginEntry({
805801
if (!sid) {
806802
throw new Error("sid required for status");
807803
}
808-
const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
804+
const call = await rt.manager.getCallFromMemoryOrStore(sid);
809805
return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false });
810806
}
811807

extensions/voice-call/src/cli.test.ts

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Voice Call tests cover cli plugin behavior.
2+
import { Command } from "commander";
23
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
3-
import { describe, expect, it } from "vitest";
4-
import { testing } from "./cli.js";
4+
import { afterEach, describe, expect, it, vi } from "vitest";
5+
import { registerVoiceCallCli, testing } from "./cli.js";
56

67
describe("voice-call CLI gateway fallback", () => {
78
it("treats abnormal local gateway closes as standalone-runtime fallback candidates", () => {
@@ -79,3 +80,74 @@ describe("voice-call CLI timeout helpers", () => {
7980
expect(testing.readGatewayPollTimeoutMs({ pollTimeoutMs: Number.NaN }, 45_000)).toBe(45_000);
8081
});
8182
});
83+
84+
function captureStdout() {
85+
let output = "";
86+
const writeSpy = vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => {
87+
output += String(chunk);
88+
return true;
89+
}) as typeof process.stdout.write);
90+
return {
91+
output: () => output,
92+
restore: () => writeSpy.mockRestore(),
93+
};
94+
}
95+
96+
describe("voice-call CLI status fallback", () => {
97+
afterEach(() => {
98+
testing.setCallGatewayFromCliForTests(undefined);
99+
});
100+
101+
function buildProgram(manager: Record<string, unknown>): Command {
102+
const program = new Command();
103+
registerVoiceCallCli({
104+
program,
105+
config: {} as never,
106+
ensureRuntime: async () => ({ manager }) as never,
107+
logger: { info() {}, warn() {}, error() {}, debug() {} } as never,
108+
});
109+
return program;
110+
}
111+
112+
async function runStatusWithUnavailableGateway(
113+
manager: Record<string, unknown>,
114+
): Promise<unknown> {
115+
testing.setCallGatewayFromCliForTests(
116+
vi.fn(async () => {
117+
throw new Error("connect ECONNREFUSED 127.0.0.1:18789");
118+
}) as never,
119+
);
120+
const program = buildProgram(manager);
121+
const capturer = captureStdout();
122+
try {
123+
await program.parseAsync(["voicecall", "status", "--call-id", "call-1", "--json"], {
124+
from: "user",
125+
});
126+
} finally {
127+
capturer.restore();
128+
}
129+
return JSON.parse(capturer.output().trim());
130+
}
131+
132+
it("uses the manager's persisted fallback when the gateway is unavailable", async () => {
133+
const result = await runStatusWithUnavailableGateway({
134+
getActiveCalls: () => [],
135+
getCallFromMemoryOrStore: async () => ({
136+
callId: "call-1",
137+
providerCallId: "CA123",
138+
state: "completed",
139+
endReason: "completed",
140+
endedAt: 1,
141+
}),
142+
});
143+
expect(result).toMatchObject({ callId: "call-1", state: "completed" });
144+
});
145+
146+
it("reports found:false when the call is neither active nor persisted", async () => {
147+
const result = await runStatusWithUnavailableGateway({
148+
getActiveCalls: () => [],
149+
getCallFromMemoryOrStore: async () => undefined,
150+
});
151+
expect(result).toEqual({ found: false });
152+
});
153+
});

extensions/voice-call/src/cli.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -733,7 +733,7 @@ export function registerVoiceCallCli(params: {
733733
}
734734
const rt = await ensureRuntime();
735735
if (options.callId) {
736-
const call = rt.manager.getCall(options.callId);
736+
const call = await rt.manager.getCallFromMemoryOrStore(options.callId);
737737
writeStdoutJson(call ?? { found: false });
738738
return;
739739
}

extensions/voice-call/src/manager.restore.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,53 @@ describe("CallManager verification on restore", () => {
103103
expect(manager.getActiveCalls()).toHaveLength(0);
104104
});
105105

106+
it("resolves a terminal call from persisted state after restore", async () => {
107+
const { call, manager } = await initializeManager({
108+
callOverrides: { state: "completed", endReason: "completed", endedAt: Date.now() },
109+
});
110+
111+
expect(manager.getCall(call.callId as string)).toBeUndefined();
112+
expect(await manager.getCallFromMemoryOrStore(call.callId as string)).toMatchObject({
113+
callId: call.callId,
114+
state: "completed",
115+
});
116+
expect(await manager.getCallFromMemoryOrStore(call.providerCallId as string)).toMatchObject({
117+
callId: call.callId,
118+
state: "completed",
119+
});
120+
});
121+
122+
it("prefers active provider state before persisted fallback", async () => {
123+
const storePath = createTestStorePath();
124+
writeCallsToStore(storePath, [
125+
makePersistedCall({
126+
callId: "call-target",
127+
providerCallId: "provider-completed",
128+
state: "completed",
129+
endReason: "completed",
130+
endedAt: Date.now(),
131+
}),
132+
makePersistedCall({
133+
callId: "call-active",
134+
providerCallId: "call-target",
135+
state: "answered",
136+
}),
137+
]);
138+
const config = VoiceCallConfigSchema.parse({
139+
enabled: true,
140+
provider: "plivo",
141+
fromNumber: "+15550000000",
142+
});
143+
const manager = new CallManager(config, storePath);
144+
await manager.initialize(new FakeProvider(), "https://example.com/voice/webhook");
145+
146+
expect(manager.getCallByProviderCallId("call-target")?.callId).toBe("call-active");
147+
expect(await manager.getCallFromMemoryOrStore("call-target")).toMatchObject({
148+
callId: "call-active",
149+
state: "answered",
150+
});
151+
});
152+
106153
it("keeps calls reported active by provider", async () => {
107154
const { call, manager } = await initializeManager({
108155
providerResult: { status: "in-progress", isTerminal: false },

extensions/voice-call/src/manager.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
type SpeakOptions,
1919
} from "./manager/outbound.js";
2020
import {
21+
findCallMatchesInStore,
2122
getCallHistoryFromStore,
2223
loadActiveCallsFromStore,
2324
persistCallRecord,
@@ -460,6 +461,18 @@ export class CallManager {
460461
return Array.from(this.activeCalls.values());
461462
}
462463

464+
/** Resolve a status record from active state or the retained event store. */
465+
async getCallFromMemoryOrStore(callId: CallId): Promise<CallRecord | undefined> {
466+
const active = this.getCall(callId) ?? this.getCallByProviderCallId(callId);
467+
if (active) {
468+
return active;
469+
}
470+
const persisted = await findCallMatchesInStore(this.storePath, callId);
471+
// Active indexes are canonical for live calls and keep provider-id status
472+
// lookups off the retained-store path. Persisted ids are fallback-only.
473+
return persisted.byCallId ?? persisted.byProviderCallId;
474+
}
475+
463476
/**
464477
* Get call history (from persisted logs).
465478
*/

extensions/voice-call/src/manager/store.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
import { clearVoiceCallStateRuntime, setVoiceCallStateRuntime } from "../runtime-state.js";
1616
import { CallRecordSchema } from "../types.js";
1717
import {
18+
findCallMatchesInStore,
1819
flushPendingCallRecordWritesForTest,
1920
getCallHistoryFromStore,
2021
loadActiveCallsFromStore,
@@ -151,4 +152,50 @@ describe("voice-call call record store", () => {
151152
const restored = loadActiveCallsFromStore(storePath);
152153
expect(restored.activeCalls.get("call-order")?.state).toBe("answered");
153154
});
155+
156+
it("finds retained snapshots outside recent history and preserves internal-id precedence", async () => {
157+
const storePath = createTestStorePath();
158+
persistCallRecord(
159+
storePath,
160+
CallRecordSchema.parse(
161+
makePersistedCall({ callId: "call-target", providerCallId: "provider-target" }),
162+
),
163+
);
164+
persistCallRecord(
165+
storePath,
166+
CallRecordSchema.parse(
167+
makePersistedCall({
168+
callId: "call-target",
169+
providerCallId: "provider-target",
170+
state: "completed",
171+
}),
172+
),
173+
);
174+
for (let index = 0; index < 101; index += 1) {
175+
persistCallRecord(
176+
storePath,
177+
CallRecordSchema.parse(
178+
makePersistedCall({
179+
callId: `noise-${index}`,
180+
providerCallId: index === 100 ? "call-target" : `provider-noise-${index}`,
181+
}),
182+
),
183+
);
184+
}
185+
await flushPendingCallRecordWritesForTest();
186+
187+
expect(await getCallHistoryFromStore(storePath, 100)).toHaveLength(100);
188+
const internalMatches = await findCallMatchesInStore(storePath, "call-target");
189+
expect(internalMatches.byCallId).toMatchObject({
190+
callId: "call-target",
191+
state: "completed",
192+
});
193+
expect(internalMatches.byProviderCallId).toMatchObject({ callId: "noise-100" });
194+
195+
const providerMatches = await findCallMatchesInStore(storePath, "provider-target");
196+
expect(providerMatches.byProviderCallId).toMatchObject({
197+
callId: "call-target",
198+
state: "completed",
199+
});
200+
});
154201
});

0 commit comments

Comments
 (0)