Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 88 additions & 7 deletions src/lib/voice-gateway/session-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,16 +48,19 @@ function serviceFixture(
sessionLifetimeMs?: number;
turnTimeoutMs?: number;
maxResponseBytes?: number;
runtimeIdentity?: string;
runtimeProfile?: string;
agent?: string;
} = {},
) {
const client = overrides.client ?? new FakeAgentClient();
const diagnostics: unknown[] = [];
const ids = [...(overrides.randomIds ?? ["voice-session", "agent-session", "turn", "response"])];
const ids = [...(overrides.randomIds ?? ["voice-session", "turn", "response"])];
const service = new VoiceSessionService({
runtimeIdentity: "voiceclaw-local",
runtimeProfile: "voiceclaw-pinned",
runtimeIdentity: overrides.runtimeIdentity ?? "voiceclaw-local",
runtimeProfile: overrides.runtimeProfile ?? "voiceclaw-pinned",
sandbox: "demo-sandbox",
agent: "main",
agent: overrides.agent ?? "main",
createClient: () => client,
diagnostic: (entry) => diagnostics.push(entry),
randomId: () => ids.shift() ?? "extra-id",
Expand All @@ -71,7 +74,7 @@ function serviceFixture(
}

describe("voice session and committed turn boundary", () => {
it("binds trusted configuration and generates internal agent, turn, and response identities (#8378)", async () => {
it("derives an internal agent session key from the trusted runtime binding (#9411)", async () => {
const { service, client } = serviceFixture();
const created = service.createSession("runtime-conversation");
const events: VoiceResponseEvent[] = [];
Expand All @@ -91,9 +94,10 @@ describe("voice session and committed turn boundary", () => {
{
idempotencyKey: "turn",
message: "repository status",
sessionKey: "agent:main:nemoclaw-voice:agent-session",
sessionKey: expect.stringMatching(/^agent:main:nemoclaw-voice:[A-Za-z0-9_-]{43}$/u),
},
]);
expect(client.calls[0]?.sessionKey).not.toContain("runtime-conversation");
expect(events).toEqual([
{
type: "response.started",
Expand All @@ -119,6 +123,83 @@ describe("voice session and committed turn boundary", () => {
service.closeAll();
});

it("reuses the derived agent session key across separate admissions for one binding (#9411)", async () => {
const { service, client } = serviceFixture({
randomIds: [
"voice-session-one",
"turn-one",
"response-one",
"voice-session-two",
"turn-two",
"response-two",
],
});

const first = service.createSession("runtime-conversation");
await service.commitTurn({
voiceSessionId: first.voiceSessionId,
grant: first.grant,
commitId: "runtime-commit-one",
text: "first question",
deliver: () => {},
deliveryOpen: () => true,
});
service.closeSession(first.voiceSessionId, first.grant);

const second = service.createSession("runtime-conversation");
await service.commitTurn({
voiceSessionId: second.voiceSessionId,
grant: second.grant,
commitId: "runtime-commit-two",
text: "second question",
deliver: () => {},
deliveryOpen: () => true,
});

expect(client.calls).toHaveLength(2);
expect(client.calls[1]?.sessionKey).toBe(client.calls[0]?.sessionKey);
service.closeAll();
});

it("isolates agent session keys when a runtime binding value changes (#9411)", async () => {
async function sessionKeyFor(options: {
runtimeConversationId?: string;
runtimeIdentity?: string;
runtimeProfile?: string;
agent?: string;
}): Promise<string> {
const { service, client } = serviceFixture({
...(options.runtimeIdentity ? { runtimeIdentity: options.runtimeIdentity } : {}),
...(options.runtimeProfile ? { runtimeProfile: options.runtimeProfile } : {}),
...(options.agent ? { agent: options.agent } : {}),
});
const created = service.createSession(
options.runtimeConversationId ?? "runtime-conversation",
);
await service.commitTurn({
voiceSessionId: created.voiceSessionId,
grant: created.grant,
commitId: "runtime-commit",
text: "question",
deliver: () => {},
deliveryOpen: () => true,
});
service.closeAll();
return client.calls[0]?.sessionKey ?? "";
}

const keys = await Promise.all([
sessionKeyFor({}),
sessionKeyFor({ runtimeConversationId: "other-conversation" }),
sessionKeyFor({ runtimeIdentity: "voiceclaw-other" }),
sessionKeyFor({ runtimeProfile: "voiceclaw-other" }),
sessionKeyFor({ agent: "secondary" }),
]);

expect(keys.every((key) => key.length > 0)).toBe(true);
expect(new Set(keys).size).toBe(keys.length);
});

it("rejects duplicate and overlapping runtime commit IDs without another invocation (#8378)", async () => {
const client = new FakeAgentClient();
let resolveRun: (value: { outcome: "completed" }) => void = () => {};
Expand All @@ -130,7 +211,7 @@ describe("voice session and committed turn boundary", () => {
};
const { service } = serviceFixture({
client,
randomIds: ["voice-session", "agent-session", "turn", "response"],
randomIds: ["voice-session", "turn", "response"],
});
const created = service.createSession("runtime-conversation");
const first = service.commitTurn({
Expand Down
16 changes: 15 additions & 1 deletion src/lib/voice-gateway/session-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,20 @@ function grantMatches(value: string, expectedHash: Buffer): boolean {
return timingSafeEqual(hashBearer(value), expectedHash);
}

function deriveAgentSessionKey(
options: Pick<VoiceSessionServiceOptions, "agent" | "runtimeIdentity" | "runtimeProfile">,
runtimeConversationId: string,
): string {
const binding = JSON.stringify([
options.agent,
options.runtimeProfile,
options.runtimeIdentity,
runtimeConversationId,
]);
const bindingHash = createHash("sha256").update(binding).digest("base64url");
return `agent:${options.agent}:nemoclaw-voice:${bindingHash}`;
}

/** Owns one runtime-neutral voice session and its single committed turn. */
export class VoiceSessionService {
private readonly options: Required<
Expand Down Expand Up @@ -113,7 +127,7 @@ export class VoiceSessionService {

const now = this.options.now();
const voiceSessionId = this.options.randomId();
const agentSessionKey = `agent:${this.options.agent}:nemoclaw-voice:${this.options.randomId()}`;
const agentSessionKey = deriveAgentSessionKey(this.options, runtimeConversationId);
const grant = this.options.randomGrant().toString("base64url");
const expiresAt = now + this.options.sessionLifetimeMs;
const session: ActiveSession = {
Expand Down
28 changes: 25 additions & 3 deletions test/fixtures/voice-gateway/pinned-openclaw-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface SentRequest {
readonly params: Record<string, unknown>;
}

/** Emits chat events for pinned OpenClaw v2026.7.1, including a repeated final sequence. */
/** Emits pinned OpenClaw v2026.7.1 chat events for gateway integration tests. */
export class PinnedOpenClawGateway {
onopen: (() => void) | null = null;
onmessage: ((event: { readonly data: unknown }) => void) | null = null;
Expand All @@ -17,7 +17,7 @@ export class PinnedOpenClawGateway {
readonly sent: SentRequest[] = [];
closed = false;

constructor() {
constructor(private readonly conversationContext?: Map<string, string>) {
queueMicrotask(() => this.onopen?.());
}

Expand Down Expand Up @@ -45,7 +45,14 @@ export class PinnedOpenClawGateway {
this.respond(request.id, {});
} else if (request.method === "chat.send") {
this.respond(request.id, { runId: "pinned-openclaw-run" });
queueMicrotask(() => this.emitRecoveredTurn(String(request.params.sessionKey)));
queueMicrotask(() => {
const sessionKey = String(request.params.sessionKey);
if (this.conversationContext) {
this.emitContextTurn(sessionKey, String(request.params.message));
} else {
this.emitRecoveredTurn(sessionKey);
}
});
}
});
}
Expand Down Expand Up @@ -90,6 +97,21 @@ export class PinnedOpenClawGateway {
});
}

private emitContextTurn(sessionKey: string, message: string): void {
let response = this.conversationContext?.get(sessionKey) ?? "I do not know.";
if (message === "My project name is Apollo.") {
response = "I will remember Apollo.";
this.conversationContext?.set(sessionKey, "Apollo");
}
this.chat({
sessionKey,
runId: "pinned-openclaw-run",
seq: 1,
state: "final",
message: this.assistantMessage(response),
});
}

private chat(payload: Record<string, unknown>): void {
this.onmessage?.({
data: JSON.stringify({
Expand Down
Loading
Loading