From 5874907300c150041c34811b937bcac0f93811a3 Mon Sep 17 00:00:00 2001 From: hailin Date: Sun, 8 Mar 2026 23:14:53 -0700 Subject: [PATCH] fix(voice): suppress session terminate during DingTalk OAuth flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the voice agent triggers DingTalk OAuth, the user leaves the app to authorize in DingTalk/browser, causing the LiveKit participant to disconnect. The voice-agent then calls DELETE /voice to terminate the session — but the user intends to return after completing OAuth. Fix: mark the session as "oauth_pending" in VoiceSessionController when oauth-trigger fires. If terminateVoiceSession is called while the flag is active (10-min grace), suppress the terminate and return 200 OK so the voice-agent exits cleanly. The session stays alive; when the user returns to the voice screen, voice/start + inject auto-resume it. Co-Authored-By: Claude Sonnet 4.6 --- .../controllers/voice-session.controller.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts b/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts index b28da05..6a62543 100644 --- a/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts +++ b/packages/services/agent-service/src/interfaces/rest/controllers/voice-session.controller.ts @@ -27,10 +27,23 @@ import { AgentEngineType } from '../../../domain/value-objects/agent-engine-type import { AgentSession } from '../../../domain/entities/agent-session.entity'; import * as crypto from 'crypto'; +/** OAuth flow timeout — give the user this long to complete authorization before + * allowing the voice session to be terminated (voice-agent disconnect during OAuth). */ +const OAUTH_GRACE_MS = 10 * 60 * 1000; // 10 minutes + @Controller('api/v1/agent/sessions') export class VoiceSessionController { private readonly logger = new Logger(VoiceSessionController.name); + /** + * Tracks sessions where a DingTalk OAuth flow was triggered. + * While an entry is present (and not expired), the voice session will NOT + * be terminated if the voice-agent calls DELETE /voice (which happens when + * the LiveKit participant disconnects — a side-effect of the user leaving + * the app to complete OAuth in DingTalk/browser). + */ + private readonly oauthPendingSessions = new Map(); // sessionId → expiresAt + constructor( private readonly voiceSessionManager: VoiceSessionManager, private readonly sessionRepository: SessionRepository, @@ -160,6 +173,20 @@ export class VoiceSessionController { @TenantId() tenantId: string, @Param('sessionId') sessionId: string, ) { + // If a DingTalk OAuth flow was triggered for this session, do NOT terminate yet. + // The user left the app to authorize in DingTalk/browser, causing the LiveKit + // participant to disconnect, which triggers this call from the voice-agent. + // We keep the session alive so the user can resume after OAuth completes. + const oauthExpiry = this.oauthPendingSessions.get(sessionId); + if (oauthExpiry && Date.now() < oauthExpiry) { + this.logger.log( + `Voice session ${sessionId} terminate suppressed — DingTalk OAuth in progress ` + + `(grace expires in ${Math.round((oauthExpiry - Date.now()) / 1000)}s)`, + ); + return { sessionId, terminated: false, reason: 'oauth_pending' }; + } + this.oauthPendingSessions.delete(sessionId); + const session = await this.sessionRepository.findById(sessionId); if (session && session.tenantId === tenantId) { @@ -211,6 +238,12 @@ export class VoiceSessionController { const instanceName = body.instanceName || inst.name || body.instanceId; const { oauthUrl } = this.dingTalkRouter.generateOAuthUrl(body.instanceId); + // Mark this session as having an in-flight OAuth flow. + // terminateVoiceSession will suppress the terminate call while this is set, + // so the LiveKit disconnect caused by the user leaving to authorize in + // DingTalk/browser does not destroy the voice session prematurely. + this.oauthPendingSessions.set(sessionId, Date.now() + OAUTH_GRACE_MS); + // Emit the oauth_prompt event to the session's WebSocket stream. // voice-agent subscribes to this stream and forwards it to Flutter via // LiveKit publish_data (topic="oauth_prompt"), which triggers the bottom sheet.