Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions src/server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -825,6 +830,7 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>

const sessionsWithPrompts = sessions.map((session) => ({
...session,
awaitingAnswer: isSessionAwaitingAnswer(session.id),
recentUserPrompts: getRecentUserPromptsForSession(session.id, 10),
}))

Expand Down Expand Up @@ -857,7 +863,11 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
* 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) })),
})
})

/**
Expand Down Expand Up @@ -3636,6 +3646,10 @@ export async function createServerHandle(config: Config): Promise<ServerHandle>
)
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))

Expand Down
40 changes: 40 additions & 0 deletions src/server/session/manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
5 changes: 5 additions & 0 deletions src/server/session/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)

Expand Down
64 changes: 64 additions & 0 deletions src/server/tools/ask.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,72 @@ import {
hasPendingQuestion,
provideAnswer,
getPendingQuestionsForSession,
isSessionAwaitingAnswer,
onAwaitingAnswerChange,
} from './ask.js'

async function ask(sessionId: string, toolCallId: string): Promise<void> {
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
Expand Down
116 changes: 84 additions & 32 deletions src/server/tools/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
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<string>
resolve: (answer: string) => void
reject: (error: Error) => void
sessionId: string
question: string
type: 'text' | 'confirm' | 'choice'
options: ChoiceOption[] | undefined
const pendingQuestions = new Map<string, PendingQuestion>()
const pendingCountBySession = new Map<string, number>()

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<AwaitingAnswerListener>()

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<T>(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',
Expand Down Expand Up @@ -68,15 +116,17 @@ export const askUserTool: Tool = {
const deferred = createDeferred<string>()
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)
},
Expand All @@ -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
}

Expand All @@ -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 {
Expand Down
5 changes: 5 additions & 0 deletions src/shared/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -242,6 +243,10 @@ export interface SessionRunningPayload {
isRunning: boolean
}

export interface SessionAwaitingAnswerPayload {
awaitingAnswer: boolean
}

export interface SessionPausePayload {
pauseState: import('./types.js').PauseState
}
Expand Down
1 change: 1 addition & 0 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading