fix(voice): suppress session terminate during DingTalk OAuth flow

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 <noreply@anthropic.com>
This commit is contained in:
hailin 2026-03-08 23:14:53 -07:00
parent 5a66f85235
commit 5874907300
1 changed files with 33 additions and 0 deletions

View File

@ -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<string, number>(); // 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.