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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $clarifyRequests, clearClarifyRequest } from '@/store/clarify'
import { resetServerRequestsForTests } from '@/store/server-requests'
import { onScrollToBottomRequest } from '@/store/thread-scroll'

import { type MessageStreamHarness, renderMessageStream } from './test-harness'
Expand Down Expand Up @@ -58,13 +59,15 @@ function seedHydratedMessages(messages: ChatMessage[]) {
describe('clarify request stream hydration', () => {
beforeEach(() => {
clearClarifyRequest()
resetServerRequestsForTests()
scrollToBottom.mockClear()
stopScrollListener = onScrollToBottomRequest(scrollToBottom, SID)
})

afterEach(() => {
cleanup()
clearClarifyRequest()
resetServerRequestsForTests()
stopScrollListener?.()
stopScrollListener = null
vi.restoreAllMocks()
Expand Down Expand Up @@ -279,6 +282,24 @@ describe('clarify request stream hydration', () => {
expect(stream.state().needsInput).toBe(false)
})

it.each(['message.complete', 'error'] as const)('keeps a live clarify card through a spurious %s', type => {
mountStream()
clarifyRequest({ choices: ['a', 'b'], question: 'Pick', request_id: 'req-live' })

act(() =>
stream.handleEvent({
payload: type === 'error' ? { message: 'spurious error' } : { text: '' },
session_id: SID,
type
})
)

expect($clarifyRequests.get()[SID]?.requestId).toBe('req-live')

clarifyExpire('req-live')
expect($clarifyRequests.get()[SID]).toBeUndefined()
})

it('merges a BATCH tool.start row with its clarify.request (no top-level question)', () => {
mountStream()

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { playCompletionSound } from '@/lib/completion-sound'
import { parseErrorSurface } from '@/lib/error-surface'
import { triggerHaptic } from '@/lib/haptics'
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest } from '@/store/clarify'
import { clearSettledClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { notify } from '@/store/notifications'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
Expand Down Expand Up @@ -323,7 +323,7 @@ export function handleMessageStreamEvent(ctx: GatewayEventContext): boolean {
// session so a background turn finishing can't wipe the active chat's
// prompt, and vice versa.
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)
clearSettledClarifyRequest(sessionId)
// Turn ended without a final `todo` update — drop a still-unfinished
// list so "Tasks N/M" doesn't stay pinned above the composer with the
// last item stuck pending/in_progress. Finished lists keep their linger.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { textPart } from '@/lib/chat-messages'
import { coerceGatewayText } from '@/lib/chat-runtime'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { type AgentNoticePayload, clearAgentNotice, nativeNoticeInput, showAgentNotice } from '@/store/agent-notices'
import { clearClarifyRequest } from '@/store/clarify'
import { clearSettledClarifyRequest } from '@/store/clarify'
import { reconcileSessionCompacting, setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
import { applyGoalStatusText } from '@/store/goals'
Expand Down Expand Up @@ -175,7 +175,7 @@ export function handleStatusEvent(ctx: GatewayEventContext): boolean {
// the failed turn (same intent as the message.complete clear).
if (sessionId) {
clearAllPrompts(sessionId)
clearClarifyRequest(undefined, sessionId)
clearSettledClarifyRequest(sessionId)
clearActiveSessionTodos(sessionId)
reconcileSessionCompacting(sessionId, 'terminal')
compactedTurnRef.current.delete(sessionId)
Expand Down
11 changes: 10 additions & 1 deletion apps/desktop/src/store/clarify.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { atom, computed } from 'nanostores'

import { respondToServerRequest } from './server-requests'
import { hasOpenServerRequest, respondToServerRequest } from './server-requests'
import { $activeSessionId } from './session'

export interface ClarifyQuestion {
Expand Down Expand Up @@ -177,6 +177,15 @@ export function clearClarifyRequest(requestId?: string, sessionId?: string | nul
export const hasClarifyRequest = (sessionId: string | null | undefined): boolean =>
Boolean($clarifyRequests.get()[keyFor(sessionId)])

/** Clear a stale card at a turn boundary, but keep it while its backend request is still waiting. */
export function clearSettledClarifyRequest(sessionId: string | null): void {
const request = $clarifyRequests.get()[keyFor(sessionId)]

if (request && !hasOpenServerRequest(request.requestId)) {
clearClarifyRequest(request.requestId, sessionId)
}
}

/**
* Answer `sessionId`'s pending clarify with an empty answer (a skip) and drop it
* locally, resolving to whether there was one to skip.
Expand Down