From b2bf2fece31c335e8c047cfbf121c5a51ea950a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Va=C5=A1ko?= Date: Mon, 11 May 2026 10:13:49 +0200 Subject: [PATCH 1/2] feat: add kai-agent backend support with new endpoints and approval flow Connects the client to the new kai-agent backend by adding methods for all new endpoints and replacing the v6 approval protocol with the dedicated POST /api/chat/{id}/approval side-channel. Co-Authored-By: Claude Sonnet 4.6 --- src/kai_client/__init__.py | 16 ++ src/kai_client/client.py | 221 +++++++++++++++- src/kai_client/models.py | 80 ++++++ tests/test_client.py | 518 +++++++++++++++++++++++++++++++++++++ 4 files changed, 831 insertions(+), 4 deletions(-) diff --git a/src/kai_client/__init__.py b/src/kai_client/__init__.py index ac375ab..d0e2a43 100644 --- a/src/kai_client/__init__.py +++ b/src/kai_client/__init__.py @@ -35,6 +35,8 @@ KaiTimeoutError, ) from kai_client.models import ( + AgentSettings, + ApprovalResponse, Chat, ChatDetail, ChatRequest, @@ -51,6 +53,8 @@ RequestContext, SSEEvent, StepStartEvent, + Suggestion, + SuggestionsResponse, TextEvent, TextPart, ToolApproval, @@ -58,11 +62,15 @@ ToolApprovalResponsePart, ToolCallEvent, ToolCallPart, + ToolInfo, ToolOutputErrorEvent, ToolResultPart, + ToolsListResponse, UnknownEvent, UsageEvent, UsageInfo, + UsageResponse, + UserAgentSettings, Vote, VoteRequest, ) @@ -111,6 +119,14 @@ "HistoryResponse", "Vote", "ErrorResponse", + "ApprovalResponse", + "UsageResponse", + "AgentSettings", + "UserAgentSettings", + "ToolInfo", + "ToolsListResponse", + "Suggestion", + "SuggestionsResponse", # SSE models "SSEEvent", "TextEvent", diff --git a/src/kai_client/client.py b/src/kai_client/client.py index 648bd74..4390c2f 100644 --- a/src/kai_client/client.py +++ b/src/kai_client/client.py @@ -4,7 +4,7 @@ from contextlib import asynccontextmanager from json import JSONDecodeError from types import TracebackType -from typing import Any, AsyncIterator, Optional +from typing import Any, AsyncIterator, Optional, Union import httpx @@ -15,6 +15,8 @@ raise_for_error_response, ) from kai_client.models import ( + AgentSettings, + ApprovalResponse, Chat, ChatDetail, ChatRequest, @@ -25,14 +27,22 @@ PingResponse, RequestContext, SSEEvent, + SuggestionsResponse, TextPart, + ToolInfo, ToolResultPart, + ToolsListResponse, + UsageResponse, + UserAgentSettings, Vote, VoteRequest, ) from kai_client.sse import parse_sse_stream from kai_client.types import VisibilityType, VoteType +# Sentinel for "caller did not pass this argument" — distinct from None (explicit clear). +_UNSET: Any = object() + def _normalize_visibility(visibility: str | VisibilityType) -> str: """Convert visibility to string value.""" @@ -461,6 +471,11 @@ async def send_tool_approval_response( """ Respond to a tool approval request (Vercel AI SDK v6 approval flow). + .. deprecated:: + Use :meth:`submit_approval` instead. This method uses the old + Vercel AI SDK v6 protocol (fetch-chat → mutate-message → re-POST) + which is not supported by the kai-agent backend. + This works by fetching the chat, finding the assistant message that contains the tool call requiring approval, updating its state to ``approval-responded``, and re-sending the updated assistant message @@ -570,6 +585,10 @@ async def approve_tool( """ Approve a pending tool call and continue the stream. + .. deprecated:: + Use :meth:`submit_approval` instead. This method uses the old + Vercel AI SDK v6 protocol not supported by the kai-agent backend. + Args: chat_id: The chat session ID. approval_id: The approval ID from the tool-call event's approval.id field. @@ -602,6 +621,11 @@ async def reject_tool( """ Reject a pending tool call and continue the stream. + .. deprecated:: + Use :meth:`submit_approval` with ``approved=False`` instead. This + method uses the old Vercel AI SDK v6 protocol not supported by the + kai-agent backend. + Args: chat_id: The chat session ID. approval_id: The approval ID from the tool-call event's approval.id field. @@ -636,9 +660,10 @@ async def send_tool_result( Send a tool result (legacy method, prefer send_tool_approval_response). .. deprecated:: - Use send_tool_approval_response / approve_tool / reject_tool instead. - This method uses the old tool-result protocol which can cause empty - user message errors with the Vercel AI SDK v6 backend. + Use :meth:`submit_approval` for the kai-agent backend. + Use send_tool_approval_response / approve_tool / reject_tool for the + old Vercel AI SDK v6 backend. This method uses the old tool-result + protocol which can cause empty user message errors. Args: chat_id: The chat session ID. @@ -743,6 +768,10 @@ async def resume_stream(self, chat_id: str) -> AsyncIterator[SSEEvent]: """ Resume a chat stream if one is available. + .. deprecated:: + The kai-agent backend does not expose a stream-resume endpoint. + This method is only functional against older backends. + This can be used to reconnect to an ongoing stream after a disconnect. Args: @@ -769,6 +798,45 @@ async def delete_chat(self, chat_id: str) -> None: """ await self._request("DELETE", "/api/chat", params={"id": chat_id}) + async def submit_approval( + self, + chat_id: str, + tool_use_id: str, + approved: bool, + *, + reason: Optional[str] = None, + updated_input: Optional[dict[str, Any]] = None, + answers: Optional[dict[str, Any]] = None, + ) -> ApprovalResponse: + """ + Approve or deny a pending tool call (kai-agent backend). + + This is a non-streaming call that unblocks the live SSE stream still + running from the original send_message call. The toolCallId from the + ``tool-input-available`` SSE event is the ``tool_use_id`` to pass here. + + Args: + chat_id: The chat session ID. + tool_use_id: The toolCallId from the tool-input-available SSE event. + approved: Whether to approve (True) or deny (False) the tool call. + reason: Optional reason for the decision. + updated_input: Optional modified tool input (only used when approved). + answers: Optional answers for interactive tool questions. + + Returns: + ApprovalResponse confirming the decision. + """ + payload: dict[str, Any] = {"toolUseId": tool_use_id, "approved": approved} + if reason is not None: + payload["reason"] = reason + if updated_input is not None: + payload["updatedInput"] = updated_input + if answers is not None: + payload["answers"] = answers + + response = await self._request("POST", f"/api/chat/{chat_id}/approval", json=payload) + return ApprovalResponse.model_validate(response.json()) + # ========================================================================= # History Endpoint # ========================================================================= @@ -919,6 +987,151 @@ async def downvote(self, chat_id: str, message_id: str) -> Vote: """ return await self.vote(chat_id, message_id, VoteType.DOWN) + # ========================================================================= + # Usage Endpoint + # ========================================================================= + + async def get_usage(self) -> UsageResponse: + """ + Get rate limit usage information for the current user and project. + + Returns: + UsageResponse with messages used, limit, and reset date. + """ + response = await self._request("GET", "/api/usage") + return UsageResponse.model_validate(response.json()) + + # ========================================================================= + # Settings Endpoints + # ========================================================================= + + async def get_settings(self) -> AgentSettings: + """ + Get project-level agent settings. + + Returns: + AgentSettings with custom instructions for the project. + """ + response = await self._request("GET", "/api/settings") + return AgentSettings.model_validate(response.json()) + + async def update_settings( + self, + *, + custom_instructions: Union[str, None] = _UNSET, + ) -> AgentSettings: + """ + Update project-level agent settings. + + Only fields explicitly passed are included in the request — omitting a + parameter leaves the corresponding server value unchanged. Pass + ``custom_instructions=None`` to explicitly clear instructions. + + Args: + custom_instructions: Custom system prompt instructions for the project. + Pass ``None`` to clear. Omit to leave unchanged. + + Returns: + Updated AgentSettings. + """ + payload: dict[str, Any] = {} + if custom_instructions is not _UNSET: + payload["customInstructions"] = custom_instructions + + response = await self._request("PATCH", "/api/settings", json=payload) + return AgentSettings.model_validate(response.json()) + + async def get_user_settings(self) -> UserAgentSettings: + """ + Get user-level agent settings. + + Returns: + UserAgentSettings with custom instructions and tool permissions. + """ + response = await self._request("GET", "/api/settings/user") + return UserAgentSettings.model_validate(response.json()) + + async def update_user_settings( + self, + *, + custom_instructions: Union[str, None] = _UNSET, + tool_permissions: Union[dict[str, str], None] = _UNSET, + ) -> UserAgentSettings: + """ + Update user-level agent settings. + + Only fields explicitly passed are included in the request — omitting a + parameter leaves the corresponding server value unchanged. + + - Pass ``custom_instructions=None`` to clear instructions. + - Pass ``tool_permissions=None`` to reset all permissions to defaults. + - Pass a dict to ``tool_permissions`` to merge with existing values. + + Valid permission values: ``"always_allow"``, ``"always_ask"``, ``"blocked"``. + + Args: + custom_instructions: Custom system prompt instructions for this user. + Pass ``None`` to clear. Omit to leave unchanged. + tool_permissions: Dict mapping tool name to permission value. + Pass ``None`` to reset to defaults. Omit to leave unchanged. + + Raises: + ValueError: If no fields are provided (nothing to update). + + Returns: + Updated UserAgentSettings. + """ + payload: dict[str, Any] = {} + if custom_instructions is not _UNSET: + payload["customInstructions"] = custom_instructions + if tool_permissions is not _UNSET: + payload["toolPermissions"] = tool_permissions + + if not payload: + raise ValueError( + "update_user_settings() requires at least one argument; " + "call with explicit values or None to clear individual fields." + ) + + response = await self._request("PATCH", "/api/settings/user", json=payload) + return UserAgentSettings.model_validate(response.json()) + + async def get_tools(self) -> ToolsListResponse: + """ + List all available MCP tools with their metadata. + + Returns: + ToolsListResponse with tool names, descriptions, and read-only flags. + """ + response = await self._request("GET", "/api/settings/tools") + data = response.json() + tools = [ToolInfo.model_validate(t) for t in data.get("tools", [])] + return ToolsListResponse(tools=tools) + + # ========================================================================= + # Suggestions Endpoint + # ========================================================================= + + async def get_suggestions( + self, + context: str, + data: dict[str, Any], + ) -> SuggestionsResponse: + """ + Generate contextual AI suggestions. + + Args: + context: The context type — one of ``"dashboard"``, + ``"job-detail"``, or ``"configuration-detail"``. + data: Context-specific data payload. + + Returns: + SuggestionsResponse with a list of suggestions and session ID. + """ + payload: dict[str, Any] = {"context": context, "data": data} + response = await self._request("POST", "/api/suggestions", json=payload) + return SuggestionsResponse.model_validate(response.json()) + # ========================================================================= # Convenience Methods # ========================================================================= diff --git a/src/kai_client/models.py b/src/kai_client/models.py index 4bb66ae..c0fcbb5 100644 --- a/src/kai_client/models.py +++ b/src/kai_client/models.py @@ -225,6 +225,86 @@ class ErrorResponse(BaseModel): cause: Optional[str] = None +class ApprovalResponse(BaseModel): + """Response from the approval endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + success: bool + tool_use_id: str = Field(alias="toolUseId") + approved: bool + + +class UsageResponse(BaseModel): + """Response from the usage endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + messages_used: int = Field(alias="messagesUsed") + messages_limit: int = Field(alias="messagesLimit") + reset_date: datetime = Field(alias="resetDate") + + +class AgentSettings(BaseModel): + """Project-level agent settings.""" + + model_config = ConfigDict(populate_by_name=True) + + project_id: str = Field(alias="projectId") + custom_instructions: Optional[str] = Field(default=None, alias="customInstructions") + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + + +class UserAgentSettings(BaseModel): + """User-level agent settings including tool permissions.""" + + model_config = ConfigDict(populate_by_name=True) + + project_id: str = Field(alias="projectId") + user_id: str = Field(alias="userId") + custom_instructions: Optional[str] = Field(default=None, alias="customInstructions") + tool_permissions: Optional[dict[str, str]] = Field(default=None, alias="toolPermissions") + created_at: datetime = Field(alias="createdAt") + updated_at: datetime = Field(alias="updatedAt") + + +class ToolInfo(BaseModel): + """Information about an available MCP tool.""" + + model_config = ConfigDict(populate_by_name=True) + + name: str + description: str + read_only: bool = Field(alias="readOnly") + + +class ToolsListResponse(BaseModel): + """Response from the tools list endpoint.""" + + tools: list[ToolInfo] + + +class Suggestion(BaseModel): + """A contextual suggestion generated by the AI.""" + + id: str + label: str + prompt: str + priority: int + category: str + reasoning: str + + +class SuggestionsResponse(BaseModel): + """Response from the suggestions endpoint.""" + + model_config = ConfigDict(populate_by_name=True) + + suggestions: list[Suggestion] + suggestion_session_id: str = Field(alias="suggestionSessionId") + + # ============================================================================= # SSE Event Models # ============================================================================= diff --git a/tests/test_client.py b/tests/test_client.py index 635b253..b63ba00 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -1532,3 +1532,521 @@ async def test_write_tool_requires_approval( assert pending_tools[0].input is not None +# ============================================================================= +# Tests for new kai-agent backend endpoints +# ============================================================================= + +CHAT_ID = "550e8400-e29b-41d4-a716-446655440000" +TOOL_USE_ID = "tool-use-abc123" + + +class TestSubmitApproval: + """Tests for submit_approval — POST /api/chat/{id}/approval.""" + + @pytest.mark.asyncio + async def test_submit_approval_approved(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=f"http://localhost:3000/api/chat/{CHAT_ID}/approval", + method="POST", + json={"success": True, "toolUseId": TOOL_USE_ID, "approved": True}, + ) + + async with client: + result = await client.submit_approval(CHAT_ID, TOOL_USE_ID, approved=True) + + assert result.success is True + assert result.tool_use_id == TOOL_USE_ID + assert result.approved is True + + @pytest.mark.asyncio + async def test_submit_approval_denied(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=f"http://localhost:3000/api/chat/{CHAT_ID}/approval", + method="POST", + json={"success": True, "toolUseId": TOOL_USE_ID, "approved": False}, + ) + + async with client: + result = await client.submit_approval( + CHAT_ID, TOOL_USE_ID, approved=False, reason="Too risky" + ) + + assert result.approved is False + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["approved"] is False + assert body["reason"] == "Too risky" + + @pytest.mark.asyncio + async def test_submit_approval_with_optional_fields( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url=f"http://localhost:3000/api/chat/{CHAT_ID}/approval", + method="POST", + json={"success": True, "toolUseId": TOOL_USE_ID, "approved": True}, + ) + + async with client: + await client.submit_approval( + CHAT_ID, + TOOL_USE_ID, + approved=True, + updated_input={"name": "updated-bucket"}, + answers={"confirm": "yes"}, + ) + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["updatedInput"] == {"name": "updated-bucket"} + assert body["answers"] == {"confirm": "yes"} + + @pytest.mark.asyncio + async def test_submit_approval_omits_none_fields( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url=f"http://localhost:3000/api/chat/{CHAT_ID}/approval", + method="POST", + json={"success": True, "toolUseId": TOOL_USE_ID, "approved": True}, + ) + + async with client: + await client.submit_approval(CHAT_ID, TOOL_USE_ID, approved=True) + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert "reason" not in body + assert "updatedInput" not in body + assert "answers" not in body + + @pytest.mark.asyncio + async def test_submit_approval_includes_auth_headers( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url=f"http://localhost:3000/api/chat/{CHAT_ID}/approval", + method="POST", + json={"success": True, "toolUseId": TOOL_USE_ID, "approved": True}, + ) + + async with client: + await client.submit_approval(CHAT_ID, TOOL_USE_ID, approved=True) + + request = httpx_mock.get_request() + assert request.headers["x-storageapi-token"] == "test-token" + assert "connection.test.keboola.com" in request.headers["x-storageapi-url"] + + @pytest.mark.asyncio + async def test_submit_approval_not_found(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url=f"http://localhost:3000/api/chat/{CHAT_ID}/approval", + method="POST", + status_code=404, + json={ + "code": "not_found:chat", + "message": "No pending approval found for this tool call.", + }, + ) + + async with client: + with pytest.raises(KaiNotFoundError): + await client.submit_approval(CHAT_ID, TOOL_USE_ID, approved=True) + + +class TestGetUsage: + """Tests for get_usage — GET /api/usage.""" + + @pytest.mark.asyncio + async def test_get_usage_success(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/usage", + json={ + "messagesUsed": 42, + "messagesLimit": 500, + "resetDate": "2026-06-01T00:00:00.000Z", + }, + ) + + async with client: + usage = await client.get_usage() + + assert usage.messages_used == 42 + assert usage.messages_limit == 500 + assert usage.reset_date.year == 2026 + assert usage.reset_date.month == 6 + + @pytest.mark.asyncio + async def test_get_usage_includes_auth(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/usage", + json={ + "messagesUsed": 0, + "messagesLimit": 500, + "resetDate": "2026-06-01T00:00:00.000Z", + }, + ) + + async with client: + await client.get_usage() + + request = httpx_mock.get_request() + assert "x-storageapi-token" in request.headers + + +class TestSettings: + """Tests for project-level settings — GET/PATCH /api/settings.""" + + _settings_payload = { + "projectId": "proj-123", + "customInstructions": "Always be concise.", + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-05-01T00:00:00.000Z", + } + + @pytest.mark.asyncio + async def test_get_settings_success(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/settings", + json=self._settings_payload, + ) + + async with client: + settings = await client.get_settings() + + assert settings.project_id == "proj-123" + assert settings.custom_instructions == "Always be concise." + + @pytest.mark.asyncio + async def test_get_settings_null_instructions( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url="http://localhost:3000/api/settings", + json={**self._settings_payload, "customInstructions": None}, + ) + + async with client: + settings = await client.get_settings() + + assert settings.custom_instructions is None + + @pytest.mark.asyncio + async def test_update_settings_with_instructions( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url="http://localhost:3000/api/settings", + method="PATCH", + json={**self._settings_payload, "customInstructions": "Be brief."}, + ) + + async with client: + settings = await client.update_settings(custom_instructions="Be brief.") + + assert settings.custom_instructions == "Be brief." + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["customInstructions"] == "Be brief." + + @pytest.mark.asyncio + async def test_update_settings_clear_instructions( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + """Passing custom_instructions=None explicitly clears the field.""" + httpx_mock.add_response( + url="http://localhost:3000/api/settings", + method="PATCH", + json={**self._settings_payload, "customInstructions": None}, + ) + + async with client: + settings = await client.update_settings(custom_instructions=None) + + assert settings.custom_instructions is None + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["customInstructions"] is None + + @pytest.mark.asyncio + async def test_update_settings_no_args_sends_empty_payload( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + """Omitting all args is a no-op — sends empty payload, leaves server state unchanged.""" + httpx_mock.add_response( + url="http://localhost:3000/api/settings", + method="PATCH", + json=self._settings_payload, + ) + + async with client: + await client.update_settings() + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert "customInstructions" not in body + + +class TestUserSettings: + """Tests for user-level settings — GET/PATCH /api/settings/user.""" + + _user_settings_payload = { + "projectId": "proj-123", + "userId": "user-456", + "customInstructions": None, + "toolPermissions": {"create_config": "always_ask"}, + "createdAt": "2026-01-01T00:00:00.000Z", + "updatedAt": "2026-05-01T00:00:00.000Z", + } + + @pytest.mark.asyncio + async def test_get_user_settings_success(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/settings/user", + json=self._user_settings_payload, + ) + + async with client: + settings = await client.get_user_settings() + + assert settings.user_id == "user-456" + assert settings.tool_permissions == {"create_config": "always_ask"} + + @pytest.mark.asyncio + async def test_update_user_settings_tool_permissions( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + updated = { + **self._user_settings_payload, + "toolPermissions": {"create_config": "always_allow", "run_job": "blocked"}, + } + httpx_mock.add_response( + url="http://localhost:3000/api/settings/user", + method="PATCH", + json=updated, + ) + + async with client: + settings = await client.update_user_settings( + tool_permissions={"create_config": "always_allow", "run_job": "blocked"} + ) + + assert settings.tool_permissions["run_job"] == "blocked" + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["toolPermissions"]["create_config"] == "always_allow" + + @pytest.mark.asyncio + async def test_update_user_settings_custom_instructions_only( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url="http://localhost:3000/api/settings/user", + method="PATCH", + json={**self._user_settings_payload, "customInstructions": "Speak formally."}, + ) + + async with client: + await client.update_user_settings(custom_instructions="Speak formally.") + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["customInstructions"] == "Speak formally." + assert "toolPermissions" not in body + + @pytest.mark.asyncio + async def test_update_user_settings_null_permissions_reset( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + """Passing tool_permissions=None explicitly resets permissions.""" + httpx_mock.add_response( + url="http://localhost:3000/api/settings/user", + method="PATCH", + json={**self._user_settings_payload, "toolPermissions": None}, + ) + + async with client: + settings = await client.update_user_settings(tool_permissions=None) + + assert settings.tool_permissions is None + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body.get("toolPermissions") is None + + @pytest.mark.asyncio + async def test_update_user_settings_clear_custom_instructions( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + """Passing custom_instructions=None explicitly clears the field.""" + httpx_mock.add_response( + url="http://localhost:3000/api/settings/user", + method="PATCH", + json={**self._user_settings_payload, "customInstructions": None}, + ) + + async with client: + await client.update_user_settings(custom_instructions=None) + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["customInstructions"] is None + assert "toolPermissions" not in body + + @pytest.mark.asyncio + async def test_update_user_settings_no_args_raises(self, client: KaiClient): + """Calling with no arguments raises ValueError.""" + with pytest.raises(ValueError, match="at least one argument"): + await client.update_user_settings() + + +class TestGetTools: + """Tests for get_tools — GET /api/settings/tools.""" + + @pytest.mark.asyncio + async def test_get_tools_success(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/settings/tools", + json={ + "tools": [ + {"name": "get_tables", "description": "List tables", "readOnly": True}, + {"name": "create_config", "description": "Create config", "readOnly": False}, + ] + }, + ) + + async with client: + result = await client.get_tools() + + assert len(result.tools) == 2 + assert result.tools[0].name == "get_tables" + assert result.tools[0].read_only is True + assert result.tools[1].name == "create_config" + assert result.tools[1].read_only is False + + @pytest.mark.asyncio + async def test_get_tools_empty_list(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/settings/tools", + json={"tools": []}, + ) + + async with client: + result = await client.get_tools() + + assert result.tools == [] + + @pytest.mark.asyncio + async def test_get_tools_includes_auth(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/settings/tools", + json={"tools": []}, + ) + + async with client: + await client.get_tools() + + request = httpx_mock.get_request() + assert "x-storageapi-token" in request.headers + + +class TestGetSuggestions: + """Tests for get_suggestions — POST /api/suggestions.""" + + _suggestion = { + "id": "550e8400-e29b-41d4-a716-446655440001", + "label": "Fix failing job", + "prompt": "Help me fix the failing extraction job", + "priority": 1, + "category": "error", + "reasoning": "Job has been failing repeatedly", + } + + @pytest.mark.asyncio + async def test_get_suggestions_success(self, client: KaiClient, httpx_mock: HTTPXMock): + session_id = "550e8400-e29b-41d4-a716-446655440002" + httpx_mock.add_response( + url="http://localhost:3000/api/suggestions", + method="POST", + json={"suggestions": [self._suggestion], "suggestionSessionId": session_id}, + ) + + async with client: + result = await client.get_suggestions( + context="job-detail", data={"jobId": "job-123", "status": "error"} + ) + + assert len(result.suggestions) == 1 + assert result.suggestions[0].label == "Fix failing job" + assert result.suggestions[0].category == "error" + assert result.suggestion_session_id == session_id + + @pytest.mark.asyncio + async def test_get_suggestions_sends_correct_payload( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + httpx_mock.add_response( + url="http://localhost:3000/api/suggestions", + method="POST", + json={ + "suggestions": [], + "suggestionSessionId": "550e8400-e29b-41d4-a716-446655440003", + }, + ) + + async with client: + await client.get_suggestions( + context="dashboard", data={"projectId": "proj-123"} + ) + + request = httpx_mock.get_request() + body = json.loads(request.content) + assert body["context"] == "dashboard" + assert body["data"] == {"projectId": "proj-123"} + + @pytest.mark.asyncio + async def test_get_suggestions_empty_result(self, client: KaiClient, httpx_mock: HTTPXMock): + httpx_mock.add_response( + url="http://localhost:3000/api/suggestions", + method="POST", + json={ + "suggestions": [], + "suggestionSessionId": "550e8400-e29b-41d4-a716-446655440004", + }, + ) + + async with client: + result = await client.get_suggestions(context="dashboard", data={}) + + assert result.suggestions == [] + + @pytest.mark.asyncio + async def test_get_suggestions_context_variants( + self, client: KaiClient, httpx_mock: HTTPXMock + ): + contexts = ("dashboard", "job-detail", "configuration-detail") + for _ in contexts: + httpx_mock.add_response( + url="http://localhost:3000/api/suggestions", + method="POST", + json={ + "suggestions": [], + "suggestionSessionId": "550e8400-e29b-41d4-a716-446655440005", + }, + ) + + async with client: + for context in contexts: + await client.get_suggestions(context=context, data={}) + + requests = httpx_mock.get_requests() + assert len(requests) == len(contexts) + for req, context in zip(requests, contexts): + body = json.loads(req.content) + assert body["context"] == context + + From 063bef974845718e1e1fafc58f34e73cb7c16ccc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Va=C5=A1ko?= Date: Mon, 11 May 2026 17:17:24 +0200 Subject: [PATCH 2/2] fix: allow non-dict tool output values in ToolCallEvent kai-agent sends output: "denied" (string) when a tool approval is rejected; the previous dict-only type caused a pydantic ValidationError. Co-Authored-By: Claude Sonnet 4.6 --- src/kai_client/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/kai_client/models.py b/src/kai_client/models.py index c0fcbb5..6eab732 100644 --- a/src/kai_client/models.py +++ b/src/kai_client/models.py @@ -352,7 +352,7 @@ class ToolCallEvent(BaseSSEEvent): tool_name: Optional[str] = Field(default=None, alias="toolName") state: str input: Optional[dict[str, Any]] = None - output: Optional[dict[str, Any]] = None + output: Optional[Any] = None approval: Optional[ToolApproval] = None