diff --git a/services/studio/src/nmp/studio/coding_agents.py b/services/studio/src/nmp/studio/coding_agents.py index e87cc6264d..b9693902cd 100644 --- a/services/studio/src/nmp/studio/coding_agents.py +++ b/services/studio/src/nmp/studio/coding_agents.py @@ -711,6 +711,7 @@ async def _request_permission(session_id: str, args: dict[str, Any]) -> dict[str try: decision = await asyncio.wait_for(future, timeout=300) except asyncio.TimeoutError: + await queue.put(("permission_expired", json.dumps({"request_id": request_id}))) return {"behavior": "deny", "message": "permission request timed out"} finally: _pending_permissions.pop(request_id, None) @@ -745,6 +746,7 @@ async def _request_agent_input(session_id: str, kind: str, args: dict[str, Any]) try: decision = await asyncio.wait_for(future, timeout=300) except asyncio.TimeoutError: + await queue.put(("input_expired", json.dumps({"request_id": request_id}))) return {"status": "error", "message": "input request timed out"} finally: _pending_agent_inputs.pop(request_id, None) @@ -864,6 +866,10 @@ async def _stream_claude( yield _sse(payload, event="permission_request") elif event_type == "input_request": yield _sse(payload, event="input_request") + elif event_type == "permission_expired": + yield _sse(payload, event="permission_expired") + elif event_type == "input_expired": + yield _sse(payload, event="input_expired") returncode = await proc.wait() if stderr_task is not None: diff --git a/web/packages/studio/src/components/agents/AgentBlockingInput/FilesetFileBlockingInput.tsx b/web/packages/studio/src/components/agents/AgentBlockingInput/FilesetFileBlockingInput.tsx index db1f7e4464..df202878ad 100644 --- a/web/packages/studio/src/components/agents/AgentBlockingInput/FilesetFileBlockingInput.tsx +++ b/web/packages/studio/src/components/agents/AgentBlockingInput/FilesetFileBlockingInput.tsx @@ -70,6 +70,7 @@ export const FilesetFileBlockingInput: FC = ( control, handleSubmit, setError, + watch, formState: { errors }, } = useForm({ defaultValues: { datasetFile: null }, @@ -77,6 +78,7 @@ export const FilesetFileBlockingInput: FC = ( disabled: isSubmitting, }); const acceptedFileTypes = getAcceptedFileTypes(input, defaultAcceptedFileTypes); + const datasetFile = watch('datasetFile'); const submit = handleSubmit((data) => { const parsed = @@ -98,6 +100,7 @@ export const FilesetFileBlockingInput: FC = ( request={request} secondaryActions={secondaryActions} secondaryActionLabel={secondaryActionLabel} + submitDisabled={!datasetFile} submitLabel={submitLabel} > diff --git a/web/packages/studio/src/components/agents/AgentDecisionInput/index.test.tsx b/web/packages/studio/src/components/agents/AgentDecisionInput/index.test.tsx index 25b308eb60..d624fbc033 100644 --- a/web/packages/studio/src/components/agents/AgentDecisionInput/index.test.tsx +++ b/web/packages/studio/src/components/agents/AgentDecisionInput/index.test.tsx @@ -116,7 +116,7 @@ describe('AgentDecisionInput', () => { ); }); - it('shows a send button for the text choice and submits the entered text', async () => { + it('shows a send button that is disabled while a text choice awaits input', async () => { const user = userEvent.setup(); const onSubmit = vi.fn(); @@ -129,13 +129,13 @@ describe('AgentDecisionInput', () => { /> ); - expect(screen.queryByRole('button', { name: /Send alternative instruction/i })).toBeNull(); + const sendButton = screen.getByRole('button', { name: /Send/i }); + expect(sendButton).not.toBeDisabled(); await user.click( screen.getByRole('option', { name: /3\.\s+Tell the Agent what to do differently/i }) ); - const sendButton = screen.getByRole('button', { name: /Send alternative instruction/i }); expect(sendButton).toBeDisabled(); await user.type( @@ -276,6 +276,39 @@ describe('AgentDecisionInput', () => { expect(onSubmit).toHaveBeenCalledWith({ id: 'no', label: 'No' }); }); + it('resets to the default choice when request.id changes', async () => { + const user = userEvent.setup(); + const onSubmit = vi.fn(); + + const { rerender } = render( + + ); + + // Navigate to the text-input choice on the first request + await user.click( + screen.getByRole('option', { name: /3\.\s+Tell the Agent what to do differently/i }) + ); + expect(screen.getByRole('button', { name: /Send/i })).toBeDisabled(); + + // Simulate a new request arriving (different id) — as if a queued permission dequeued + rerender( + + ); + + // Send button must be enabled immediately — no stale "alternative" selection + expect(screen.getByRole('button', { name: /Send/i })).not.toBeDisabled(); + }); + it('calls skip from the bottom action', async () => { const user = userEvent.setup(); const onSkip = vi.fn(); diff --git a/web/packages/studio/src/components/agents/AgentDecisionInput/index.tsx b/web/packages/studio/src/components/agents/AgentDecisionInput/index.tsx index 1ccb173df4..0bc6a39b67 100644 --- a/web/packages/studio/src/components/agents/AgentDecisionInput/index.tsx +++ b/web/packages/studio/src/components/agents/AgentDecisionInput/index.tsx @@ -367,19 +367,17 @@ export const AgentDecisionInput = ({ {skipLabel} ) : null} - {selectedChoiceInput ? ( - - ) : null} + diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx index 5a776eb97c..a2872379a8 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/ClaudeCodeChatThread.tsx @@ -171,6 +171,7 @@ export const ClaudeCodeChatThread: FC = ({ composerOverride={ studioNavigationRequest ? ( = ({ /> ) : decisionRequest ? ( { ); }); + it('calls onPermissionExpired when a permission_expired event arrives', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + [ + 'event: permission_expired', + 'data: {"request_id":"request-1"}', + '', + 'event: done', + 'data: ', + '', + ].join('\n'), + { status: 200 } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const onPermissionExpired = vi.fn(); + + await streamClaudeCodeMessage({ + sessionId: 'session-1', + message: 'list files', + signal: new AbortController().signal, + handlers: { + onClaudeEvent: vi.fn(), + onInputRequest: vi.fn(), + onPermissionRequest: vi.fn(), + onPermissionExpired, + onDone: vi.fn(), + onError: vi.fn(), + }, + }); + + expect(onPermissionExpired).toHaveBeenCalledWith('request-1'); + }); + + it('calls onInputExpired when an input_expired event arrives', async () => { + const fetchMock = vi + .fn() + .mockResolvedValue( + new Response( + [ + 'event: input_expired', + 'data: {"request_id":"request-2"}', + '', + 'event: done', + 'data: ', + '', + ].join('\n'), + { status: 200 } + ) + ); + vi.stubGlobal('fetch', fetchMock); + + const onInputExpired = vi.fn(); + + await streamClaudeCodeMessage({ + sessionId: 'session-1', + message: 'list files', + signal: new AbortController().signal, + handlers: { + onClaudeEvent: vi.fn(), + onInputRequest: vi.fn(), + onPermissionRequest: vi.fn(), + onInputExpired, + onDone: vi.fn(), + onError: vi.fn(), + }, + }); + + expect(onInputExpired).toHaveBeenCalledWith('request-2'); + }); + it('posts approval decisions using the backend permission shape', async () => { const fetchMock = vi.fn().mockResolvedValue(new Response('{}', { status: 200 })); vi.stubGlobal('fetch', fetchMock); diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts index 460f02b8b9..8493561f93 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/api.ts @@ -389,6 +389,22 @@ const handleSseEvent = ( return true; } + if (event.event === 'permission_expired') { + const payload = parseJsonObject(event.data); + if (isRecord(payload) && typeof payload.request_id === 'string') { + handlers.onPermissionExpired?.(payload.request_id); + } + return true; + } + + if (event.event === 'input_expired') { + const payload = parseJsonObject(event.data); + if (isRecord(payload) && typeof payload.request_id === 'string') { + handlers.onInputExpired?.(payload.request_id); + } + return true; + } + handlers.onClaudeEvent(parseJsonObject(event.data)); return true; }; diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts index 958dc8232d..6a682b66a7 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/types.ts @@ -5,6 +5,8 @@ export interface ClaudeCodeStreamHandlers { onClaudeEvent: (event: unknown) => void; onInputRequest: (request: ClaudeCodeInputRequest) => void; onPermissionRequest: (request: ClaudeCodePermissionRequest) => void; + onInputExpired?: (requestId: string) => void; + onPermissionExpired?: (requestId: string) => void; onDone: () => void; onError: (error: Error) => void; } diff --git a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts index bfa0128cb3..91c3ccc788 100644 --- a/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts +++ b/web/packages/studio/src/routes/agents/ClaudeCodeChatRoute/useClaudeCodeChatRuntime.ts @@ -243,6 +243,8 @@ type BlockingAction = | { type: 'set_input_status'; status: AgentDecisionInputStatus } | { type: 'clear_permission'; requestId?: string; dequeueNext?: boolean } | { type: 'clear_input'; requestId?: string; dequeueNext?: boolean } + | { type: 'expire_permission'; requestId: string } + | { type: 'expire_input'; requestId: string } | { type: 'reset' }; const INITIAL_BLOCKING_STATE: BlockingState = { @@ -328,6 +330,39 @@ function blockingReducer(state: BlockingState, action: BlockingAction): Blocking return action.dequeueNext ? withNextDequeued(cleared) : cleared; } + case 'expire_permission': { + const withoutQueued = { + ...state, + queue: state.queue.filter( + (q) => !(q.kind === 'permission' && q.request.requestId === action.requestId) + ), + }; + if (state.activePermission?.requestId !== action.requestId) return withoutQueued; + const cleared: BlockingState = { + ...withoutQueued, + activePermission: null, + activeDecision: null, + decisionStatus: 'pending', + }; + return withNextDequeued(cleared); + } + + case 'expire_input': { + const withoutQueued = { + ...state, + queue: state.queue.filter( + (q) => !(q.kind === 'input' && q.request.requestId === action.requestId) + ), + }; + if (state.activeInput?.requestId !== action.requestId) return withoutQueued; + const cleared: BlockingState = { + ...withoutQueued, + activeInput: null, + inputStatus: 'pending', + }; + return withNextDequeued(cleared); + } + case 'reset': return INITIAL_BLOCKING_STATE; } @@ -528,6 +563,14 @@ export const useClaudeCodeChatRuntime = (options?: UseClaudeCodeChatRuntimeOptio prepareForUserInput(); handleInputRequest(request); }, + onPermissionExpired: (requestId) => { + if (signal.aborted || !isCurrentRun()) return; + dispatchBlocking({ type: 'expire_permission', requestId }); + }, + onInputExpired: (requestId) => { + if (signal.aborted || !isCurrentRun()) return; + dispatchBlocking({ type: 'expire_input', requestId }); + }, onDone: () => { doneReceived = true; },