Skip to content

Commit d4d370f

Browse files
fix(voice-call): fall back to persisted call store on status misses (#96586)
1 parent 72f837a commit d4d370f

4 files changed

Lines changed: 127 additions & 4 deletions

File tree

extensions/voice-call/index.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -387,6 +387,23 @@ export default definePluginEntry({
387387
return `call is not active (${details.join(", ")})`;
388388
};
389389

390+
const findCallWithPersistedFallback = async (
391+
rt: VoiceCallRuntime,
392+
callId: string,
393+
) => {
394+
const active = rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
395+
if (active) {
396+
return active;
397+
}
398+
// Fall back to the persisted call store when the in-memory manager has
399+
// evicted a completed call (issue #96586).
400+
return (
401+
(await rt.manager.getPersistedCallByCallId(callId)) ??
402+
(await rt.manager.getPersistedCallByProviderCallId(callId)) ??
403+
null
404+
);
405+
};
406+
390407
const resolveCallMessageRequest = async (params: GatewayRequestHandlerOptions["params"]) => {
391408
const callId = normalizeOptionalString(params?.callId) ?? "";
392409
const message = normalizeOptionalString(params?.message) ?? "";
@@ -657,7 +674,7 @@ export default definePluginEntry({
657674
});
658675
return;
659676
}
660-
const call = rt.manager.getCall(raw) || rt.manager.getCallByProviderCallId(raw);
677+
const call = await findCallWithPersistedFallback(rt, raw);
661678
if (!call) {
662679
respond(true, { found: false });
663680
return;
@@ -794,8 +811,7 @@ export default definePluginEntry({
794811
if (!callId) {
795812
throw new Error("callId required");
796813
}
797-
const call =
798-
rt.manager.getCall(callId) || rt.manager.getCallByProviderCallId(callId);
814+
const call = await findCallWithPersistedFallback(rt, callId);
799815
return json(
800816
call ? { found: true, call: toVoiceCallStatus(call) } : { found: false },
801817
);
@@ -809,7 +825,7 @@ export default definePluginEntry({
809825
if (!sid) {
810826
throw new Error("sid required for status");
811827
}
812-
const call = rt.manager.getCall(sid) || rt.manager.getCallByProviderCallId(sid);
828+
const call = await findCallWithPersistedFallback(rt, sid);
813829
return json(call ? { found: true, call: toVoiceCallStatus(call) } : { found: false });
814830
}
815831

extensions/voice-call/src/manager.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import {
1818
} from "./manager/outbound.js";
1919
import {
2020
getCallHistoryFromStore,
21+
getPersistedCallByCallId,
22+
getPersistedCallByProviderCallId,
2123
loadActiveCallsFromStore,
2224
persistCallRecord,
2325
} from "./manager/store.js";
@@ -448,6 +450,23 @@ export class CallManager {
448450
});
449451
}
450452

453+
/**
454+
* Find a persisted call record by call id, falling back to the on-disk store
455+
* when the in-memory manager has evicted the call (issue #96586).
456+
*/
457+
async getPersistedCallByCallId(callId: string): Promise<CallRecord | null> {
458+
return getPersistedCallByCallId(this.storePath, callId);
459+
}
460+
461+
/**
462+
* Find a persisted call record by provider call id (e.g. Twilio CallSid),
463+
* falling back to the on-disk store when the in-memory manager has evicted
464+
* the call (issue #96586).
465+
*/
466+
async getPersistedCallByProviderCallId(providerCallId: string): Promise<CallRecord | null> {
467+
return getPersistedCallByProviderCallId(this.storePath, providerCallId);
468+
}
469+
451470
/**
452471
* Get all active calls.
453472
*/

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

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ import { CallRecordSchema } from "../types.js";
1717
import {
1818
flushPendingCallRecordWritesForTest,
1919
getCallHistoryFromStore,
20+
getPersistedCallByCallId,
21+
getPersistedCallByProviderCallId,
2022
loadActiveCallsFromStore,
2123
persistCallRecord,
2224
} from "./store.js";
@@ -38,6 +40,43 @@ function installStateRuntime(): void {
3840
}
3941

4042
describe("voice-call call record store", () => {
43+
44+
it("finds a persisted call record by call id (fallback for #96586)", async () => {
45+
const storePath = createTestStorePath();
46+
const call = CallRecordSchema.parse(
47+
makePersistedCall({ callId: "call-by-id", providerCallId: "SID-by-id" }),
48+
);
49+
persistCallRecord(storePath, call);
50+
await flushPendingCallRecordWritesForTest();
51+
52+
const found = await getPersistedCallByCallId(storePath, "call-by-id");
53+
expect(found?.callId).toBe("call-by-id");
54+
});
55+
56+
it("returns null when no persisted call matches the call id", async () => {
57+
const storePath = createTestStorePath();
58+
const found = await getPersistedCallByCallId(storePath, "missing-call-id");
59+
expect(found).toBeNull();
60+
});
61+
62+
it("finds a persisted call record by provider call id (fallback for #96586)", async () => {
63+
const storePath = createTestStorePath();
64+
const call = CallRecordSchema.parse(
65+
makePersistedCall({ callId: "call-by-provider", providerCallId: "SID-by-provider" }),
66+
);
67+
persistCallRecord(storePath, call);
68+
await flushPendingCallRecordWritesForTest();
69+
70+
const found = await getPersistedCallByProviderCallId(storePath, "SID-by-provider");
71+
expect(found?.providerCallId).toBe("SID-by-provider");
72+
});
73+
74+
it("returns null when no persisted call matches the provider call id", async () => {
75+
const storePath = createTestStorePath();
76+
const found = await getPersistedCallByProviderCallId(storePath, "missing-sid");
77+
expect(found).toBeNull();
78+
});
79+
4180
beforeEach(() => {
4281
resetPluginStateStoreForTests();
4382
installStateRuntime();

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

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -394,3 +394,52 @@ export async function getCallHistoryFromStore(
394394
}
395395
return [];
396396
}
397+
398+
/**
399+
* Find a persisted call record by call id. Used as a fallback when the
400+
* in-memory manager has evicted a completed call but its transcript is still
401+
* stored (see issue #96586).
402+
*/
403+
export async function getPersistedCallByCallId(
404+
storePath: string,
405+
callId: string,
406+
): Promise<CallRecord | null> {
407+
if (!callId) {
408+
return null;
409+
}
410+
const stores = tryCreateCallRecordStateStores(storePath);
411+
if (!stores) {
412+
return null;
413+
}
414+
try {
415+
const events = readCallRecordEvents(stores);
416+
return events.find((call) => call.callId === callId) ?? null;
417+
} catch (err) {
418+
console.error("[voice-call] Failed to read persisted call by callId:", err);
419+
return null;
420+
}
421+
}
422+
423+
/**
424+
* Find a persisted call record by provider call id (e.g. Twilio CallSid).
425+
* Fallback for status lookups once the in-memory manager evicts the call.
426+
*/
427+
export async function getPersistedCallByProviderCallId(
428+
storePath: string,
429+
providerCallId: string,
430+
): Promise<CallRecord | null> {
431+
if (!providerCallId) {
432+
return null;
433+
}
434+
const stores = tryCreateCallRecordStateStores(storePath);
435+
if (!stores) {
436+
return null;
437+
}
438+
try {
439+
const events = readCallRecordEvents(stores);
440+
return events.find((call) => call.providerCallId === providerCallId) ?? null;
441+
} catch (err) {
442+
console.error("[voice-call] Failed to read persisted call by providerCallId:", err);
443+
return null;
444+
}
445+
}

0 commit comments

Comments
 (0)