refactor ui for stop hook and userPromptSubmit

This commit is contained in:
DennisYu07 2026-03-25 20:44:55 +08:00
parent 3776825c2d
commit a5c6084222
6 changed files with 346 additions and 6 deletions

View file

@ -230,6 +230,16 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
{itemForDisplay.type === 'btw' && itemForDisplay.btw && (
<BtwMessage btw={itemForDisplay.btw} />
)}
{itemForDisplay.type === 'user_prompt_submit_blocked' && (
<ErrorMessage
text={`UserPromptSubmit operation blocked by hook:\n${itemForDisplay.reason}\n\nOriginal prompt: ${itemForDisplay.originalPrompt}`}
/>
)}
{itemForDisplay.type === 'stop_hook_loop' && (
<InfoMessage
text={`Ran ${itemForDisplay.iterationCount} stop hooks\n ⎿ Stop hook reason: ${itemForDisplay.reasons[itemForDisplay.reasons.length - 1]}`}
/>
)}
</Box>
);
};

View file

@ -3226,4 +3226,209 @@ describe('useGeminiStream', () => {
});
});
});
describe('UserPromptSubmitBlocked Event', () => {
it('should handle UserPromptSubmitBlocked event and add blocked history item', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.UserPromptSubmitBlocked,
value: {
reason: 'Hook blocked due to security policy',
originalPrompt: 'This is the original user prompt',
},
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('This is the original user prompt');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'user_prompt_submit_blocked',
reason: 'Hook blocked due to security policy',
originalPrompt: 'This is the original user prompt',
}),
expect.any(Number),
);
});
// Verify streaming state transitions correctly
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should move pending history item before adding UserPromptSubmitBlocked event', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Partial response before block',
};
yield {
type: ServerGeminiEventType.UserPromptSubmitBlocked,
value: {
reason: 'Security violation detected',
originalPrompt: 'Execute system command',
},
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('Execute system command');
});
// Verify content was added first
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'gemini',
text: 'Partial response before block',
}),
expect.any(Number),
);
});
// Then verify blocked event was added
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'user_prompt_submit_blocked',
reason: 'Security violation detected',
originalPrompt: 'Execute system command',
}),
expect.any(Number),
);
});
});
});
describe('StopHookLoop Event', () => {
it('should handle StopHookLoop event and add stop hook loop history item', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.StopHookLoop,
value: {
iterationCount: 3,
reasons: [
'Reason 1: Continue analysis',
'Reason 2: More details needed',
'Reason 3: Incomplete response',
],
},
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('test query with stop hooks');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'stop_hook_loop',
iterationCount: 3,
reasons: [
'Reason 1: Continue analysis',
'Reason 2: More details needed',
'Reason 3: Incomplete response',
],
}),
expect.any(Number),
);
});
// Verify streaming state transitions correctly
expect(result.current.streamingState).toBe(StreamingState.Idle);
});
it('should move pending history item before adding StopHookLoop event', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.Content,
value: 'Initial response before loop',
};
yield {
type: ServerGeminiEventType.StopHookLoop,
value: {
iterationCount: 5,
reasons: ['Hook reason 1', 'Hook reason 2'],
},
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('query triggering stop hooks');
});
// Verify content was added first
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'gemini',
text: 'Initial response before loop',
}),
expect.any(Number),
);
});
// Then verify stop hook loop event was added
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'stop_hook_loop',
iterationCount: 5,
reasons: ['Hook reason 1', 'Hook reason 2'],
}),
expect.any(Number),
);
});
});
it('should handle single iteration StopHookLoop event', async () => {
mockSendMessageStream.mockReturnValue(
(async function* () {
yield {
type: ServerGeminiEventType.StopHookLoop,
value: {
iterationCount: 1,
reasons: ['Single hook execution'],
},
};
})(),
);
const { result } = renderTestHook();
await act(async () => {
await result.current.submitQuery('single iteration query');
});
await waitFor(() => {
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'stop_hook_loop',
iterationCount: 1,
reasons: ['Single hook execution'],
}),
expect.any(Number),
);
});
});
});
});

View file

@ -972,6 +972,48 @@ export const useGeminiStream = (
});
}, [handleLoopDetectionConfirmation]);
const handleUserPromptSubmitBlockedEvent = useCallback(
(
value: { reason: string; originalPrompt: string },
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{
type: 'user_prompt_submit_blocked',
reason: value.reason,
originalPrompt: value.originalPrompt,
} as HistoryItemWithoutId,
userMessageTimestamp,
);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
const handleStopHookLoopEvent = useCallback(
(
value: { iterationCount: number; reasons: string[] },
userMessageTimestamp: number,
) => {
if (pendingHistoryItemRef.current) {
addItem(pendingHistoryItemRef.current, userMessageTimestamp);
setPendingHistoryItem(null);
}
addItem(
{
type: 'stop_hook_loop',
iterationCount: value.iterationCount,
reasons: value.reasons,
} as HistoryItemWithoutId,
userMessageTimestamp,
);
},
[addItem, pendingHistoryItemRef, setPendingHistoryItem],
);
const processGeminiStreamEvents = useCallback(
async (
stream: AsyncIterable<GeminiEvent>,
@ -1061,6 +1103,15 @@ export const useGeminiStream = (
userMessageTimestamp,
);
break;
case ServerGeminiEventType.UserPromptSubmitBlocked:
handleUserPromptSubmitBlockedEvent(
event.value,
userMessageTimestamp,
);
break;
case ServerGeminiEventType.StopHookLoop:
handleStopHookLoopEvent(event.value, userMessageTimestamp);
break;
default: {
// enforces exhaustive switch-case
const unreachable: never = event;
@ -1089,6 +1140,8 @@ export const useGeminiStream = (
setThought,
pendingHistoryItemRef,
setPendingHistoryItem,
handleUserPromptSubmitBlockedEvent,
handleStopHookLoopEvent,
],
);

View file

@ -361,6 +361,26 @@ export type HistoryItemBtw = HistoryItemBase & {
btw: BtwProps;
};
/**
* UserPromptSubmit hook blocked event.
* Displayed when a UserPromptSubmit hook blocks the user's prompt.
*/
export type HistoryItemUserPromptSubmitBlocked = HistoryItemBase & {
type: 'user_prompt_submit_blocked';
reason: string;
originalPrompt: string;
};
/**
* Stop hook loop event.
* Displayed when Stop hooks create a loop, forcing the agent to continue.
*/
export type HistoryItemStopHookLoop = HistoryItemBase & {
type: 'stop_hook_loop';
iterationCount: number;
reasons: string[];
};
// Using Omit<HistoryItem, 'id'> seems to have some issues with typescript's
// type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that
// 'tools' in historyItem.
@ -395,7 +415,9 @@ export type HistoryItemWithoutId =
| HistoryItemArenaAgentComplete
| HistoryItemArenaSessionComplete
| HistoryItemInsightProgress
| HistoryItemBtw;
| HistoryItemBtw
| HistoryItemUserPromptSubmitBlocked
| HistoryItemStopHookLoop;
export type HistoryItem = HistoryItemWithoutId & { id: number };