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
28 changes: 27 additions & 1 deletion src/web-ui/src/flow_chat/components/ChatInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ import { useAssistantBootstrap } from '@/app/hooks/useAssistantBootstrap';
import {
clearComposerForSubmission,
failedSubmissionRecoveryTarget,
manualCompactionTurnStarted,
shouldRecordContextMutation,
successfulRetryCleanupTarget,
} from './chatInputDraftRecovery';
Expand Down Expand Up @@ -3739,7 +3740,19 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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 });

Expand All @@ -3750,7 +3763,19 @@ export const ChatInput: React.FC<ChatInputProps> = ({
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'),
{
Expand All @@ -3760,6 +3785,7 @@ export const ChatInput: React.FC<ChatInputProps> = ({
);
}
}, [
clearPendingLargePastes,
derivedState?.isProcessing,
dispatchInput,
effectiveTargetSession,
Expand Down
26 changes: 26 additions & 0 deletions src/web-ui/src/flow_chat/components/ChatInputDraftRecovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
clearComposerForSubmission,
failedSubmissionRecoveryTarget,
manualCompactionTurnStarted,
shouldRecordContextMutation,
successfulRetryCleanupTarget,
} from './chatInputDraftRecovery';
Expand Down Expand Up @@ -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);
});
});
Original file line number Diff line number Diff line change
@@ -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)');
});
});
21 changes: 21 additions & 0 deletions src/web-ui/src/flow_chat/components/chatInputDraftRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>,
turns: readonly CommandTurnIdentity[],
): boolean {
return turns.some(turn => turn.kind === 'manual_compaction' && !knownTurnIds.has(turn.id));
}

export function successfulRetryCleanupTarget(
submissionSessionId: string,
currentSessionId: string | null,
Expand Down
Loading