From d11a2c2aa8b78b50452dac2697f77e94c64d19ab Mon Sep 17 00:00:00 2001 From: dimakis Date: Thu, 2 Jul 2026 01:21:13 +0100 Subject: [PATCH 1/3] fix(chat): echo closeout prompts to frontend as user bubbles Both closeoutSession() and closeSessionByUser() injected prompts directly into the SDK inputQueue without calling storeAndEchoIfNew(), so the agent processed the closeout but no message bubble appeared in the chat UI. Add the missing echo calls before the inputQueue push. Co-Authored-By: Claude Opus 4.6 --- server/chat.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/server/chat.ts b/server/chat.ts index c30bee3b..d99a88e0 100644 --- a/server/chat.ts +++ b/server/chat.ts @@ -1410,6 +1410,19 @@ function _closeoutSessionInner(clientId: string): void { log.info('injecting closeout prompt', { clientId, wtId: session.wtId }); + // Echo the closeout prompt to the frontend so it appears as a user bubble + const closeoutMsgId = `umsg-${Date.now()}-${randomUUID().slice(0, 8)}-closeout`; + if (session.sessionId) { + storeAndEchoIfNew( + session.sessionId, + closeoutMsgId, + CLOSEOUT_PROMPT, + clientId, + session.transport, + session.observers, + ); + } + // Push the closeout prompt as an interrupt so the agent sees it immediately session.inputQueue.push(makeUserMessage(CLOSEOUT_PROMPT, 'now')); @@ -1495,6 +1508,19 @@ export function closeSessionByUser(clientId: string): void { log.info('user-initiated closeout', { clientId, wtId: session.wtId }); + // Echo the closeout prompt to the frontend so it appears as a user bubble + const userCloseoutMsgId = `umsg-${Date.now()}-${randomUUID().slice(0, 8)}-closeout`; + if (session.sessionId) { + storeAndEchoIfNew( + session.sessionId, + userCloseoutMsgId, + USER_CLOSEOUT_PROMPT, + clientId, + session.transport, + session.observers, + ); + } + // Inject closeout prompt session.inputQueue.push(makeUserMessage(USER_CLOSEOUT_PROMPT, 'now')); From 5654fda2a352611fbb773de8353fe6ad84873bfe Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 3 Jul 2026 00:16:21 +0100 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20address=20Centaur=20review=20?= =?UTF-8?q?=E2=80=94=20extract=20helper,=20add=20tests,=20debug=20log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract echoCloseoutPrompt() helper to DRY the two identical blocks - Add debug log when sessionId is null (consistency with sendToChat) - Add source-level tests verifying echo precedes inputQueue.push in both auto-closeout and user-closeout paths Co-Authored-By: Claude Opus 4.6 --- server/__tests__/chat.test.ts | 43 +++++++++++++++++++++++++++++ server/chat.ts | 51 ++++++++++++++++------------------- 2 files changed, 66 insertions(+), 28 deletions(-) diff --git a/server/__tests__/chat.test.ts b/server/__tests__/chat.test.ts index c561c5aa..d26cec30 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('function closeSessionByUser(') || chatSource.indexOf('export function closeSessionByUser('); + expect(fnStart).toBeGreaterThan(-1); + const fnEnd = chatSource.indexOf('\nregistry.', 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); + }); +}); diff --git a/server/chat.ts b/server/chat.ts index d99a88e0..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,20 +1431,7 @@ function _closeoutSessionInner(clientId: string): void { log.info('injecting closeout prompt', { clientId, wtId: session.wtId }); - // Echo the closeout prompt to the frontend so it appears as a user bubble - const closeoutMsgId = `umsg-${Date.now()}-${randomUUID().slice(0, 8)}-closeout`; - if (session.sessionId) { - storeAndEchoIfNew( - session.sessionId, - closeoutMsgId, - CLOSEOUT_PROMPT, - clientId, - session.transport, - session.observers, - ); - } - - // 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 @@ -1508,20 +1516,7 @@ export function closeSessionByUser(clientId: string): void { log.info('user-initiated closeout', { clientId, wtId: session.wtId }); - // Echo the closeout prompt to the frontend so it appears as a user bubble - const userCloseoutMsgId = `umsg-${Date.now()}-${randomUUID().slice(0, 8)}-closeout`; - if (session.sessionId) { - storeAndEchoIfNew( - session.sessionId, - userCloseoutMsgId, - USER_CLOSEOUT_PROMPT, - clientId, - session.transport, - session.observers, - ); - } - - // 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' From 577fc2b6bc15cfeb864789ab2f2978f9e3669bf4 Mon Sep 17 00:00:00 2001 From: dimakis Date: Fri, 3 Jul 2026 00:24:12 +0100 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20address=20Centaur=20review=20?= =?UTF-8?q?=E2=80=94=20robust=20test=20boundaries?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use 'export function closeSessionByUser(' for exact match (no dead || branch) - Use next export function as boundary instead of '\nregistry.' which resolves before fnStart Co-Authored-By: Claude Opus 4.6 --- server/__tests__/chat.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/__tests__/chat.test.ts b/server/__tests__/chat.test.ts index d26cec30..66e7739d 100644 --- a/server/__tests__/chat.test.ts +++ b/server/__tests__/chat.test.ts @@ -856,10 +856,10 @@ describe('closeout prompts echo to frontend', () => { }); it('user-closeout calls echoCloseoutPrompt before inputQueue.push', () => { - const fnStart = chatSource.indexOf('function closeSessionByUser(') || chatSource.indexOf('export function closeSessionByUser('); + const fnStart = chatSource.indexOf('export function closeSessionByUser('); expect(fnStart).toBeGreaterThan(-1); - const fnEnd = chatSource.indexOf('\nregistry.', fnStart); - const fnBody = chatSource.slice(fnStart, fnEnd); + 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);