fix(discord): reuse stored sessionId across voice (stt-tts) turns (#97746)

* fix(discord): reuse stored sessionId across voice (stt-tts) turns

Discord voice turns previously called agentCommandFromIngress without a
sessionId, so the core session resolver minted a fresh UUID per turn.
Because the codex app-server thread is bound beside the per-session
file, every voice turn produced a new codex thread, a brand-new
rollout-*.jsonl, and lost all prior context.

The fix reads the persisted session entry for the route's sessionKey
(the same supervisor entry already used for the route) and threads that
sessionId into agentCommandFromIngress, mirroring how voice-call's
response-generator and the gateway 'voice.transcript' dispatcher already
keep voice conversations stable.

Adds extensions/discord/src/voice/ingress.test.ts covering both
directions: reuses the stored id when present, and lets core mint a
fresh one when the store is empty.

Fixes #97688

* fix(discord): preserve voice session after rollover

* refactor(discord): keep voice session ownership in core

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Sanjay Santhanam
2026-07-05 00:00:08 -07:00
committed by GitHub
parent 009b00f7ba
commit 6cc534bbad
3 changed files with 70 additions and 17 deletions

View File

@@ -103,7 +103,7 @@ import {
resolveInternalEventTranscriptBody,
} from "./command/attempt-execution.shared.js";
import { resolveAgentRunContext } from "./command/run-context.js";
import { resolveSession } from "./command/session.js";
import { clearRotatedSessionMetadata, resolveSession } from "./command/session.js";
import type { AgentCommandIngressOpts, AgentCommandOpts } from "./command/types.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
@@ -953,8 +953,12 @@ async function agentCommandInternal(
const currentStoreEntry = sessionStore[sessionKey];
const allowCreateRestartRecoveryEntry =
currentStoreEntry === undefined && sessionEntry === undefined;
const entry = currentStoreEntry ??
const initialEntry = currentStoreEntry ??
sessionEntry ?? { sessionId, updatedAt: now, sessionStartedAt: now };
const isSessionRollover = isNewSession && initialEntry.sessionId !== sessionId;
const entry = isSessionRollover
? clearRotatedSessionMetadata(initialEntry)
: initialEntry;
currentRunDeliveryContext = await resolveCurrentRunDeliveryContext({
cfg,
opts,
@@ -965,6 +969,8 @@ async function agentCommandInternal(
...entry,
sessionId,
updatedAt: now,
sessionStartedAt: isSessionRollover ? now : entry.sessionStartedAt,
lastInteractionAt: isSessionRollover ? now : entry.lastInteractionAt,
restartRecoveryDeliveryContext: currentRunDeliveryContext,
restartRecoveryDeliveryRunId: currentRunDeliveryContext ? runId : undefined,
};
@@ -972,15 +978,17 @@ async function agentCommandInternal(
sessionStore,
sessionKey,
storePath,
initialEntry: entry,
initialEntry,
entry: next,
shouldPersist: (current) =>
shouldPersistRestartRecoveryContextClaim(
current,
sessionId,
runId,
allowCreateRestartRecoveryEntry,
),
isSessionRollover
? current?.sessionId === initialEntry.sessionId
: shouldPersistRestartRecoveryContextClaim(
current,
sessionId,
runId,
allowCreateRestartRecoveryEntry,
),
});
sessionEntry = persisted ?? sessionEntry;
trackedRestartRecoveryDeliveryContext =

View File

@@ -59,12 +59,7 @@ type SessionKeyResolution = {
storePath: string;
};
function clearRotatedTerminalMainSessionMetadata(
entry: SessionEntry | undefined,
): SessionEntry | undefined {
if (!entry) {
return undefined;
}
export function clearRotatedSessionMetadata(entry: SessionEntry): SessionEntry {
const next = {
...entry,
sessionFile: undefined,
@@ -406,8 +401,8 @@ export function resolveSession(opts: {
const sessionId =
requestedSessionId || (fresh ? sessionEntry?.sessionId : undefined) || crypto.randomUUID();
const isNewSession = !fresh && !requestedSessionId;
const resolvedSessionEntry = terminalMainTranscriptNewerThanRegistry
? clearRotatedTerminalMainSessionMetadata(sessionEntry)
const resolvedSessionEntry = isNewSession && sessionEntry
? clearRotatedSessionMetadata(sessionEntry)
: sessionEntry;
clearBootstrapSnapshotOnSessionRollover({

View File

@@ -460,6 +460,56 @@ describe("agentCommand", () => {
).rejects.toThrow("allowModelOverride must be explicitly set for ingress agent runs.");
});
it("reuses a Discord voice session after one stale-session rollover", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");
const sessionKey = "agent:main:discord:channel:voice-1";
const staleStartedAt = Date.now() - 2 * 24 * 60 * 60_000;
mockConfig(home, store);
writeSessionStoreSeed(store, {
[sessionKey]: {
sessionId: "stale-voice-session",
updatedAt: staleStartedAt,
sessionStartedAt: staleStartedAt,
},
});
const runVoiceTurn = async (message: string) =>
await agentCommandFromIngress(
{
message,
sessionKey,
agentId: "main",
messageChannel: "discord",
messageProvider: "discord-voice",
allowModelOverride: false,
deliver: false,
},
runtime,
);
await runVoiceTurn("remember 42");
const firstSessionId = getLastEmbeddedCall()?.sessionId;
expect(firstSessionId).toBeTruthy();
expect(firstSessionId).not.toBe("stale-voice-session");
const firstPersisted = readSessionStore<{
sessionId: string;
sessionStartedAt?: number;
}>(store)[sessionKey];
expect(firstPersisted?.sessionId).toBe(firstSessionId);
expect(firstPersisted?.sessionStartedAt).toBeGreaterThan(staleStartedAt);
await runVoiceTurn("what number?");
expect(getLastEmbeddedCall()?.sessionId).toBe(firstSessionId);
const persisted = readSessionStore<{ sessionId: string; sessionStartedAt?: number }>(store)[
sessionKey
];
expect(persisted?.sessionId).toBe(firstSessionId);
expect(persisted?.sessionStartedAt).toBeGreaterThan(staleStartedAt);
});
});
it("rejects archived sessions selected by session id", async () => {
await withTempHome(async (home) => {
const store = path.join(home, "sessions.json");