diff --git a/src/server/index.ts b/src/server/index.ts index 1de4eec5..adc45139 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -13,7 +13,12 @@ import { initDatabase } from './db/index.js' import { getProject, deleteProject } from './db/projects.js' import { initEventStore, getEventStore, combineEventsWithSnapshot } from './events/index.js' import { buildMessagesFromStoredEvents } from './events/folding.js' -import { provideAnswer, getPendingQuestionsForSession } from './tools/ask.js' +import { + provideAnswer, + getPendingQuestionsForSession, + isSessionAwaitingAnswer, + onAwaitingAnswerChange, +} from './tools/ask.js' import { providePathConfirmation, getPendingConfirmationsBySession } from './tools/path-security.js' import './llm/proxy.js' import { detectModel, getLlmStatus, getBackendDisplayName } from './llm/index.js' @@ -825,6 +830,7 @@ export async function createServerHandle(config: Config): Promise const sessionsWithPrompts = sessions.map((session) => ({ ...session, + awaitingAnswer: isSessionAwaitingAnswer(session.id), recentUserPrompts: getRecentUserPromptsForSession(session.id, 10), })) @@ -857,7 +863,11 @@ export async function createServerHandle(config: Config): Promise * Registered before /api/sessions/:id so 'home' is not treated as an id. */ app.get('/api/sessions/home', (_req, res) => { - res.json({ sessions: sessionManager.listHomeSessions() }) + res.json({ + sessions: sessionManager + .listHomeSessions() + .map((session) => ({ ...session, awaitingAnswer: isSessionAwaitingAnswer(session.id) })), + }) }) /** @@ -3636,6 +3646,10 @@ export async function createServerHandle(config: Config): Promise ) const wss = wssExports.wss + onAwaitingAnswerChange((sessionId, awaitingAnswer) => { + wssExports.broadcastAll({ type: 'session.awaiting_answer', sessionId, payload: { awaitingAnswer } }) + }) + // Point the plugin host at the live WebSocket broadcaster now that it exists. pluginHost.setBroadcaster((message) => wssExports.broadcastAll(message)) diff --git a/src/server/session/manager.test.ts b/src/server/session/manager.test.ts index 2a822295..d4e2a898 100644 --- a/src/server/session/manager.test.ts +++ b/src/server/session/manager.test.ts @@ -205,6 +205,46 @@ describe('SessionManager', () => { expect(getSessionDisabledServers(session.id)).toEqual([]) }) + it('cancels pending ask_user questions when a session is deleted', async () => { + const { askUserTool, getPendingQuestionsForSession, isSessionAwaitingAnswer } = await import('../tools/ask.js') + const session = manager.createSession(projectId) + const other = manager.createSession(projectId) + for (const [id, callId] of [ + [session.id, 'del-q-1'], + [other.id, 'del-q-2'], + ] as const) { + await askUserTool + .execute({ question: 'Q?' }, { workdir, sessionId: id, sessionManager: {} as never, toolCallId: callId }) + .catch(() => {}) + } + + manager.deleteSession(session.id) + + expect(getPendingQuestionsForSession(session.id)).toEqual([]) + expect(isSessionAwaitingAnswer(session.id)).toBe(false) + expect(isSessionAwaitingAnswer(other.id)).toBe(true) + }) + + it('cancels pending ask_user questions of every session when all project sessions are deleted', async () => { + const { askUserTool, getPendingQuestionsForSession, isSessionAwaitingAnswer } = await import('../tools/ask.js') + const sessions = [manager.createSession(projectId), manager.createSession(projectId)] + for (const [index, s] of sessions.entries()) { + await askUserTool + .execute( + { question: 'Q?' }, + { workdir, sessionId: s.id, sessionManager: {} as never, toolCallId: `del-all-q-${index}` }, + ) + .catch(() => {}) + } + + manager.deleteAllSessions(projectId, workdir) + + for (const s of sessions) { + expect(getPendingQuestionsForSession(s.id)).toEqual([]) + expect(isSessionAwaitingAnswer(s.id)).toBe(false) + } + }) + it('uses database is_running as source of truth for session state', () => { const session = manager.createSession(projectId) diff --git a/src/server/session/manager.ts b/src/server/session/manager.ts index 8bf059b2..72028aab 100644 --- a/src/server/session/manager.ts +++ b/src/server/session/manager.ts @@ -72,6 +72,8 @@ import { logger } from '../utils/logger.js' import { EventEmitter, type Unsubscribe } from '../utils/async.js' import { getLspManager as getOrCreateLspManager, shutdownLspManager, type LspManager } from '../lsp/index.js' import { devServerManager } from '../dev-server/manager.js' +import { cancelQuestionsForSession } from '../tools/ask.js' +import { cancelPathConfirmationsForSession } from '../tools/path-security.js' import { resolveLLMClientForAgent, getAgentModelOverride } from '../agents/model-overrides.js' import { parseDefaultModelSelection } from '../provider-manager.js' import { getEventStore } from '../events/store.js' @@ -750,6 +752,9 @@ export class SessionManager { // Clear message queue to prevent memory leak this.messageQueues.delete(id) + cancelQuestionsForSession(id, 'Session deleted') + cancelPathConfirmationsForSession(id, 'Session deleted') + // Release any blocked pause gate and drop the pause state this.clearPauseState(id) diff --git a/src/server/tools/ask.test.ts b/src/server/tools/ask.test.ts index 3927fc6d..e2d9b9df 100644 --- a/src/server/tools/ask.test.ts +++ b/src/server/tools/ask.test.ts @@ -7,8 +7,72 @@ import { hasPendingQuestion, provideAnswer, getPendingQuestionsForSession, + isSessionAwaitingAnswer, + onAwaitingAnswerChange, } from './ask.js' +async function ask(sessionId: string, toolCallId: string): Promise { + try { + await askUserTool.execute( + { question: 'Q?' }, + { workdir: '/tmp/project', sessionId, sessionManager: {} as never, toolCallId }, + ) + } catch { + // AskUserInterrupt expected + } +} + +describe('awaiting-answer notifications', () => { + it('notifies only on transitions of a session awaiting state', async () => { + const changes: Array<[string, boolean]> = [] + const unsubscribe = onAwaitingAnswerChange((sessionId, awaiting) => changes.push([sessionId, awaiting])) + + await ask('s-await', 'c-await-1') + await ask('s-await', 'c-await-2') + expect(isSessionAwaitingAnswer('s-await')).toBe(true) + provideAnswer('c-await-1', 'x') + expect(isSessionAwaitingAnswer('s-await')).toBe(true) + cancelQuestion('c-await-2', 'stop') + expect(isSessionAwaitingAnswer('s-await')).toBe(false) + + await ask('s-await', 'c-await-3') + cancelQuestionsForSession('s-await', 'deleted') + + unsubscribe() + await ask('s-await', 'c-await-4') + provideAnswer('c-await-4', 'x') + + expect(changes).toEqual([ + ['s-await', true], + ['s-await', false], + ['s-await', true], + ['s-await', false], + ]) + }) + + it('reports false for a session with no pending question', () => { + expect(isSessionAwaitingAnswer('never-asked')).toBe(false) + }) + + it('keeps the per-session count exact when a call id is asked twice', async () => { + await ask('s-dup', 'c-dup-1') + await ask('s-dup', 'c-dup-1') + expect(getPendingQuestionsForSession('s-dup')).toHaveLength(1) + provideAnswer('c-dup-1', 'x') + expect(isSessionAwaitingAnswer('s-dup')).toBe(false) + }) + + it('is independent across sessions', async () => { + await ask('s-a', 'c-a-1') + await ask('s-b', 'c-b-1') + cancelQuestionsForSession('s-a', 'deleted') + expect(isSessionAwaitingAnswer('s-a')).toBe(false) + expect(isSessionAwaitingAnswer('s-b')).toBe(true) + provideAnswer('c-b-1', 'x') + expect(isSessionAwaitingAnswer('s-b')).toBe(false) + }) +}) + describe('ask_user tool', () => { it('throws an AskUserInterrupt and tracks the pending question', async () => { let interrupt: AskUserInterrupt | null = null diff --git a/src/server/tools/ask.ts b/src/server/tools/ask.ts index f61bad90..9d17cad5 100644 --- a/src/server/tools/ask.ts +++ b/src/server/tools/ask.ts @@ -4,19 +4,67 @@ import type { PendingQuestionPayload, ChoiceOption } from '../../shared/protocol import { normalizeAskOptions } from '../../shared/ask-options.js' import { createDeferred } from '../utils/async.js' +interface PendingQuestion { + promise: Promise + resolve: (answer: string) => void + reject: (error: Error) => void + sessionId: string + question: string + type: 'text' | 'confirm' | 'choice' + options: ChoiceOption[] | undefined +} + // Store pending questions by call ID -const pendingQuestions = new Map< - string, - { - promise: Promise - resolve: (answer: string) => void - reject: (error: Error) => void - sessionId: string - question: string - type: 'text' | 'confirm' | 'choice' - options: ChoiceOption[] | undefined +const pendingQuestions = new Map() +const pendingCountBySession = new Map() + +function addPendingQuestion(callId: string, pending: PendingQuestion): void { + removePendingQuestion(callId) + pendingQuestions.set(callId, pending) + pendingCountBySession.set(pending.sessionId, (pendingCountBySession.get(pending.sessionId) ?? 0) + 1) +} + +function removePendingQuestion(callId: string): void { + const pending = pendingQuestions.get(callId) + if (!pending) return + pendingQuestions.delete(callId) + const remaining = (pendingCountBySession.get(pending.sessionId) ?? 1) - 1 + if (remaining > 0) { + pendingCountBySession.set(pending.sessionId, remaining) + } else { + pendingCountBySession.delete(pending.sessionId) } ->() +} + +type AwaitingAnswerListener = (sessionId: string, awaiting: boolean) => void +const awaitingAnswerListeners = new Set() + +export function isSessionAwaitingAnswer(sessionId: string): boolean { + return pendingCountBySession.has(sessionId) +} + +export function onAwaitingAnswerChange(listener: AwaitingAnswerListener): () => void { + awaitingAnswerListeners.add(listener) + return () => { + awaitingAnswerListeners.delete(listener) + } +} + +function trackAwaitingTransition(sessionId: string, mutate: () => T): T { + const before = isSessionAwaitingAnswer(sessionId) + const result = mutate() + const after = isSessionAwaitingAnswer(sessionId) + if (before !== after) { + for (const listener of awaitingAnswerListeners) { + try { + listener(sessionId, after) + } catch { + // a failing listener must not break question handling + } + } + } + return result +} export const askUserTool: Tool = { name: 'ask_user', @@ -68,15 +116,17 @@ export const askUserTool: Tool = { const deferred = createDeferred() void deferred.promise.catch(() => {}) - pendingQuestions.set(callId, { - promise: deferred.promise, - resolve: deferred.resolve, - reject: deferred.reject, - sessionId: context.sessionId, - question, - type, - options, - }) + trackAwaitingTransition(context.sessionId, () => + addPendingQuestion(callId, { + promise: deferred.promise, + resolve: deferred.resolve, + reject: deferred.reject, + sessionId: context.sessionId, + question, + type, + options, + }), + ) throw new AskUserInterrupt(callId, question, type, options) }, @@ -101,7 +151,7 @@ export function provideAnswer(callId: string, answer: string, skip?: boolean): b } pending.resolve(skip ? '[user skipped]' : answer) - pendingQuestions.delete(callId) + trackAwaitingTransition(pending.sessionId, () => removePendingQuestion(callId)) return true } @@ -112,24 +162,26 @@ export function cancelQuestion(callId: string, reason: string): boolean { } pending.reject(new Error(reason)) - pendingQuestions.delete(callId) + trackAwaitingTransition(pending.sessionId, () => removePendingQuestion(callId)) return true } export function cancelQuestionsForSession(sessionId: string, reason: string): number { - let cancelledCount = 0 + return trackAwaitingTransition(sessionId, () => { + let cancelledCount = 0 - for (const [callId, pending] of pendingQuestions.entries()) { - if (pending.sessionId !== sessionId) { - continue - } + for (const [callId, pending] of pendingQuestions.entries()) { + if (pending.sessionId !== sessionId) { + continue + } - pending.reject(new Error(reason)) - pendingQuestions.delete(callId) - cancelledCount += 1 - } + pending.reject(new Error(reason)) + removePendingQuestion(callId) + cancelledCount += 1 + } - return cancelledCount + return cancelledCount + }) } export function hasPendingQuestion(callId: string): boolean { diff --git a/src/shared/protocol.ts b/src/shared/protocol.ts index 762ee089..67dc5c67 100644 --- a/src/shared/protocol.ts +++ b/src/shared/protocol.ts @@ -89,6 +89,7 @@ export type ServerMessageType = | 'session.deleted' | 'session.deletedAll' | 'session.running' // Real-time running state change + | 'session.awaiting_answer' // An ask_user question is pending (or resolved) in this session (broadcast to all) | 'session.pause' // Cooperative pause state change (none/pending/paused/resuming) | 'session.name_generated' // Session name was auto-generated | 'session.confirmation_pending' // Path confirmation waiting in another session (broadcast to all) @@ -242,6 +243,10 @@ export interface SessionRunningPayload { isRunning: boolean } +export interface SessionAwaitingAnswerPayload { + awaitingAnswer: boolean +} + export interface SessionPausePayload { pauseState: import('./types.js').PauseState } diff --git a/src/shared/types.ts b/src/shared/types.ts index b6084703..0412ee43 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -163,6 +163,7 @@ export interface SessionSummary { mode: SessionMode phase: SessionPhase // Current workflow phase isRunning: boolean + awaitingAnswer?: boolean // An ask_user question is pending an answer from the user isFavorite: boolean // Whether the session is favorited for pinning providerId?: string | null // Per-session provider override providerModel?: string | null // Per-session model override diff --git a/web/src/components/HomePage.tsx b/web/src/components/HomePage.tsx index ee1242b2..3592dab3 100644 --- a/web/src/components/HomePage.tsx +++ b/web/src/components/HomePage.tsx @@ -117,26 +117,25 @@ function SessionProjectMenu({ /** Color-coded activity dot for a session row on the homepage list. */ function SessionStatusDot({ session, waiting }: { session: SessionSummary; waiting: boolean }) { const t = useT() - if (session.isRunning) { + if (waiting) { return ( ) } - if (waiting || session.phase === 'blocked') { + if (session.isRunning) { return ( ) } + if (session.phase === 'blocked') { + return + } return } @@ -313,7 +312,7 @@ export function HomePage() { const project = projectById.get(session.projectId) const displayTitle = session.title ?? session.id.slice(0, 8) const matchType = matchTypes?.get(session.id) - const waiting = sessionsWithPendingConfirmations.includes(session.id) + const waiting = sessionsWithPendingConfirmations.includes(session.id) || session.awaitingAnswer === true const rowClass = 'flex items-center gap-3 px-3 md:px-4 py-2.5 transition-colors' + (project ? ' hover:bg-bg-tertiary/50 cursor-pointer' : ' cursor-default') diff --git a/web/src/components/layout/Sidebar.test.tsx b/web/src/components/layout/Sidebar.test.tsx index 04a3dcb4..cb2250b0 100644 --- a/web/src/components/layout/Sidebar.test.tsx +++ b/web/src/components/layout/Sidebar.test.tsx @@ -143,6 +143,22 @@ describe('Sidebar', () => { expect(html).not.toContain('bg-black/50') }) + it('shows a waiting-for-answer marker on a background session awaiting an answer, above the unread dot', () => { + sessionStoreStateRef.current = { + ...sessionStoreState, + sessions: sessionStoreState.sessions.map((s) => (s.id === 'session-3' ? { ...s, awaitingAnswer: true } : s)), + } + const html = renderToStaticMarkup() + expect(html).toContain('Waiting for your answer') + expect(html).not.toContain('Unread activity') + }) + + it('shows the waiting-for-answer marker on the active session while its question is pending', () => { + sessionStoreStateRef.current = { ...sessionStoreState, pendingQuestions: [{ callId: 'c1' }] } as never + const html = renderToStaticMarkup() + expect(html.match(/Waiting for your answer/g)?.length).toBe(2) + }) + it('collapses the inline sidebar to zero width when closed', () => { const html = renderToStaticMarkup() expect(html).toContain('w-0') diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx index 15b6c1c3..68ede6a4 100644 --- a/web/src/components/layout/Sidebar.tsx +++ b/web/src/components/layout/Sidebar.tsx @@ -67,6 +67,7 @@ export function Sidebar({ projectId, isOpen = true, overlay = false, onClose }: const sessionsPaginationLoading = useSessionStore((state) => state.sessionsPaginationLoading) const sessionsWithPendingConfirmations = useSessionStore((state) => state.sessionsWithPendingConfirmations) const pendingPathConfirmations = useSessionStore((state) => state.pendingPathConfirmations) + const currentPendingQuestionsCount = useSessionStore((state) => state.pendingQuestions?.length ?? 0) const toggleFavorite = useSessionStore((state) => state.toggleFavorite) const currentProject = useCurrentProject() @@ -492,6 +493,7 @@ export function Sidebar({ projectId, isOpen = true, overlay = false, onClose }: projectId, sessionsWithPendingConfirmations, pendingPathConfirmations, + currentPendingQuestionsCount, searchQuery, focusedIndex, t, @@ -546,6 +548,7 @@ function renderSessionList( projectId: string, sessionsWithPendingConfirmations: string[], pendingPathConfirmations: PendingPathConfirmation[], + currentPendingQuestionsCount: number, searchQuery: string, focusedIndex: number, t: ( @@ -564,6 +567,7 @@ function renderSessionList( const isFavorite = session.isFavorite const hasPendingConfirmation = sessionsWithPendingConfirmations.includes(session.id) || (isActive && pendingPathConfirmations.length > 0) + const isAwaitingAnswer = isActive ? currentPendingQuestionsCount > 0 : session.awaitingAnswer === true return (
+ ) : isAwaitingAnswer ? ( + ) : isRunning ? ( ) : hasUnread && !isActive ? ( diff --git a/web/src/stores/session/messageHandler.test.ts b/web/src/stores/session/messageHandler.test.ts index a8c433f2..62cf4275 100644 --- a/web/src/stores/session/messageHandler.test.ts +++ b/web/src/stores/session/messageHandler.test.ts @@ -692,6 +692,31 @@ describe('chat.stats handler', () => { expect(useSessionStore.getState().liveTurnStats).toBeNull() }) + it('tracks awaitingAnswer on a background session summary', async () => { + const useSessionStore = await loadSessionStore() + + useSessionStore.setState((state) => ({ + ...state, + sessions: [{ id: 'bg-1', isRunning: false } as any, { id: 'bg-2', isRunning: false } as any], + })) + + useSessionStore.getState().handleServerMessage({ + type: 'session.awaiting_answer', + sessionId: 'bg-1', + payload: { awaitingAnswer: true }, + }) + const afterTrue = useSessionStore.getState().sessions + expect(afterTrue.find((s) => s.id === 'bg-1')?.awaitingAnswer).toBe(true) + expect(afterTrue.find((s) => s.id === 'bg-2')?.awaitingAnswer).toBeUndefined() + + useSessionStore.getState().handleServerMessage({ + type: 'session.awaiting_answer', + sessionId: 'bg-1', + payload: { awaitingAnswer: false }, + }) + expect(useSessionStore.getState().sessions.find((s) => s.id === 'bg-1')?.awaitingAnswer).toBe(false) + }) + it('clears stale live turn stats when a new turn starts running', async () => { const useSessionStore = await loadSessionStore() diff --git a/web/src/stores/session/messageHandler.ts b/web/src/stores/session/messageHandler.ts index d05da18f..f75c156c 100644 --- a/web/src/stores/session/messageHandler.ts +++ b/web/src/stores/session/messageHandler.ts @@ -5,6 +5,7 @@ import type { GitDiffFile, SessionListPayload, SessionRunningPayload, + SessionAwaitingAnswerPayload, SessionPausePayload, ChatAskUserPayload, ChatDeltaPayload, @@ -473,6 +474,12 @@ export function handleServerMessage( break } + case 'session.awaiting_answer': { + const payload = message.payload as SessionAwaitingAnswerPayload + updateSessionField(message, set, get, (s) => ({ ...s, awaitingAnswer: payload.awaitingAnswer })) + break + } + case 'session.pause': { const payload = message.payload as SessionPausePayload const eventSessionId = message.sessionId