mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-25 08:54:45 +00:00
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 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
184 lines
6.2 KiB
Python
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]
|