diff --git a/server/__tests__/chat.test.ts b/server/__tests__/chat.test.ts index c561c5aa..66e7739d 100644 --- a/server/__tests__/chat.test.ts +++ b/server/__tests__/chat.test.ts @@ -824,3 +824,46 @@ describe('agent definition wiring', () => { expect(['contexgin', 'local', 'fallback']).toContain(session.agentDefinitionSource); }); }); + +describe('closeout prompts echo to frontend', () => { + let chatSource: string; + + beforeAll(async () => { + const { readFileSync } = await import('fs'); + const { join } = await import('path'); + chatSource = readFileSync(join(import.meta.dirname, '..', 'chat.ts'), 'utf-8'); + }); + + it('echoCloseoutPrompt helper calls storeAndEchoIfNew', () => { + const fnStart = chatSource.indexOf('function echoCloseoutPrompt('); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = chatSource.indexOf('\n}', fnStart); + const fnBody = chatSource.slice(fnStart, fnEnd); + expect(fnBody).toContain('storeAndEchoIfNew('); + expect(fnBody).toContain("log.debug('skipping closeout echo"); + }); + + it('auto-closeout calls echoCloseoutPrompt before inputQueue.push', () => { + const fnStart = chatSource.indexOf('function _closeoutSessionInner('); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = chatSource.indexOf('\n}', fnStart); + const fnBody = chatSource.slice(fnStart, fnEnd); + const echoIdx = fnBody.indexOf('echoCloseoutPrompt('); + const pushIdx = fnBody.indexOf('session.inputQueue.push('); + expect(echoIdx).toBeGreaterThan(-1); + expect(pushIdx).toBeGreaterThan(-1); + expect(echoIdx).toBeLessThan(pushIdx); + }); + + it('user-closeout calls echoCloseoutPrompt before inputQueue.push', () => { + const fnStart = chatSource.indexOf('export function closeSessionByUser('); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = chatSource.indexOf('\nexport function', fnStart + 1); + const fnBody = chatSource.slice(fnStart, fnEnd > -1 ? fnEnd : undefined); + const echoIdx = fnBody.indexOf('echoCloseoutPrompt('); + const pushIdx = fnBody.indexOf('session.inputQueue.push('); + expect(echoIdx).toBeGreaterThan(-1); + expect(pushIdx).toBeGreaterThan(-1); + expect(echoIdx).toBeLessThan(pushIdx); + }); +}); diff --git a/server/chat.ts b/server/chat.ts index c30bee3b..50579b77 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -1355,6 +1355,27 @@ export function cleanupSessionWorktrees( if (primary) session.worktreePaths.set('primary', primary); } +/** Echo a closeout prompt to the frontend as a user bubble before injecting into the SDK. */ +function echoCloseoutPrompt( + session: import('./session-registry.js').ManagedSession, + clientId: string, + prompt: string, +): void { + const messageId = `umsg-${Date.now()}-${randomUUID().slice(0, 8)}-closeout`; + if (session.sessionId) { + storeAndEchoIfNew( + session.sessionId, + messageId, + prompt, + clientId, + session.transport, + session.observers, + ); + } else { + log.debug('skipping closeout echo — session not yet resolved', { clientId }); + } +} + const CLOSEOUT_PROMPT = `This session is closing in 10 minutes due to inactivity. Please perform session closeout: @@ -1410,7 +1431,7 @@ function _closeoutSessionInner(clientId: string): void { log.info('injecting closeout prompt', { clientId, wtId: session.wtId }); - // Push the closeout prompt as an interrupt so the agent sees it immediately + echoCloseoutPrompt(session, clientId, CLOSEOUT_PROMPT); session.inputQueue.push(makeUserMessage(CLOSEOUT_PROMPT, 'now')); // The registry's CLOSEOUT_TIMEOUT_MS timer will abort the session after @@ -1495,7 +1516,7 @@ export function closeSessionByUser(clientId: string): void { log.info('user-initiated closeout', { clientId, wtId: session.wtId }); - // Inject closeout prompt + echoCloseoutPrompt(session, clientId, USER_CLOSEOUT_PROMPT); session.inputQueue.push(makeUserMessage(USER_CLOSEOUT_PROMPT, 'now')); // Register abort listener to finalize with closed_by: 'user'