Skip to content

Commit a2245ed

Browse files
fix(voice-call): emit canonical session keys
1 parent d0f7c8f commit a2245ed

7 files changed

Lines changed: 47 additions & 18 deletions

File tree

docs/plugins/voice-call.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,11 @@ each carrier call should start with fresh context, for example reception,
211211
booking, IVR, or Google Meet bridge flows where the same phone number may
212212
represent different meetings.
213213

214+
Generated session keys are scoped under the configured voice agent, for example
215+
`agent:main:voice:15550001234` for per-phone sessions or
216+
`agent:main:voice:call:<call-id>` for per-call sessions. Explicit session keys
217+
provided by an inbound route or outbound call request are preserved as-is.
218+
214219
## Realtime voice conversations
215220

216221
`realtime` selects a full-duplex realtime voice provider for live call
@@ -233,7 +238,7 @@ Current runtime behaviour:
233238
- `realtime.agentContext.enabled` is default-off. When enabled, Voice Call injects a bounded agent identity, system prompt override, and selected workspace-file capsule into the realtime provider instructions at session setup.
234239
- `realtime.fastContext.enabled` is default-off. When enabled, Voice Call first searches indexed memory/session context for the consult question and returns those snippets to the realtime model within `realtime.fastContext.timeoutMs` before falling back to the full consult agent only if `realtime.fastContext.fallbackToConsult` is true.
235240
- If `realtime.provider` points at an unregistered provider, or no realtime voice provider is registered at all, Voice Call logs a warning and skips realtime media instead of failing the whole plugin.
236-
- Consult session keys reuse the stored call session when available, then fall back to the configured `sessionScope` (`per-phone` by default, or `per-call` for isolated calls).
241+
- Consult session keys reuse the stored call session when available, then fall back to the agent-scoped configured `sessionScope` (`per-phone` by default, or `per-call` for isolated calls).
237242

238243
### Tool policy
239244

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

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -295,7 +295,23 @@ describe("resolveVoiceCallConfig session routing", () => {
295295
callId: "call-123",
296296
phone: "+1 (555) 000-1111",
297297
}),
298-
).toBe("voice:15550001111");
298+
).toBe("agent:main:voice:15550001111");
299+
});
300+
301+
it("scopes voice sessions by configured agent id", () => {
302+
const config = resolveVoiceCallConfig({
303+
enabled: true,
304+
provider: "mock",
305+
agentId: "Cards",
306+
});
307+
308+
expect(
309+
resolveVoiceCallSessionKey({
310+
config,
311+
callId: "call-123",
312+
phone: "+1 (555) 000-1111",
313+
}),
314+
).toBe("agent:cards:voice:15550001111");
299315
});
300316

301317
it("can scope voice sessions to each call", () => {
@@ -312,7 +328,7 @@ describe("resolveVoiceCallConfig session routing", () => {
312328
callId: "call-123",
313329
phone: "+1 (555) 000-1111",
314330
}),
315-
).toBe("voice:call:call-123");
331+
).toBe("agent:main:voice:call:call-123");
316332
});
317333

318334
it("preserves explicit voice session keys", () => {

extensions/voice-call/src/config.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { REALTIME_VOICE_AGENT_CONSULT_TOOL_POLICIES } from "openclaw/plugin-sdk/realtime-voice";
2+
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
23
import {
34
buildSecretInputSchema,
45
hasConfiguredSecretInput,
@@ -717,7 +718,7 @@ export function normalizeVoiceCallConfig(config: VoiceCallConfigInput): VoiceCal
717718
}
718719

719720
export function resolveVoiceCallSessionKey(params: {
720-
config: Pick<VoiceCallConfig, "sessionScope">;
721+
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
721722
callId: string;
722723
phone?: string;
723724
explicitSessionKey?: string;
@@ -726,11 +727,14 @@ export function resolveVoiceCallSessionKey(params: {
726727
if (explicit) {
727728
return explicit;
728729
}
730+
const prefix = `agent:${normalizeAgentId(params.config.agentId)}:voice`;
729731
if (params.config.sessionScope === "per-call") {
730-
return `voice:call:${params.callId}`;
732+
return `${prefix}:call:${params.callId}`.toLowerCase();
731733
}
732734
const normalizedPhone = params.phone?.replace(/\D/g, "");
733-
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
735+
return (
736+
normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`
737+
).toLowerCase();
734738
}
735739

736740
/**

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -451,7 +451,7 @@ describe("processEvent (functional)", () => {
451451
processEvent(ctx, event);
452452

453453
const call = requireFirstActiveCall(ctx);
454-
expect(call.sessionKey).toBe(`voice:call:${call.callId}`);
454+
expect(call.sessionKey).toBe(`agent:main:voice:call:${call.callId}`);
455455
});
456456

457457
it("applies per-number inbound greeting and stores the matched route key", () => {

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

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ describe("voice-call outbound helpers", () => {
194194
expect(result.success).toBe(true);
195195
expect(result.callId).toBeTypeOf("string");
196196
expect(result.callId).not.toBe("");
197-
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(`voice:call:${result.callId}`);
197+
expect(ctx.activeCalls.get(result.callId)?.sessionKey).toBe(
198+
`agent:main:voice:call:${result.callId}`,
199+
);
198200
});
199201

200202
it("initiates conversation calls with pre-connect DTMF TwiML", async () => {

extensions/voice-call/src/response-generator.test.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ describe("generateVoiceResponse", () => {
206206
});
207207

208208
expect(result.text).toBe("Pinned model works.");
209-
const pinnedSessionEntry = sessionStore["voice:15550001111"];
209+
const pinnedSessionEntry = sessionStore["agent:main:voice:15550001111"];
210210
expect(pinnedSessionEntry?.providerOverride).toBe("openai");
211211
expect(pinnedSessionEntry?.modelOverride).toBe("gpt-4.1-nano");
212212
expect(pinnedSessionEntry?.modelOverrideSource).toBe("auto");
@@ -219,7 +219,7 @@ describe("generateVoiceResponse", () => {
219219
const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent);
220220
expect(args.provider).toBe("openai");
221221
expect(args.model).toBe("gpt-4.1-nano");
222-
expect(args.sessionKey).toBe("voice:15550001111");
222+
expect(args.sessionKey).toBe("agent:main:voice:15550001111");
223223
});
224224

225225
it("uses the persisted per-call session key for classic responses", async () => {
@@ -246,7 +246,7 @@ describe("generateVoiceResponse", () => {
246246
const perCallSessionEntry = sessionStore["voice:call:call-123"];
247247
expect(perCallSessionEntry?.sessionId).toBeTypeOf("string");
248248
expect(perCallSessionEntry?.sessionId).not.toBe("");
249-
expect(sessionStore["voice:15550001111"]).toBeUndefined();
249+
expect(sessionStore["agent:main:voice:15550001111"]).toBeUndefined();
250250
const args = requireEmbeddedAgentArgs(runEmbeddedPiAgent);
251251
expect(args.sessionKey).toBe("voice:call:call-123");
252252
expect(args.sandboxSessionKey).toBe("agent:main:voice:call:call-123");
@@ -279,7 +279,7 @@ describe("generateVoiceResponse", () => {
279279
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "main");
280280
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "main");
281281
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "main");
282-
const defaultSessionEntry = sessionStore["voice:15550001111"];
282+
const defaultSessionEntry = sessionStore["agent:main:voice:15550001111"];
283283
if (!defaultSessionEntry) {
284284
throw new Error("Expected default voice session entry");
285285
}
@@ -329,7 +329,7 @@ describe("generateVoiceResponse", () => {
329329
expect(resolveAgentDir).toHaveBeenCalledWith(coreConfig, "voice");
330330
expect(resolveAgentWorkspaceDir).toHaveBeenCalledWith(coreConfig, "voice");
331331
expect(resolveAgentIdentity).toHaveBeenCalledWith(coreConfig, "voice");
332-
const voiceSessionEntry = sessionStore["voice:15550001111"];
332+
const voiceSessionEntry = sessionStore["agent:voice:voice:15550001111"];
333333
if (!voiceSessionEntry) {
334334
throw new Error("Expected routed voice session entry");
335335
}

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const mocks = vi.hoisted(() => ({
2828

2929
vi.mock("./config.js", () => ({
3030
resolveVoiceCallSessionKey: (params: {
31-
config: Pick<VoiceCallConfig, "sessionScope">;
31+
config: Pick<VoiceCallConfig, "agentId" | "sessionScope">;
3232
callId: string;
3333
phone?: string;
3434
explicitSessionKey?: string;
@@ -37,11 +37,13 @@ vi.mock("./config.js", () => ({
3737
if (explicit) {
3838
return explicit;
3939
}
40+
const agentId = params.config.agentId?.trim().toLowerCase() || "main";
41+
const prefix = `agent:${agentId}:voice`;
4042
if (params.config.sessionScope === "per-call") {
41-
return `voice:call:${params.callId}`;
43+
return `${prefix}:call:${params.callId}`;
4244
}
4345
const normalizedPhone = params.phone?.replace(/\D/g, "");
44-
return normalizedPhone ? `voice:${normalizedPhone}` : `voice:${params.callId}`;
46+
return normalizedPhone ? `${prefix}:${normalizedPhone}` : `${prefix}:${params.callId}`;
4547
},
4648
resolveVoiceCallEffectiveConfig: (config: VoiceCallConfig) => ({ config }),
4749
resolveVoiceCallConfig: mocks.resolveVoiceCallConfig,
@@ -413,7 +415,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
413415
firstCallParam(runEmbeddedPiAgent.mock.calls as unknown[][], "embedded PI consult"),
414416
"embedded PI consult params",
415417
);
416-
expect(consultParams.sessionKey).toBe("voice:15550009999");
418+
expect(consultParams.sessionKey).toBe("agent:main:voice:15550009999");
417419
expect(consultParams.spawnedBy).toBe("agent:main:discord:channel:general");
418420
expect(consultParams.messageProvider).toBe("voice");
419421
expect(consultParams.lane).toBe("voice");
@@ -558,7 +560,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
558560
error: console.error,
559561
debug: console.debug,
560562
},
561-
sessionKey: "voice:15550001234",
563+
sessionKey: "agent:main:voice:15550001234",
562564
});
563565
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
564566
});

0 commit comments

Comments
 (0)