-
Notifications
You must be signed in to change notification settings - Fork 13.5k
feat(cli): show acknowledgment when user steering hint is processed #26498
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 5 commits
c4d6009
9e83545
0351b52
e81028a
698fcc8
1470105
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,3 +1,4 @@ | ||
| {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"Starting a long task. First, I'll list the files."},{"functionCall":{"name":"list_directory","args":{"dir_path":"."}}}]},"finishReason":"STOP"}]}]} | ||
| {"method":"generateContent","response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Understood. I'll focus on .txt files."}]},"finishReason":"STOP"}]}} | ||
| {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I see the files. Since you want me to focus on .txt files, I will read file1.txt."},{"functionCall":{"name":"read_file","args":{"file_path":"file1.txt"}}}]},"finishReason":"STOP"}]}]} | ||
| {"method":"generateContentStream","response":[{"candidates":[{"content":{"role":"model","parts":[{"text":"I have read file1.txt. Task complete."}]},"finishReason":"STOP"}]}]} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -34,6 +34,7 @@ import { | |
| CoreEvent, | ||
| CoreToolCallStatus, | ||
| buildUserSteeringHintPrompt, | ||
| generateSteeringAckMessage, | ||
| GeminiCliOperation, | ||
| getPlanModeExitMessage, | ||
| isBackgroundExecutionData, | ||
|
|
@@ -1994,13 +1995,18 @@ export const useGeminiStream = ( | |
| (toolCall) => toolCall.response.responseParts, | ||
| ); | ||
|
|
||
| let pendingSteeringAck: { | ||
| hintText: string; | ||
| ackTimestamp: number; | ||
| } | null = null; | ||
| if (consumeUserHint) { | ||
| const userHint = consumeUserHint(); | ||
| if (userHint && userHint.trim().length > 0) { | ||
| const hintText = userHint.trim(); | ||
| responsesToSend.unshift({ | ||
| text: buildUserSteeringHintPrompt(hintText), | ||
| }); | ||
| pendingSteeringAck = { hintText, ackTimestamp: Date.now() }; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| } | ||
| } | ||
|
|
||
|
|
@@ -2019,6 +2025,36 @@ export const useGeminiStream = ( | |
| return; | ||
| } | ||
|
|
||
| if (pendingSteeringAck) { | ||
| const { hintText, ackTimestamp } = pendingSteeringAck; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| // Defer until after submitQuery below has installed the new | ||
| // turn's AbortController; the signal captured here belongs to | ||
| // the continuation turn, not the one that just finished. | ||
| queueMicrotask(() => { | ||
| const signal = abortControllerRef.current?.signal; | ||
| void generateSteeringAckMessage(config.getBaseLlmClient(), hintText, { | ||
| signal, | ||
| }) | ||
|
Comment on lines
+2033
to
+2036
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The const ackTimestamp = Date.now();
queueMicrotask(() => {
const signal = abortControllerRef.current?.signal;
void generateSteeringAckMessage(config.getBaseLlmClient(), hintText, {
signal,
})
.then((ackText) => {
if (signal?.aborted) return;
addItem(
{
type: MessageType.INFO,
icon: '· ',
color: theme.text.secondary,
marginBottom: 1,
text: ackText,
} as HistoryItemInfo,
ackTimestamp,
);
})
.catch((err) => {
if (err?.name === 'AbortError') return;
// Silently ignore — steering ack is non-critical UI feedback.
});
});References
|
||
| .then((ackText) => { | ||
| if (signal?.aborted || turnCancelledRef.current) return; | ||
| addItem( | ||
| { | ||
| type: MessageType.INFO, | ||
| icon: '· ', | ||
| color: theme.text.secondary, | ||
| marginBottom: 1, | ||
| text: ackText, | ||
| } as HistoryItemInfo, | ||
| ackTimestamp, | ||
|
Comment on lines
+2031
to
+2047
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Capturing queueMicrotask(() => {
const ackTimestamp = Date.now();
const signal = abortControllerRef.current?.signal;
void generateSteeringAckMessage(config.getBaseLlmClient(), hintText, {
signal,
})
.then((ackText) => {
if (signal?.aborted || turnCancelledRef.current) return;
addItem(
{
type: MessageType.INFO,
icon: '· ',
color: theme.text.secondary,
marginBottom: 1,
text: ackText,
} as HistoryItemInfo,
ackTimestamp,References
|
||
| ); | ||
| }) | ||
| .catch((err) => { | ||
| if (err?.name === 'AbortError') return; | ||
| // Silently ignore — steering ack is non-critical UI feedback. | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | ||
| submitQuery( | ||
| responsesToSend, | ||
|
|
@@ -2041,6 +2077,7 @@ export const useGeminiStream = ( | |
| maybeAddSuppressedToolErrorNote, | ||
| maybeAddLowVerbosityFailureNote, | ||
| setIsResponding, | ||
| config, | ||
| ], | ||
| ); | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -57,11 +57,23 @@ export const USER_STEERING_INSTRUCTION = | |||||||||||||||||||||||||||||||||||||||||||||||||
| 'Do not cancel/skip tasks unless the user explicitly cancels them. ' + | ||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Acknowledge the steering briefly and state the course correction.'; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||||||||||||||||||
| * Wraps user input in XML-like tags to mitigate prompt injection. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const XML_CLOSING_TAG_RE = /<\/([^>]+)>/gi; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const CONTEXT_BREAKER_RE = /\]/g; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const NEWLINE_RE = /\r?\n/g; | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| function sanitizeForWrapper(input: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return input | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(XML_CLOSING_TAG_RE, '<\\/$1>') | ||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(CONTEXT_BREAKER_RE, '\\]') | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The implementation escapes the ] character instead of removing it. According to the repository rules, context-breaking characters like ] should be removed when sanitizing data from LLM-driven tools to prevent prompt injection. Escaping may not be sufficient depending on how the model parses the input.
Suggested change
References
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(NEWLINE_RE, ' '); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+64
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The sanitization logic for XML closing tags and context breakers is currently ineffective. In JavaScript string literals, the backslash is an escape character. For characters that do not have a special escape sequence (like As a result:
To ensure a literal backslash is included in the replacement string (to escape the character for the LLM), you must use double backslashes in the string literal (e.g., '\\' to represent a single '\' in the string). Note that '\\n' is already correctly handled because '\n' is a special escape sequence for a newline, so '\\n' correctly produces a literal backslash followed by an 'n'.
Suggested change
References
Comment on lines
+64
to
+69
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Suggested change
References
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| function wrapInput(input: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return `<user_input>\n${input}\n</user_input>`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return `<user_input>\n${sanitizeForWrapper(input)}\n</user_input>`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| function wrapBackgroundOutput(input: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return `<background_output>\n${sanitizeForWrapper(input)}\n</background_output>`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| export function buildUserSteeringHintPrompt(hintText: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -88,7 +100,7 @@ const BACKGROUND_COMPLETION_INSTRUCTION = | |||||||||||||||||||||||||||||||||||||||||||||||||
| * Wraps untrusted output in XML tags with inline instructions to treat it as data. | ||||||||||||||||||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||||||||||||||||||
| export function formatBackgroundCompletionForModel(output: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return `Background execution update:\n<background_output>\n${output}\n</background_output>\n\n${BACKGROUND_COMPLETION_INSTRUCTION}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return `Background execution update:\n${wrapBackgroundOutput(output)}\n\n${BACKGROUND_COMPLETION_INSTRUCTION}`; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const STEERING_ACK_INSTRUCTION = | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -113,15 +125,23 @@ function buildSteeringFallbackMessage(hintText: string): string { | |||||||||||||||||||||||||||||||||||||||||||||||||
| export async function generateSteeringAckMessage( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| llmClient: BaseLlmClient, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| hintText: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| options?: { signal?: AbortSignal }, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<string> { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const fallbackText = buildSteeringFallbackMessage(hintText); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| if (options?.signal?.aborted) { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return fallbackText; | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const abortController = new AbortController(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| const timeout = setTimeout( | ||||||||||||||||||||||||||||||||||||||||||||||||||
| () => abortController.abort(), | ||||||||||||||||||||||||||||||||||||||||||||||||||
| STEERING_ACK_TIMEOUT_MS, | ||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| const onParentAbort = () => abortController.abort(); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| options?.signal?.addEventListener('abort', onParentAbort, { once: true }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| return await generateFastAckText(llmClient, { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| instruction: STEERING_ACK_INSTRUCTION, | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -134,6 +154,7 @@ export async function generateSteeringAckMessage( | |||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } finally { | ||||||||||||||||||||||||||||||||||||||||||||||||||
| clearTimeout(timeout); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| options?.signal?.removeEventListener('abort', onParentAbort); | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
ackTimestampshould be captured inside thequeueMicrotask(line 2033) rather than here. Capturing it now results in a timestamp that is less than or equal to the one generated for the user hint insidesubmitQuery(line 1578). This can cause the acknowledgment to be rendered above the hint in the UI history if the history manager sorts by timestamp. Removing it from thependingSteeringAckobject simplifies the state management.