From 6cc534bbad7b593b701f133daee275cfca67ff86 Mon Sep 17 00:00:00 2001 From: Sanjay Santhanam <51058514+Sanjays2402@users.noreply.github.com> Date: Sun, 5 Jul 2026 00:00:08 -0700 Subject: [PATCH] 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 --- src/agents/agent-command.ts | 26 +++++++++++------- src/agents/command/session.ts | 11 +++----- src/commands/agent.test.ts | 50 +++++++++++++++++++++++++++++++++++ 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts index cda3d38a5d2b..a13a4181b0ec 100644 --- a/src/agents/agent-command.ts +++ b/src/agents/agent-command.ts @@ -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 = diff --git a/src/agents/command/session.ts b/src/agents/command/session.ts index 0c91cc55da24..bc523964a1c2 100644 --- a/src/agents/command/session.ts +++ b/src/agents/command/session.ts @@ -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({ diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index 823f5ff8cddf..4ba397234df2 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -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");