qwen-code/packages/sdk-python/tests/unit/test_validation.py
juhuan 8e6a572562
Some checks are pending
E2E Tests / web-shell Browser Regression (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
SDK Python / Classify PR (push) Waiting to run
SDK Python / SDK Python (3.10) (push) Blocked by required conditions
SDK Python / SDK Python (3.11) (push) Blocked by required conditions
SDK Python / SDK Python (3.12) (push) Blocked by required conditions
feat(sdk): add control request methods for effort, models, usage, context (#6492)
* feat(sdk): add control request methods for effort, models, usage, context

Add 4 control request methods across CLI, Python SDK, and TypeScript SDK:

- set_effort: Set reasoning effort tier (low/medium/high/xhigh/max) at
  runtime via config.setReasoningEffort(), also accept initial effort
  in initialize payload
- get_available_models: Return models available for current auth type
  via config.getAvailableModels()
- get_usage_info: Return usage dashboard data via loadUsageDashboard()
  with optional range filter (today/week/month/all)
- get_context_usage: Add Python SDK method (TS SDK already has it)

CLI: Add request types, dispatcher routing, and SystemController handlers
with capability flags (can_set_effort, can_get_available_models,
can_get_usage_info).

TS SDK: Add ControlRequestType enum values, protocol interfaces,
QueryOptions.effort field, Zod schema, and Query methods.
Python SDK: Add Effort type, validation, protocol TypedDicts, and
Query methods.

* fix(sdk): address CI feedback for control request methods

- Fix TransportOptions.effort type to literal union instead of string
- Fix Python CLIControlGetUsageInfoRequest.range to Literal type
- Remove redundant signal.aborted checks in handleGetUsageInfo
- Add Python SDK tests for set_effort, get_available_models, get_context_usage, get_usage_info, effort in initialize
- Add TS SDK tests for setEffort, getAvailableModels, getUsageInfo

* fix(sdk): address review feedback for control request methods

- Remove dead effort field from TransportOptions (flows via QueryOptions)
- Throw on invalid effort in handleInitialize (matching handleSetEffort)
- Wrap setReasoningEffort in try/catch (matching addMcpServers pattern)
- Sanitize get_available_models response to exclude baseUrl/envKey
- Rename Python get_usage_info(range=) to time_range= to avoid shadowing built-in
- Use Effort type alias and Literal type in Python set_effort/get_usage_info
- Make set_effort/setEffort return None/void to match set_model/setModel
- Add TS test for effort in initialize payload

* fix(cli): add effort read-back check and CLI-level unit tests

- Add read-back check after setReasoningEffort() in both handleSetEffort
  and handleInitialize to detect silent no-op when thinking is disabled
- Return applied flag in set_effort response so SDK consumers can detect
  when effort was not applied
- Remove user input from error messages to match handleSetModel convention
- Add unit tests for set_effort, get_available_models, get_usage_info, and
  initialize with effort handlers

* fix(sdk): surface applied flag from setEffort, move effort validation before side effects

- Move effort validation in handleInitialize before MCP servers and
  subagents processing to prevent config-mutating side effects if
  effort is invalid
- Return applied boolean from setEffort/set_effort so SDK consumers can
  detect when effort was a no-op (thinking disabled)
- Fix spread order in handleGetUsageInfo to prevent subtype overwrite
- Update JSDoc to reflect actual behavior
2026-07-11 10:08:12 +00:00

184 lines
6.2 KiB
Python

from __future__ import annotations
from typing import Any, cast
import pytest
from qwen_code_sdk.errors import ValidationError
from qwen_code_sdk.types import QueryOptions, TimeoutOptions
from qwen_code_sdk.validation import validate_query_options
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
def test_rejects_resume_with_continue_session() -> None:
with pytest.raises(ValidationError, match="resume together with continue_session"):
validate_query_options(
QueryOptions(
resume=VALID_UUID,
continue_session=True,
)
)
def test_rejects_session_id_with_resume() -> None:
with pytest.raises(ValidationError, match="Cannot use session_id with resume"):
validate_query_options(
QueryOptions(
session_id=VALID_UUID,
resume="223e4567-e89b-12d3-a456-426614174000",
)
)
def test_rejects_invalid_session_id() -> None:
with pytest.raises(ValidationError, match="Invalid session_id"):
validate_query_options(QueryOptions(session_id="not-a-uuid"))
def test_rejects_invalid_resume() -> None:
with pytest.raises(ValidationError, match="Invalid resume"):
validate_query_options(QueryOptions(resume="not-a-uuid"))
def test_rejects_invalid_permission_mode() -> None:
with pytest.raises(ValidationError, match="Invalid permission_mode"):
validate_query_options(
QueryOptions.from_mapping({"permission_mode": "unsafe-mode"})
)
def test_rejects_invalid_auth_type() -> None:
with pytest.raises(ValidationError, match="Invalid auth_type"):
validate_query_options(QueryOptions.from_mapping({"auth_type": "custom"}))
def test_from_mapping_rejects_non_callable_can_use_tool() -> None:
with pytest.raises(TypeError, match="can_use_tool must be callable"):
QueryOptions.from_mapping({"can_use_tool": "bad"})
def test_from_mapping_rejects_non_callable_stderr() -> None:
with pytest.raises(TypeError, match="stderr must be callable"):
QueryOptions.from_mapping({"stderr": "bad"})
def test_validation_rejects_non_callable_can_use_tool() -> None:
with pytest.raises(ValidationError, match="can_use_tool must be callable"):
validate_query_options(QueryOptions(can_use_tool=cast(Any, "bad")))
def test_validation_rejects_non_callable_stderr() -> None:
with pytest.raises(ValidationError, match="stderr must be callable"):
validate_query_options(QueryOptions(stderr=cast(Any, "bad")))
def test_from_mapping_rejects_sync_can_use_tool() -> None:
def can_use_tool( # type: ignore[no-untyped-def]
tool_name, tool_input, context
):
return {"behavior": "deny", "message": "bad"}
with pytest.raises(TypeError, match="can_use_tool must be an async callable"):
QueryOptions.from_mapping({"can_use_tool": can_use_tool})
def test_validation_rejects_sync_can_use_tool() -> None:
def can_use_tool( # type: ignore[no-untyped-def]
tool_name, tool_input, context
):
return {"behavior": "deny", "message": "bad"}
with pytest.raises(ValidationError, match="can_use_tool must be an async callable"):
validate_query_options(QueryOptions(can_use_tool=cast(Any, can_use_tool)))
def test_from_mapping_rejects_can_use_tool_with_wrong_arity() -> None:
async def can_use_tool(
tool_name: str,
tool_input: dict[str, Any],
) -> dict[str, str]:
return {"behavior": "deny"}
with pytest.raises(
TypeError, match="can_use_tool must accept exactly 3 positional arguments"
):
QueryOptions.from_mapping({"can_use_tool": can_use_tool})
def test_validation_rejects_can_use_tool_with_wrong_arity() -> None:
async def can_use_tool(
tool_name: str,
tool_input: dict[str, Any],
) -> dict[str, str]:
return {"behavior": "deny"}
with pytest.raises(
ValidationError,
match="can_use_tool must accept exactly 3 positional arguments",
):
validate_query_options(QueryOptions(can_use_tool=cast(Any, can_use_tool)))
def test_from_mapping_rejects_stderr_with_wrong_arity() -> None:
def stderr() -> None:
return None
with pytest.raises(
TypeError, match="stderr must accept exactly 1 positional argument"
):
QueryOptions.from_mapping({"stderr": stderr})
def test_validation_rejects_stderr_with_wrong_arity() -> None:
def stderr() -> None:
return None
with pytest.raises(
ValidationError, match="stderr must accept exactly 1 positional argument"
):
validate_query_options(QueryOptions(stderr=cast(Any, stderr)))
def test_rejects_invalid_max_session_turns() -> None:
with pytest.raises(ValidationError, match="max_session_turns"):
validate_query_options(QueryOptions(max_session_turns=-2))
def test_rejects_empty_qwen_executable_path() -> None:
with pytest.raises(
ValidationError, match="path_to_qwen_executable cannot be empty"
):
validate_query_options(QueryOptions(path_to_qwen_executable=" "))
def test_timeout_rejects_non_numeric_value() -> None:
with pytest.raises(TypeError, match=r"timeout\.can_use_tool must be a positive"):
TimeoutOptions.from_mapping({"can_use_tool": "fast"})
def test_timeout_rejects_negative_value() -> None:
pattern = r"timeout\.control_request must be a positive"
with pytest.raises(ValueError, match=pattern):
TimeoutOptions.from_mapping({"control_request": -1})
def test_timeout_rejects_boolean_value() -> None:
with pytest.raises(TypeError, match=r"timeout\.stream_close must be a positive"):
TimeoutOptions.from_mapping({"stream_close": True})
def test_rejects_mcp_servers() -> None:
with pytest.raises(ValidationError, match="mcp_servers is not supported"):
validate_query_options(
QueryOptions(mcp_servers={"my-server": {"command": "node", "args": []}})
)
def test_rejects_invalid_effort() -> None:
with pytest.raises(ValidationError, match="Invalid effort"):
validate_query_options(QueryOptions(effort="invalid")) # type: ignore[arg-type]
def test_accepts_valid_effort() -> None:
for effort in ("low", "medium", "high", "xhigh", "max"):
validate_query_options(QueryOptions(effort=effort)) # type: ignore[arg-type]