qwen-code/packages/sdk-python/src/qwen_code_sdk/protocol.py
jinye e384338145
feat(SDK) Add Python SDK implementation for #3010 (#3494)
* 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>
2026-04-25 07:02:58 +08:00

357 lines
8.2 KiB
Python

"""Protocol message types and helpers for qwen stream-json."""
from __future__ import annotations
from typing import Any, Literal, TypeAlias, TypeGuard
from typing_extensions import NotRequired, TypedDict
from .types import PermissionMode, PermissionSuggestion
class Annotation(TypedDict):
type: str
value: str
class Usage(TypedDict):
input_tokens: int
output_tokens: int
cache_creation_input_tokens: NotRequired[int]
cache_read_input_tokens: NotRequired[int]
total_tokens: NotRequired[int]
class ExtendedUsage(Usage, total=False):
server_tool_use: dict[str, int]
service_tier: str
cache_creation: dict[str, int]
class CLIPermissionDenial(TypedDict):
tool_name: str
tool_use_id: str
tool_input: Any
class TextBlock(TypedDict):
type: Literal["text"]
text: str
annotations: NotRequired[list[Annotation]]
class ThinkingBlock(TypedDict):
type: Literal["thinking"]
thinking: str
signature: NotRequired[str]
annotations: NotRequired[list[Annotation]]
class ToolUseBlock(TypedDict):
type: Literal["tool_use"]
id: str
name: str
input: Any
annotations: NotRequired[list[Annotation]]
class ToolResultBlock(TypedDict):
type: Literal["tool_result"]
tool_use_id: str
content: NotRequired[str | list[ContentBlock]]
is_error: NotRequired[bool]
annotations: NotRequired[list[Annotation]]
ContentBlock: TypeAlias = TextBlock | ThinkingBlock | ToolUseBlock | ToolResultBlock
class APIUserMessage(TypedDict):
role: Literal["user"]
content: str | list[ContentBlock]
class APIAssistantMessage(TypedDict):
role: Literal["assistant"]
content: list[ContentBlock]
id: NotRequired[str]
type: NotRequired[Literal["message"]]
model: NotRequired[str]
stop_reason: NotRequired[str | None]
usage: NotRequired[Usage]
class SDKUserMessage(TypedDict):
type: Literal["user"]
session_id: str
message: APIUserMessage
parent_tool_use_id: str | None
uuid: NotRequired[str]
options: NotRequired[dict[str, Any]]
class SDKAssistantMessage(TypedDict):
type: Literal["assistant"]
uuid: str
session_id: str
message: APIAssistantMessage
parent_tool_use_id: str | None
class MCPServerState(TypedDict):
name: str
status: str
class SDKSystemMessage(TypedDict):
type: Literal["system"]
subtype: str
uuid: str
session_id: str
data: NotRequired[Any]
cwd: NotRequired[str]
tools: NotRequired[list[str]]
mcp_servers: NotRequired[list[MCPServerState]]
model: NotRequired[str]
permission_mode: NotRequired[str]
slash_commands: NotRequired[list[str]]
qwen_code_version: NotRequired[str]
output_style: NotRequired[str]
agents: NotRequired[list[str]]
skills: NotRequired[list[str]]
capabilities: NotRequired[dict[str, Any]]
class SDKResultMessageSuccess(TypedDict):
type: Literal["result"]
subtype: Literal["success"]
uuid: str
session_id: str
is_error: Literal[False]
duration_ms: int
duration_api_ms: int
num_turns: int
result: str
usage: ExtendedUsage
permission_denials: list[CLIPermissionDenial]
class ResultErrorObject(TypedDict):
message: str
type: NotRequired[str]
class SDKResultMessageError(TypedDict):
type: Literal["result"]
subtype: Literal["error_max_turns", "error_during_execution"]
uuid: str
session_id: str
is_error: Literal[True]
duration_ms: int
duration_api_ms: int
num_turns: int
usage: ExtendedUsage
permission_denials: list[CLIPermissionDenial]
error: NotRequired[ResultErrorObject]
SDKResultMessage: TypeAlias = SDKResultMessageSuccess | SDKResultMessageError
class MessageStartStreamEvent(TypedDict):
type: Literal["message_start"]
message: dict[str, Any]
class ContentBlockStartEvent(TypedDict):
type: Literal["content_block_start"]
index: int
content_block: ContentBlock
class ContentBlockDeltaEvent(TypedDict):
type: Literal["content_block_delta"]
index: int
delta: dict[str, Any]
class ContentBlockStopEvent(TypedDict):
type: Literal["content_block_stop"]
index: int
class MessageStopStreamEvent(TypedDict):
type: Literal["message_stop"]
StreamEvent: TypeAlias = (
MessageStartStreamEvent
| ContentBlockStartEvent
| ContentBlockDeltaEvent
| ContentBlockStopEvent
| MessageStopStreamEvent
)
class SDKPartialAssistantMessage(TypedDict):
type: Literal["stream_event"]
uuid: str
session_id: str
event: StreamEvent
parent_tool_use_id: str | None
class CLIControlInterruptRequest(TypedDict):
subtype: Literal["interrupt"]
class CLIControlPermissionRequest(TypedDict):
subtype: Literal["can_use_tool"]
tool_name: str
tool_use_id: str
input: Any
permission_suggestions: list[PermissionSuggestion] | None
blocked_path: str | None
class CLIControlInitializeRequest(TypedDict):
subtype: Literal["initialize"]
hooks: NotRequired[Any]
mcpServers: NotRequired[dict[str, dict[str, Any]]]
class CLIControlSetPermissionModeRequest(TypedDict):
subtype: Literal["set_permission_mode"]
mode: PermissionMode
class CLIControlSetModelRequest(TypedDict):
subtype: Literal["set_model"]
model: str
class CLIControlMcpStatusRequest(TypedDict):
subtype: Literal["mcp_server_status"]
class CLIControlSupportedCommandsRequest(TypedDict):
subtype: Literal["supported_commands"]
ControlRequestPayload: TypeAlias = (
CLIControlInterruptRequest
| CLIControlPermissionRequest
| CLIControlInitializeRequest
| CLIControlSetPermissionModeRequest
| CLIControlSetModelRequest
| CLIControlMcpStatusRequest
| CLIControlSupportedCommandsRequest
| dict[str, Any]
)
class CLIControlRequest(TypedDict):
type: Literal["control_request"]
request_id: str
request: ControlRequestPayload
class ControlResponseSuccess(TypedDict):
subtype: Literal["success"]
request_id: str
response: Any
class ControlResponseError(TypedDict):
subtype: Literal["error"]
request_id: str
error: str | dict[str, Any]
class CLIControlResponse(TypedDict):
type: Literal["control_response"]
response: ControlResponseSuccess | ControlResponseError
class ControlCancelRequest(TypedDict):
type: Literal["control_cancel_request"]
request_id: NotRequired[str]
SDKMessage: TypeAlias = (
SDKUserMessage
| SDKAssistantMessage
| SDKSystemMessage
| SDKResultMessage
| SDKPartialAssistantMessage
)
ControlMessage: TypeAlias = (
CLIControlRequest | CLIControlResponse | ControlCancelRequest
)
def is_sdk_user_message(msg: Any) -> TypeGuard[SDKUserMessage]:
return isinstance(msg, dict) and msg.get("type") == "user" and "message" in msg
def is_sdk_assistant_message(msg: Any) -> TypeGuard[SDKAssistantMessage]:
return (
isinstance(msg, dict)
and msg.get("type") == "assistant"
and "session_id" in msg
and "message" in msg
)
def is_sdk_system_message(msg: Any) -> TypeGuard[SDKSystemMessage]:
return (
isinstance(msg, dict)
and msg.get("type") == "system"
and "subtype" in msg
and "session_id" in msg
)
def is_sdk_result_message(msg: Any) -> TypeGuard[SDKResultMessage]:
return (
isinstance(msg, dict)
and msg.get("type") == "result"
and "subtype" in msg
and "session_id" in msg
)
def is_sdk_partial_assistant_message(msg: Any) -> TypeGuard[SDKPartialAssistantMessage]:
return (
isinstance(msg, dict)
and msg.get("type") == "stream_event"
and "session_id" in msg
and "event" in msg
)
def is_control_request(msg: Any) -> TypeGuard[CLIControlRequest]:
return (
isinstance(msg, dict)
and msg.get("type") == "control_request"
and "request_id" in msg
and "request" in msg
)
def is_control_response(msg: Any) -> TypeGuard[CLIControlResponse]:
return (
isinstance(msg, dict)
and msg.get("type") == "control_response"
and "response" in msg
)
def is_control_cancel(msg: Any) -> TypeGuard[ControlCancelRequest]:
return (
isinstance(msg, dict)
and msg.get("type") == "control_cancel_request"
and "request_id" in msg
)