Skip to content

Commit 8286128

Browse files
ross-rlclaude
andauthored
feat(examples): support Codex question tool (item/tool/requestUserInput) in combined-app (#142)
## Summary - **Support the Codex question tool end to end in the combined-app.** `item/tool/requestUserInput` (the agent asking the user something mid-turn) was already routable in the SDK but the app silently answered it with empty answers. Now: - `codex-manager` registers a handler **in every approval mode** (it's a question, not a permission gate), broadcasts a `user_input_request` WS event, and parks the JSON-RPC response until the user answers via the new `POST /api/user-input-response` route (values validated as string arrays). Pending questions resolve empty on shutdown. - New `UserInputPrompt` component renders each question's header/text, options as selectable buttons (description as tooltip), an "Other" free-text field when `isOther` (masked for `isSecret`, and always shown when a question has no options so it's never unanswerable), and a Submit gated on all questions being answered. - `requestTimeoutMs` now uses the 10-minute window in all modes (previously interactive-only) so the SDK doesn't auto-resolve the question with empty answers after 60s while the user is reading it. Note this also lengthens outgoing request timeouts in auto-approve mode, matching interactive mode's existing behavior. - **SDK:** export the `ToolRequestUserInput*` protocol types from the codex entry point (previously only reachable via the `v2` namespace). - **Combined-app:** default the Codex model to `gpt-5.6-sol` when the setup form leaves it empty; setup card hint/placeholder updated to match. ## Test plan - [x] Added SDK test covering the custom `item/tool/requestUserInput` handler round-trip (answers written back on the wire with the request id); default-response path was already covered. `bun test src/codex`: 40 pass. - [x] SDK typecheck (`tsc --project tsconfig.check.json`) and combined-app typecheck (`tsc --noEmit`) clean. - [x] `bun run lint` clean. - [ ] Manual: start a Codex session in the combined-app, trigger a `request_user_input` question, answer via option / Other text, confirm the turn continues with the chosen answer. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ff9b3e4 commit 8286128

11 files changed

Lines changed: 290 additions & 5 deletions

File tree

examples/combined-app/src/client/App.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3150,6 +3150,14 @@ html, body, #root {
31503150
line-height: 1.5;
31513151
}
31523152

3153+
.user-input-question {
3154+
margin-bottom: 16px;
3155+
}
3156+
3157+
.user-input-question .permission-actions {
3158+
margin-bottom: 8px;
3159+
}
3160+
31533161
.elicitation-title {
31543162
font-size: 12px;
31553163
font-weight: 600;

examples/combined-app/src/client/App.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { ControlsBar } from "./components/ControlsBar.js";
1616
import { AssistantTurn } from "./components/AssistantTurn.js";
1717
import { AttachmentBar } from "./components/AttachmentBar.js";
1818
import { ApprovalPrompt } from "./components/ApprovalPrompt.js";
19+
import { UserInputPrompt } from "./components/UserInputPrompt.js";
1920
import { ElicitationForm } from "./components/ElicitationForm.js";
2021
import { PermissionDialog } from "./components/PermissionDialog.js";
2122
import { ControlRequestPrompt } from "./components/ControlRequestPrompt.js";
@@ -569,6 +570,14 @@ export default function App() {
569570
/>
570571
)}
571572

573+
{agent.agentType === "codex" && agent.pendingUserInput && (
574+
<UserInputPrompt
575+
key={agent.pendingUserInput.requestId}
576+
userInput={agent.pendingUserInput}
577+
onRespond={agent.respondToUserInput}
578+
/>
579+
)}
580+
572581
{agent.agentType === "acp" && agent.pendingPermission && (
573582
<PermissionDialog
574583
permission={agent.pendingPermission}

examples/combined-app/src/client/components/SetupCard.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -121,12 +121,12 @@ export function SetupCard({
121121
<div className="form-group">
122122
<label>Model</label>
123123
<div className="form-hint">
124-
{agentType === "claude" ? "Claude model to use. Leave empty for default." : "Codex model to use. Leave empty for default."}
124+
{agentType === "claude" ? "Claude model to use. Leave empty for default." : "Codex model to use. Leave empty for the default (gpt-5.6-sol)."}
125125
</div>
126126
<input
127127
value={model}
128128
onChange={(e) => setModel(e.target.value)}
129-
placeholder={agentType === "claude" ? "claude-sonnet-4-20250514" : "gpt-5.1-codex"}
129+
placeholder={agentType === "claude" ? "claude-sonnet-4-20250514" : "gpt-5.6-sol"}
130130
disabled={connecting}
131131
/>
132132
</div>
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { useState } from "react";
2+
import type { PendingUserInput, UserInputQuestion } from "../types.js";
3+
4+
interface QuestionAnswer {
5+
selected?: string;
6+
other: string;
7+
}
8+
9+
// A question with no options must accept typed text even if isOther is unset,
10+
// or it would be unanswerable.
11+
function allowsFreeText(question: UserInputQuestion): boolean {
12+
return question.isOther || !question.options || question.options.length === 0;
13+
}
14+
15+
function answerValue(question: UserInputQuestion, answer: QuestionAnswer | undefined): string | null {
16+
if (!answer) return null;
17+
if (answer.selected != null) return answer.selected;
18+
if (allowsFreeText(question) && answer.other.trim()) return answer.other.trim();
19+
return null;
20+
}
21+
22+
export function UserInputPrompt({
23+
userInput,
24+
onRespond,
25+
}: {
26+
userInput: PendingUserInput;
27+
onRespond: (requestId: string, answers: Record<string, string[]>) => void;
28+
}) {
29+
const [answers, setAnswers] = useState<Record<string, QuestionAnswer>>({});
30+
31+
const setAnswer = (questionId: string, patch: Partial<QuestionAnswer>) => {
32+
setAnswers((prev) => ({
33+
...prev,
34+
[questionId]: { ...(prev[questionId] ?? { other: "" }), ...patch },
35+
}));
36+
};
37+
38+
const complete = userInput.questions.every((q) => answerValue(q, answers[q.id]) != null);
39+
40+
const submit = () => {
41+
const result: Record<string, string[]> = {};
42+
for (const q of userInput.questions) {
43+
const value = answerValue(q, answers[q.id]);
44+
if (value != null) result[q.id] = [value];
45+
}
46+
onRespond(userInput.requestId, result);
47+
};
48+
49+
return (
50+
<div className="elicitation-form">
51+
{userInput.questions.map((q) => {
52+
const answer = answers[q.id];
53+
return (
54+
<div key={q.id} className="user-input-question">
55+
<div className="elicitation-message">
56+
<strong>{q.header}</strong>{q.question}
57+
</div>
58+
{q.options && q.options.length > 0 && (
59+
<div className="permission-actions">
60+
{q.options.map((opt) => (
61+
<button
62+
key={opt.label}
63+
className={`btn permission-btn ${
64+
answer?.selected === opt.label ? "permission-btn-allow" : ""
65+
}`}
66+
title={opt.description}
67+
onClick={() => setAnswer(q.id, { selected: opt.label, other: "" })}
68+
>
69+
{opt.label}
70+
</button>
71+
))}
72+
</div>
73+
)}
74+
{allowsFreeText(q) && (
75+
<input
76+
className="elicitation-input"
77+
type={q.isSecret ? "password" : "text"}
78+
placeholder={q.options && q.options.length > 0 ? "Other…" : "Type your answer…"}
79+
value={answer?.other ?? ""}
80+
onChange={(e) =>
81+
setAnswer(q.id, { other: e.target.value, selected: undefined })
82+
}
83+
/>
84+
)}
85+
</div>
86+
);
87+
})}
88+
<div className="permission-actions">
89+
<button className="btn permission-btn permission-btn-allow" disabled={!complete} onClick={submit}>
90+
Submit
91+
</button>
92+
</div>
93+
</div>
94+
);
95+
}

examples/combined-app/src/client/hooks/useCodexAgent.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import type {
1717
ChatItem,
1818
UsageState,
1919
PendingApproval,
20+
PendingUserInput,
2021
AxonEventView,
2122
ToolCallBlock,
2223
CodexInitExtensions,
@@ -45,10 +46,12 @@ export interface UseCodexAgentReturn {
4546
timelineEvents: CodexTimelineEvent[];
4647
autoApprovePermissions: boolean;
4748
pendingApproval: PendingApproval | null;
49+
pendingUserInput: PendingUserInput | null;
4850
sendMessage: (text: string, content?: Array<{ type: string; [key: string]: unknown }>) => Promise<void>;
4951
cancel: () => Promise<void>;
5052
setAutoApprovePermissions: (enabled: boolean) => Promise<void>;
5153
respondToApproval: (requestId: string, approve: boolean) => Promise<void>;
54+
respondToUserInput: (requestId: string, answers: Record<string, string[]>) => Promise<void>;
5255
shutdown: () => Promise<void>;
5356
}
5457

@@ -65,6 +68,7 @@ interface CodexState {
6568
axonId: string | null;
6669
runloopUrl: string | null;
6770
pendingApproval: PendingApproval | null;
71+
pendingUserInput: PendingUserInput | null;
6872
autoApprovePermissions: boolean;
6973
axonEvents: AxonEventView[];
7074
timelineEvents: CodexTimelineEvent[];
@@ -83,6 +87,7 @@ const INITIAL_CODEX_STATE: CodexState = {
8387
axonId: null,
8488
runloopUrl: null,
8589
pendingApproval: null,
90+
pendingUserInput: null,
8691
autoApprovePermissions: true,
8792
axonEvents: [],
8893
timelineEvents: [],
@@ -475,6 +480,16 @@ export function useCodexAgent(agentId: string | null): UseCodexAgentReturn {
475480
} } });
476481
}
477482

483+
function handleUserInputRequest(
484+
requestId: string,
485+
request: Extract<ApprovalRequest, { method: "item/tool/requestUserInput" }>,
486+
): void {
487+
dispatch({ type: "SET", patch: { pendingUserInput: {
488+
requestId,
489+
questions: request.params.questions,
490+
} } });
491+
}
492+
478493
useEffect(() => {
479494
resetAllState();
480495

@@ -514,6 +529,8 @@ export function useCodexAgent(agentId: string | null): UseCodexAgentReturn {
514529

515530
if (parsed.type === "approval_request") {
516531
handleApprovalRequest(parsed.requestId, parsed.request);
532+
} else if (parsed.type === "user_input_request") {
533+
handleUserInputRequest(parsed.requestId, parsed.request);
517534
} else if (parsed.type === "turn_error") {
518535
finalizeTurn();
519536
dispatch({ type: "SET", patch: { error: parsed.error } });
@@ -579,6 +596,14 @@ export function useCodexAgent(agentId: string | null): UseCodexAgentReturn {
579596
}
580597
}, [agentId]);
581598

599+
const respondToUserInput = useCallback(async (requestId: string, answers: Record<string, string[]>) => {
600+
try {
601+
await api("/api/user-input-response", { agentId, requestId, answers });
602+
} finally {
603+
dispatch({ type: "SET", patch: { pendingUserInput: null } });
604+
}
605+
}, [agentId]);
606+
582607
const shutdown = useCallback(async () => {
583608
try { await api("/api/shutdown", { agentId }); } catch { /* ignore */ }
584609
wsRef.current?.close();
@@ -605,10 +630,12 @@ export function useCodexAgent(agentId: string | null): UseCodexAgentReturn {
605630
timelineEvents: s.timelineEvents,
606631
autoApprovePermissions: s.autoApprovePermissions,
607632
pendingApproval: s.pendingApproval,
633+
pendingUserInput: s.pendingUserInput,
608634
sendMessage,
609635
cancel,
610636
setAutoApprovePermissions,
611637
respondToApproval,
638+
respondToUserInput,
612639
shutdown,
613640
};
614641
}

examples/combined-app/src/client/types.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,27 @@ export interface PendingApproval {
340340
rawRequest: ApprovalRequest;
341341
}
342342

343+
// --- Codex-specific: question tool (item/tool/requestUserInput) ---
344+
345+
export interface UserInputOption {
346+
label: string;
347+
description: string;
348+
}
349+
350+
export interface UserInputQuestion {
351+
id: string;
352+
header: string;
353+
question: string;
354+
isOther: boolean;
355+
isSecret: boolean;
356+
options: UserInputOption[] | null;
357+
}
358+
359+
export interface PendingUserInput {
360+
requestId: string;
361+
questions: UserInputQuestion[];
362+
}
363+
343364
// --- ACP-specific: elicitation ---
344365

345366
export interface PendingElicitation {
@@ -476,6 +497,8 @@ export interface CodexAgentState extends SharedAgentState {
476497
threadId: string | null;
477498
pendingApproval: PendingApproval | null;
478499
respondToApproval: (requestId: string, approve: boolean) => Promise<void>;
500+
pendingUserInput: PendingUserInput | null;
501+
respondToUserInput: (requestId: string, answers: Record<string, string[]>) => Promise<void>;
479502
}
480503

481504
export interface IdleAgentState extends SharedAgentState {

0 commit comments

Comments
 (0)