feat: add support for Codex's request_user_input tool

Implement full support for the request_user_input tool with both Web UI and CLI
components. This includes:

- New view and footer components for request_user_input tool UI
- Tool registration in knownTools and view registries
- Nested answer format support (Record<string, { answers: string[] }>)
- Backward compatibility with flat answer format (Record<string, string[]>)
- Permission handler updates for CLI request_user_input acceptance
- Type definitions and schema updates across shared/cli/server/web packages
- Translation strings for request_user_input UI elements
- Conditional footer rendering in ToolCard for question tools
This commit is contained in:
weishu
2026-01-26 11:58:36 +08:00
parent 8137ce4f23
commit ae9650ca7c
18 changed files with 754 additions and 27 deletions
+70 -11
View File
@@ -28,7 +28,7 @@ interface PermissionResponse {
reason?: string;
mode?: PermissionMode;
allowTools?: string[];
answers?: Record<string, string[]>;
answers?: Record<string, string[]> | Record<string, { answers: string[] }>;
receivedAt?: number;
}
@@ -38,7 +38,25 @@ function isAskUserQuestionToolName(toolName: string): boolean {
return toolName === 'AskUserQuestion' || toolName === 'ask_user_question';
}
function formatAskUserQuestionAnswers(answers: Record<string, string[]>, input: unknown): string {
function isRequestUserInputToolName(toolName: string): boolean {
return toolName === 'request_user_input';
}
function isQuestionToolName(toolName: string): boolean {
return isAskUserQuestionToolName(toolName) || isRequestUserInputToolName(toolName);
}
function formatAskUserQuestionAnswers(answers: Record<string, string[]> | Record<string, { answers: string[] }>, input: unknown): string {
// Normalize nested format to flat format for display
const flatAnswers: Record<string, string[]> = {};
for (const [key, value] of Object.entries(answers)) {
if (Array.isArray(value)) {
flatAnswers[key] = value;
} else if (value && typeof value === 'object' && 'answers' in value) {
flatAnswers[key] = value.answers;
}
}
const questions = (() => {
if (!isObject(input)) return null;
const raw = input.questions;
@@ -46,7 +64,7 @@ function formatAskUserQuestionAnswers(answers: Record<string, string[]>, input:
return raw.filter((q) => isObject(q));
})();
const keys = Object.keys(answers).sort((a, b) => {
const keys = Object.keys(flatAnswers).sort((a, b) => {
const aNum = Number.parseInt(a, 10);
const bNum = Number.parseInt(b, 10);
if (Number.isFinite(aNum) && Number.isFinite(bNum)) return aNum - bNum;
@@ -63,7 +81,7 @@ function formatAskUserQuestionAnswers(answers: Record<string, string[]>, input:
: Number.isFinite(idx)
? `Question ${idx + 1}`
: `Question ${key}`;
const value = answers[key] ?? [];
const value = flatAnswers[key] ?? [];
const joined = value.map((v) => String(v)).filter((v) => v.trim().length > 0).join(', ');
return `${header}: ${joined || '(no answer)'}`;
});
@@ -82,7 +100,32 @@ function formatAskUserQuestionAnswers(answers: Record<string, string[]>, input:
: `User answered:\n${body}`;
}
function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record<string, string[]>): Record<string, unknown> {
function buildAskUserQuestionUpdatedInput(input: unknown, answers: Record<string, string[]> | Record<string, { answers: string[] }>): Record<string, unknown> {
// Normalize to flat format for AskUserQuestion
const flatAnswers: Record<string, string[]> = {};
for (const [key, value] of Object.entries(answers)) {
if (Array.isArray(value)) {
flatAnswers[key] = value;
} else if (value && typeof value === 'object' && 'answers' in value) {
flatAnswers[key] = value.answers;
}
}
if (!isObject(input)) {
return { answers: flatAnswers };
}
return {
...input,
answers: flatAnswers
};
}
/**
* Build updated input for request_user_input tool
* The answers format is nested: { answers: { [id]: { answers: string[] } } }
*/
function buildRequestUserInputUpdatedInput(input: unknown, answers: unknown): Record<string, unknown> {
if (!isObject(input)) {
return { answers };
}
@@ -138,7 +181,7 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
// Update allowed tools
if (response.allowTools && response.allowTools.length > 0) {
response.allowTools.forEach(tool => {
if (isAskUserQuestionToolName(tool)) {
if (isQuestionToolName(tool)) {
return;
}
if (tool.startsWith('Bash(') || tool === 'Bash') {
@@ -171,6 +214,22 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
return completion;
}
// Handle request_user_input
if (isRequestUserInputToolName(pending.toolName)) {
const answers = response.answers ?? {};
if (Object.keys(answers).length === 0) {
pending.resolve({ behavior: 'deny', message: 'No answers were provided.' });
completion.status = 'denied';
completion.reason = completion.reason ?? 'No answers were provided.';
} else {
pending.resolve({
behavior: 'allow',
updatedInput: buildRequestUserInputUpdatedInput(pending.input, answers)
});
}
return completion;
}
if (pending.toolName === 'exit_plan_mode' || pending.toolName === 'ExitPlanMode') {
// Handle exit_plan_mode specially
logger.debug('Plan mode result received', response);
@@ -202,10 +261,10 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
* Creates the canCallTool callback for the SDK
*/
handleToolCall = async (toolName: string, input: unknown, mode: EnhancedMode, options: { signal: AbortSignal }): Promise<PermissionResult> => {
const isAskUserQuestion = isAskUserQuestionToolName(toolName);
const isQuestionTool = isQuestionToolName(toolName);
// Check if tool is explicitly allowed
if (!isAskUserQuestion && toolName === 'Bash') {
if (!isQuestionTool && toolName === 'Bash') {
const inputObj = input as { command?: string };
if (inputObj?.command) {
// Check literal matches
@@ -219,7 +278,7 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
}
}
}
} else if (!isAskUserQuestion && this.allowedTools.has(toolName)) {
} else if (!isQuestionTool && this.allowedTools.has(toolName)) {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
@@ -230,11 +289,11 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
// Handle special cases
//
if (!isAskUserQuestion && this.permissionMode === 'bypassPermissions') {
if (!isQuestionTool && this.permissionMode === 'bypassPermissions') {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
if (!isAskUserQuestion && this.permissionMode === 'acceptEdits' && descriptor.edit) {
if (!isQuestionTool && this.permissionMode === 'acceptEdits' && descriptor.edit) {
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}
@@ -45,7 +45,7 @@ export type PermissionCompletion = {
mode?: string;
decision?: 'approved' | 'approved_for_session' | 'denied' | 'abort';
allowTools?: string[];
answers?: Record<string, string[]>;
answers?: Record<string, string[]> | Record<string, { answers: string[] }>;
};
export type CancelPendingRequestOptions = {