Skip to content

Commit 5c82305

Browse files
steipeteDarren2030
andcommitted
refactor(voice-call): centralize persisted status lookup
Co-authored-by: 曾文锋0668000834 <[email protected]>
1 parent 72348d0 commit 5c82305

9 files changed

Lines changed: 156 additions & 86 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai
2020
- **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.
2121
- **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.
2222
- **Codex native hook relay diagnostics:** avoid bridge registry writes before the local relay server begins listening. (#100300) Thanks @nankingjing.
23+
- **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.
2324
- **Agent stop recovery:** prevent late-aborting prompts from reacquiring orphaned session locks after teardown, so `/stop` leaves the conversation ready for the next turn.
2425
- **Message delivery status:** report failed and partially failed best-effort channel delivery instead of returning a success-shaped message-tool result. (#99928) Thanks @masatohoshino.
2526
- **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: 9 additions & 20 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,25 +742,15 @@ describe("voice-call plugin", () => {
741742
expectRedactedVoiceCallStatus(result.details.call);
742743
});
743744

744-
it("tool get_status falls back to the newest persisted snapshot when the call was evicted from memory", async () => {
745-
// History is returned oldest-first (matching the on-disk store order). The
746-
// fallback must resolve the NEWEST matching snapshot, not the first one — a
747-
// forward find() would return the stale ringing record. See #96586.
748-
const ringing = createCallRecord({
749-
callId: "call-1",
750-
providerCallId: "CA123",
751-
state: "ringing",
752-
});
745+
it("tool get_status uses the manager's persisted fallback", async () => {
753746
const completed = createCallRecord({
754747
callId: "call-1",
755748
providerCallId: "CA123",
756749
state: "completed",
757750
endReason: "completed",
758751
endedAt: Date.UTC(2026, 4, 2, 9, 18, 23),
759752
});
760-
runtimeStub.manager.getCall = vi.fn(() => undefined);
761-
runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined);
762-
runtimeStub.manager.getCallHistory = vi.fn(async () => [ringing, completed]);
753+
runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => completed);
763754
const { tools } = setup({ provider: "mock" });
764755
const tool = tools[0] as {
765756
execute: (id: string, params: unknown) => Promise<unknown>;
@@ -768,15 +759,13 @@ describe("voice-call plugin", () => {
768759
action: "get_status",
769760
callId: "call-1",
770761
})) as { details: { found?: boolean; call?: { state?: string } } };
771-
expect(runtimeStub.manager["getCallHistory"]).toHaveBeenCalled();
762+
expect(runtimeStub.manager["getCallFromMemoryOrStore"]).toHaveBeenCalledWith("call-1");
772763
expect(result.details.found).toBe(true);
773764
expect(result.details.call?.state).toBe("completed");
774765
});
775766

776767
it("tool get_status reports found:false when the call is neither active nor persisted", async () => {
777-
runtimeStub.manager.getCall = vi.fn(() => undefined);
778-
runtimeStub.manager.getCallByProviderCallId = vi.fn(() => undefined);
779-
runtimeStub.manager.getCallHistory = vi.fn(async () => []);
768+
runtimeStub.manager.getCallFromMemoryOrStore = vi.fn(async () => undefined);
780769
const { tools } = setup({ provider: "mock" });
781770
const tool = tools[0] as {
782771
execute: (id: string, params: unknown) => Promise<unknown>;

extensions/voice-call/index.ts

Lines changed: 4 additions & 26 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
}
@@ -388,25 +385,6 @@ export default definePluginEntry({
388385
return `call is not active (${details.join(", ")})`;
389386
};
390387

391-
const resolveCallFromMemoryOrStore = async (
392-
rt: VoiceCallRuntime,
393-
callId: string,
394-
): Promise<CallRecord | undefined> => {
395-
const active = rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
396-
if (active) {
397-
return active;
398-
}
399-
// Fall back to the persisted call store. Once a call is evicted from the
400-
// in-memory manager (finalize, gateway restart, or max-duration expiry) it
401-
// is gone from memory even though its full record remains on disk. Pick the
402-
// NEWEST matching snapshot: history is sorted oldest-first, so a forward
403-
// find() would return a stale record. See #96586.
404-
const history = await rt.manager.getCallHistory(100);
405-
return history
406-
.toReversed()
407-
.find((candidate) => candidate.callId === callId || candidate.providerCallId === callId);
408-
};
409-
410388
const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => {
411389
const callId = normalizeOptionalString(params?.callId) ?? "";
412390
const message = normalizeOptionalString(params?.message) ?? "";
@@ -677,7 +655,7 @@ export default definePluginEntry({
677655
});
678656
return;
679657
}
680-
const call = await resolveCallFromMemoryOrStore(rt, raw);
658+
const call = await rt.manager.getCallFromMemoryOrStore(raw);
681659
if (!call) {
682660
respond(true, { found: false });
683661
return;
@@ -809,7 +787,7 @@ export default definePluginEntry({
809787
if (!callId) {
810788
throw new Error("callId required");
811789
}
812-
const call = await resolveCallFromMemoryOrStore(rt, callId);
790+
const call = await rt.manager.getCallFromMemoryOrStore(callId);
813791
return json(
814792
call ? { found: true, call: toVoiceCallStatus(call) } : { found: false },
815793
);
@@ -823,7 +801,7 @@ export default definePluginEntry({
823801
if (!sid) {
824802
throw new Error("sid required for status");
825803
}
826-
const call = await resolveCallFromMemoryOrStore(rt, sid);
804+
const call = await rt.manager.getCallFromMemoryOrStore(sid);
827805
return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false });
828806
}
829807

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

Lines changed: 9 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -129,33 +129,24 @@ describe("voice-call CLI status fallback", () => {
129129
return JSON.parse(capturer.output().trim());
130130
}
131131

132-
it("resolves the newest persisted snapshot when the gateway is unavailable", async () => {
133-
// History is oldest-first; the fallback must pick the newest (completed)
134-
// snapshot, not the stale ringing one. See #96586.
132+
it("uses the manager's persisted fallback when the gateway is unavailable", async () => {
135133
const result = await runStatusWithUnavailableGateway({
136-
getCall: () => undefined,
137-
getCallByProviderCallId: () => undefined,
138134
getActiveCalls: () => [],
139-
getCallHistory: async () => [
140-
{ callId: "call-1", providerCallId: "CA123", state: "ringing" },
141-
{
142-
callId: "call-1",
143-
providerCallId: "CA123",
144-
state: "completed",
145-
endReason: "completed",
146-
endedAt: 1,
147-
},
148-
],
135+
getCallFromMemoryOrStore: async () => ({
136+
callId: "call-1",
137+
providerCallId: "CA123",
138+
state: "completed",
139+
endReason: "completed",
140+
endedAt: 1,
141+
}),
149142
});
150143
expect(result).toMatchObject({ callId: "call-1", state: "completed" });
151144
});
152145

153146
it("reports found:false when the call is neither active nor persisted", async () => {
154147
const result = await runStatusWithUnavailableGateway({
155-
getCall: () => undefined,
156-
getCallByProviderCallId: () => undefined,
157148
getActiveCalls: () => [],
158-
getCallHistory: async () => [],
149+
getCallFromMemoryOrStore: async () => undefined,
159150
});
160151
expect(result).toEqual({ found: false });
161152
});

extensions/voice-call/src/cli.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -733,19 +733,7 @@ export function registerVoiceCallCli(params: {
733733
}
734734
const rt = await ensureRuntime();
735735
if (options.callId) {
736-
const id = options.callId;
737-
const active = rt.manager.getCall(id) || rt.manager.getCallByProviderCallId(id);
738-
let call = active;
739-
if (!call) {
740-
// Gateway is unavailable; fall back to the persisted call store so a
741-
// completed/evicted call is still resolvable. Pick the NEWEST matching
742-
// snapshot — history is oldest-first, so a forward find() would return
743-
// a stale record. Mirrors the gateway/tool status paths. See #96586.
744-
const history = await rt.manager.getCallHistory(100);
745-
call = history
746-
.toReversed()
747-
.find((candidate) => candidate.callId === id || candidate.providerCallId === id);
748-
}
736+
const call = await rt.manager.getCallFromMemoryOrStore(options.callId);
749737
writeStdoutJson(call ?? { found: false });
750738
return;
751739
}

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)