diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index 05ac1f817c..6386caa4d8 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -89,6 +89,7 @@ import { useAssistantBootstrap } from '@/app/hooks/useAssistantBootstrap'; import { clearComposerForSubmission, failedSubmissionRecoveryTarget, + manualCompactionTurnStarted, shouldRecordContextMutation, successfulRetryCleanupTarget, } from './chatInputDraftRecovery'; @@ -3739,7 +3740,19 @@ export const ChatInput: React.FC = ({ return; } + // Turns already visible before the submission; a maintenance turn that is + // not in this set proves the host admitted the compaction. + const knownTurnIds = new Set( + (FlowChatStore.getInstance().getState().sessions.get(effectiveTargetSessionId)?.dialogTurns ?? []) + .map(turn => turn.id), + ); + + setHistoryIndex(-1); + setSavedDraft(''); dispatchInput({ type: 'CLEAR_VALUE' }); + clearPendingLargePastes(); + // Clear the machine queue too; a stale queued value would be restored into + // the now-empty composer by the queuedInput sync effect. setQueuedInput(null); setSlashCommandState({ isActive: false, kind: 'all', query: '', selectedIndex: 0 }); @@ -3750,7 +3763,19 @@ export const ChatInput: React.FC = ({ error, sessionId: effectiveTargetSessionId, }); - dispatchInput({ type: 'SET_VALUE', payload: message }); + // A command the host admitted is not a draft. The desktop /compact entry + // point settles only after the compaction reaches a terminal state, so + // restoring that text would put "/compact" back into the composer long + // after the user submitted it - including when the compaction was already + // applied and only its terminal bookkeeping failed. Only an early + // rejection, which never started a maintenance turn, hands the command + // back for a retry. + const session = FlowChatStore.getInstance() + .getState() + .sessions.get(effectiveTargetSessionId); + if (!manualCompactionTurnStarted(knownTurnIds, session?.dialogTurns ?? [])) { + dispatchInput({ type: 'SET_VALUE', payload: message }); + } notificationService.error( error instanceof Error ? error.message : t('error.unknown'), { @@ -3760,6 +3785,7 @@ export const ChatInput: React.FC = ({ ); } }, [ + clearPendingLargePastes, derivedState?.isProcessing, dispatchInput, effectiveTargetSession, diff --git a/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts b/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts index 9835fd1bad..e725a52756 100644 --- a/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts +++ b/src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { clearComposerForSubmission, failedSubmissionRecoveryTarget, + manualCompactionTurnStarted, shouldRecordContextMutation, successfulRetryCleanupTarget, } from './chatInputDraftRecovery'; @@ -77,3 +78,28 @@ describe('ChatInput failed submission recovery', () => { )).toBe('none'); }); }); + +describe('manual compaction admission', () => { + const turn = (id: string, kind?: string) => ({ id, kind }); + + it('keeps a command retryable while no maintenance turn exists', () => { + expect(manualCompactionTurnStarted( + new Set(['turn-1']), + [turn('turn-1', 'user_dialog')], + )).toBe(false); + }); + + it('detects the maintenance turn the host created for the submission', () => { + expect(manualCompactionTurnStarted( + new Set(['turn-1']), + [turn('turn-1', 'user_dialog'), turn('turn-2', 'manual_compaction')], + )).toBe(true); + }); + + it('ignores a compaction turn left by an earlier submission', () => { + expect(manualCompactionTurnStarted( + new Set(['turn-1', 'turn-2']), + [turn('turn-1', 'manual_compaction'), turn('turn-2', 'user_dialog')], + )).toBe(false); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts b/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts new file mode 100644 index 0000000000..d534bed3b4 --- /dev/null +++ b/src/web-ui/src/flow_chat/components/ChatInputLocalCommandComposer.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +function readChatInputComponent(): string { + return readFileSync(fileURLToPath(new URL('./ChatInput.tsx', import.meta.url)), 'utf8') + .replace(/\r\n/g, '\n'); +} + +function readCallbackBody(component: string, entry: string): string { + const start = component.indexOf(`const ${entry} = useCallback(`); + expect(start).toBeGreaterThan(-1); + return component.slice(start, component.indexOf(' }, [', start)); +} + +describe('composer ownership of local slash commands', () => { + const component = readChatInputComponent(); + + it('hands /compact back only when the host never admitted it', () => { + // The desktop /compact entry point settles only after the compaction + // reaches a terminal state. An admitted compaction already reported its + // outcome in the transcript (and may have applied before failing), so + // refilling the composer there puts "/compact" back minutes later. Only an + // early rejection - no maintenance turn - is a retryable draft. + const body = readCallbackBody(component, 'submitCompactFromInput'); + + expect(body).toContain("dispatchInput({ type: 'CLEAR_VALUE' })"); + expect(body.indexOf('const knownTurnIds')).toBeGreaterThan(-1); + expect(body.indexOf('const knownTurnIds')).toBeLessThan(body.indexOf('compactSession')); + expect(body).toMatch( + /if \(!manualCompactionTurnStarted\(knownTurnIds,[\s\S]*?dispatchInput\(\{ type: 'SET_VALUE', payload: message \}\);\s*\n\s*\}/, + ); + }); + + it('clears the machine queue so the submission cannot be restored later', () => { + // The queuedInput sync effect restores queued text into an empty composer; + // a queue entry left behind by the command submission would resurface it. + const body = readCallbackBody(component, 'submitCompactFromInput'); + expect(body).toContain('setQueuedInput(null)'); + }); +}); diff --git a/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts b/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts index db902e99da..7235086854 100644 --- a/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts +++ b/src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts @@ -38,6 +38,27 @@ export function shouldRecordContextMutation( return contextsChanged && !isRestoringSessionDraft; } +interface CommandTurnIdentity { + id: string; + kind?: string; +} + +/** + * Whether the host admitted a manual compaction for a command submission. + * + * A command that failed before admission never produced a turn, so its text is + * still a retryable draft. Once the maintenance turn exists the outcome already + * reached the transcript - and the compaction may even have been applied before + * its terminal bookkeeping failed - so refilling the composer would resurrect + * the submitted command long after the fact. + */ +export function manualCompactionTurnStarted( + knownTurnIds: ReadonlySet, + turns: readonly CommandTurnIdentity[], +): boolean { + return turns.some(turn => turn.kind === 'manual_compaction' && !knownTurnIds.has(turn.id)); +} + export function successfulRetryCleanupTarget( submissionSessionId: string, currentSessionId: string | null,