mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-04-29 04:00:36 +00:00
* Codex worktree snapshot: startup-cleanup Co-authored-by: Codex * Add Python SDK real smoke test Adds a repository-only real E2E smoke script for the Python SDK, plus npm and developer documentation entry points. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): address review findings — bugs, type safety, and test coverage - Fix prepare_spawn_info: JS files now use "node" instead of sys.executable - Fix protocol.py: correct total=False misuse on 7 TypedDicts (required fields were optional) - Fix query.py: add _closed guard in _ensure_started, suppress exceptions in close() - Fix sync_query.py: prevent close() deadlock, add context manager, add timeouts - Fix transport.py: handle malformed JSON lines, add _closed guard in start() - Fix validation.py: use uuid.RFC_4122 instead of magic UUID - Fix __init__.py: export TextBlock, widen query_sync signature - Remove dead code: ensure_not_aborted, write_json_line, _thread_error - Add 12 new tests (29 → 41): context managers, JSON skip, closed guards, spawn info, timeouts Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): address wenshao review — session_id, bool validation, debug stderr - Fix continue_session=True generating a wrong random session_id - Add _as_optional_bool helper for strict type validation on bool fields - Default debug stderr to sys.stderr when no custom callback is provided Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): address remaining wenshao review feedback Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * test(cli): harden settings dialog restart prompt test Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): review fixes — UUID compat, stderr fallback, sync cleanup - Remove UUID version restriction to support v6/v7/v8 (RFC 9562) - Always write to sys.stderr when stderr callback raises (was silent when debug=False) - Prevent duplicate _STOP sentinel in SyncQuery.close() via _stop_sent flag - Add ruff format --check to CI workflow - Fix smoke_real.py version guard: fail early before imports instead of NameError - Apply ruff format to existing files Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): remaining review fixes — exit_code attr, guard strictness, sync timeout - Add exit_code attribute to ProcessExitError for programmatic access - Strengthen is_control_response/is_control_cancel guards to require payload fields, preventing misrouting of malformed messages - Expose control_request_timeout property on Query so SyncQuery uses the configured timeout instead of a hardcoded 30s default - Use dataclasses.replace() instead of direct mutation on frozen-style QueryOptions in query() factory - Add ResourceWarning in SyncQuery.__del__ when not properly closed Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): add exit_code default and guard __del__ against partial GC - Give ProcessExitError.exit_code a default value (-1) so user code can construct the exception with just a message string - Wrap SyncQuery.__del__ in try/except AttributeError to prevent crashes when the object is partially garbage-collected Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): review fixes — resource leak, type safety, CI matrix, docs - Fix SyncQuery.__del__ to call close() on GC instead of only warning - Replace hasattr duck-type check with isinstance(prompt, AsyncIterable) - Type-validate permission_mode/auth_type in QueryOptions.from_mapping - Use TypeGuard return types on all is_sdk_*/is_control_* predicates - Add 5s margin to sync wrapper timeouts to prevent error type masking - Expand CI matrix to test Python 3.10, 3.11, 3.12 - Change ProcessExitError.exit_code default from -1 to None - Add stderr to docs QueryOptions listing - Update README sync example to use context manager pattern Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): preserve iterator exhaustion state and suppress detached task warning - Add _exhausted flag to Query.__anext__ and SyncQuery.__next__ so repeated iteration after end-of-stream raises Stop(Async)Iteration instead of blocking forever. - Remove re-raise in _initialize() to prevent asyncio "Task exception was never retrieved" warning on detached tasks; the error is already surfaced via _finish_with_error(). Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): reject mcp_servers at validation time and add iterator/init tests - Reject mcp_servers in validate_query_options() with a clear error instead of advertising MCP support to the CLI and then failing at runtime when mcp_message arrives. - Remove dead mcp_servers branch from _initialize(). - Add tests for async/sync iterator exhaustion, detached init task warning suppression, and mcp_servers validation. Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * fix(sdk-python): fix ruff lint errors in new tests - Use ControlRequestTimeoutError instead of bare Exception (B017) - Fix import sorting for stdlib vs third-party (I001) - Break long line to stay within 88-char limit (E501) Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> * style(sdk-python): apply ruff format to new tests Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com> --------- Co-authored-by: jinye.djy <jinye.djy@alibaba-inc.com> Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
parent
202be6ec7d
commit
e384338145
25 changed files with 4676 additions and 14 deletions
276
packages/sdk-python/tests/integration/test_async_query.py
Normal file
276
packages/sdk-python/tests/integration/test_async_query.py
Normal file
|
|
@ -0,0 +1,276 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator, Callable
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from qwen_code_sdk import (
|
||||
ProcessExitError,
|
||||
SDKUserMessage,
|
||||
is_sdk_assistant_message,
|
||||
is_sdk_partial_assistant_message,
|
||||
is_sdk_result_message,
|
||||
is_sdk_system_message,
|
||||
is_sdk_user_message,
|
||||
query,
|
||||
)
|
||||
|
||||
CONTINUED_SESSION_ID = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
VALID_UUID = "123e4567-e89b-12d3-a456-426614174000"
|
||||
RESUME_UUID = "223e4567-e89b-12d3-a456-426614174000"
|
||||
|
||||
|
||||
async def _collect_messages(result: Any) -> list[dict[str, Any]]:
|
||||
messages: list[dict[str, Any]] = []
|
||||
async for message in result:
|
||||
messages.append(message)
|
||||
return messages
|
||||
|
||||
|
||||
async def _wait_for(predicate: Callable[[], bool], timeout: float = 2.0) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + timeout
|
||||
while loop.time() < deadline:
|
||||
if predicate():
|
||||
return
|
||||
await asyncio.sleep(0.01)
|
||||
raise AssertionError("timed out waiting for expected SDK state")
|
||||
|
||||
|
||||
def _tool_result_error_flag(message: dict[str, Any]) -> bool:
|
||||
content = message["message"]["content"]
|
||||
assert isinstance(content, list)
|
||||
return bool(content[0]["is_error"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_turn_query(fake_qwen_path: str) -> None:
|
||||
result = query(
|
||||
"hello world",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
},
|
||||
)
|
||||
messages = await _collect_messages(result)
|
||||
|
||||
assistant = next(
|
||||
message for message in messages if is_sdk_assistant_message(message)
|
||||
)
|
||||
final = next(message for message in messages if is_sdk_result_message(message))
|
||||
|
||||
assert assistant["message"]["content"][0]["text"] == "Echo: hello world"
|
||||
assert final["result"] == "done: hello world"
|
||||
await result.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_include_partial_messages(fake_qwen_path: str) -> None:
|
||||
result = query(
|
||||
"stream partial",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
"include_partial_messages": True,
|
||||
},
|
||||
)
|
||||
messages = await _collect_messages(result)
|
||||
|
||||
partial = next(
|
||||
message for message in messages if is_sdk_partial_assistant_message(message)
|
||||
)
|
||||
assert partial["event"]["type"] == "content_block_delta"
|
||||
await result.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_permission_callback_denies_tool_use(fake_qwen_path: str) -> None:
|
||||
result = query(
|
||||
"use tool now",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
},
|
||||
)
|
||||
messages = await _collect_messages(result)
|
||||
|
||||
tool_result = next(
|
||||
message
|
||||
for message in messages
|
||||
if is_sdk_user_message(message)
|
||||
and isinstance(message["message"]["content"], list)
|
||||
)
|
||||
assert _tool_result_error_flag(tool_result) is True
|
||||
await result.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_permission_callback_can_allow_tool_use(fake_qwen_path: str) -> None:
|
||||
async def can_use_tool(
|
||||
tool_name: str,
|
||||
tool_input: dict[str, Any],
|
||||
context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
assert tool_name == "write_file"
|
||||
assert tool_input["path"] == "demo.txt"
|
||||
assert context["suggestions"][0]["type"] == "allow"
|
||||
return {"behavior": "allow", "updatedInput": tool_input}
|
||||
|
||||
result = query(
|
||||
"create file with use tool",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
"can_use_tool": can_use_tool,
|
||||
},
|
||||
)
|
||||
messages = await _collect_messages(result)
|
||||
|
||||
tool_result = next(
|
||||
message
|
||||
for message in messages
|
||||
if is_sdk_user_message(message)
|
||||
and isinstance(message["message"]["content"], list)
|
||||
)
|
||||
assert _tool_result_error_flag(tool_result) is False
|
||||
await result.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_unknown_control_requests_are_rejected(fake_qwen_path: str) -> None:
|
||||
result = query(
|
||||
"request unknown control",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
},
|
||||
)
|
||||
messages = await _collect_messages(result)
|
||||
|
||||
final = next(message for message in messages if is_sdk_result_message(message))
|
||||
assert final["result"] == "unknown-control: request unknown control"
|
||||
await result.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dynamic_controls_and_status(fake_qwen_path: str) -> None:
|
||||
release_input = asyncio.Event()
|
||||
|
||||
async def prompts() -> AsyncIterator[SDKUserMessage]:
|
||||
yield {
|
||||
"type": "user",
|
||||
"session_id": VALID_UUID,
|
||||
"message": {
|
||||
"role": "user",
|
||||
"content": "first turn",
|
||||
},
|
||||
"parent_tool_use_id": None,
|
||||
}
|
||||
await release_input.wait()
|
||||
|
||||
result = query(
|
||||
prompts(),
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
"session_id": VALID_UUID,
|
||||
},
|
||||
)
|
||||
|
||||
messages: list[dict[str, Any]] = []
|
||||
|
||||
async def consume() -> list[dict[str, Any]]:
|
||||
async for message in result:
|
||||
messages.append(message)
|
||||
return messages
|
||||
|
||||
collector = asyncio.create_task(consume())
|
||||
await _wait_for(lambda: any(is_sdk_result_message(message) for message in messages))
|
||||
|
||||
assert await result.supported_commands() == {
|
||||
"commands": [
|
||||
"initialize",
|
||||
"interrupt",
|
||||
"set_model",
|
||||
"set_permission_mode",
|
||||
]
|
||||
}
|
||||
assert await result.mcp_server_status() == {"servers": []}
|
||||
|
||||
await result.set_model("new-model")
|
||||
await result.set_permission_mode("plan")
|
||||
release_input.set()
|
||||
await collector
|
||||
|
||||
system_messages = [
|
||||
message for message in messages if is_sdk_system_message(message)
|
||||
]
|
||||
assert any(message["model"] == "new-model" for message in system_messages)
|
||||
assert any(message["permission_mode"] == "plan" for message in system_messages)
|
||||
await result.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_session_id_resume_and_continue(fake_qwen_path: str) -> None:
|
||||
explicit = query(
|
||||
"hello explicit",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
"session_id": VALID_UUID,
|
||||
},
|
||||
)
|
||||
explicit_messages = await _collect_messages(explicit)
|
||||
assert explicit.get_session_id() == VALID_UUID
|
||||
assert all(message["session_id"] == VALID_UUID for message in explicit_messages)
|
||||
await explicit.close()
|
||||
|
||||
resumed = query(
|
||||
"hello resume",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
"resume": RESUME_UUID,
|
||||
},
|
||||
)
|
||||
resumed_messages = await _collect_messages(resumed)
|
||||
assert resumed.get_session_id() == RESUME_UUID
|
||||
assert all(message["session_id"] == RESUME_UUID for message in resumed_messages)
|
||||
await resumed.close()
|
||||
|
||||
continued = query(
|
||||
"hello continue",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
"continue_session": True,
|
||||
},
|
||||
)
|
||||
continued_messages = await _collect_messages(continued)
|
||||
assert continued.get_session_id() == CONTINUED_SESSION_ID
|
||||
assert any(
|
||||
message["session_id"] == CONTINUED_SESSION_ID for message in continued_messages
|
||||
)
|
||||
await continued.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_zero_process_exit_is_propagated(fake_qwen_path: str) -> None:
|
||||
result = query(
|
||||
"please exit nonzero",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(ProcessExitError, match="code 9"):
|
||||
await _collect_messages(result)
|
||||
|
||||
await result.close()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_context_manager(fake_qwen_path: str) -> None:
|
||||
async with query(
|
||||
"hello context",
|
||||
{
|
||||
"path_to_qwen_executable": fake_qwen_path,
|
||||
},
|
||||
) as result:
|
||||
messages = await _collect_messages(result)
|
||||
|
||||
assert result.is_closed()
|
||||
final = next(m for m in messages if is_sdk_result_message(m))
|
||||
assert final["result"] == "done: hello context"
|
||||
Loading…
Add table
Add a link
Reference in a new issue