clawd-code-sdk 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/PKG-INFO +1 -1
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/pyproject.toml +1 -1
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/__init__.py +6 -0
- clawd_code_sdk-0.3.1/src/clawd_code_sdk/list_sessions.py +245 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/__init__.py +4 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/base.py +21 -1
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/content_blocks.py +2 -2
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/messages.py +48 -16
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/options.py +20 -1
- clawd_code_sdk-0.3.1/tests/e2e/test_mcp_tools.py +193 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/mcp_server.py +14 -1
- clawd_code_sdk-0.3.0/tests/e2e/test_mcp_image.py +0 -121
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/.gitignore +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/LICENSE +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/README.md +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_bundled/.gitignore +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_errors.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/__init__.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/message_parser.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/query.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/transport/__init__.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/transport/subprocess_cli.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_version.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/anthropic_types.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/client.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/mcp_utils.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/agents.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/control.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/hooks.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/input_types.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/mcp.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/output_types.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/permissions.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/sandbox.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/server_info.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/models/ts_output_types.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/py.typed +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/query.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/session.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/ARCHITECTURE.md +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/__init__.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/helpers.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/models.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/storage/replay.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/usage.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/conftest.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/__init__.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_agents_and_settings.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_dynamic_control.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_hook_events.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_hooks.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_include_partial_messages.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_sdk_mcp_tools.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_slash_commands.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_stderr_callback.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_structured_output.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_subagent_invocation.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/e2e/test_tool_permissions.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/mock_claude_server.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_changelog.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_client.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_errors.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_image.png +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_integration.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_message_parser.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_sdk_mcp_integration.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_session.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_streaming_client.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_subprocess_buffering.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_tool_callbacks.py +0 -0
- {clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/tests/test_transport.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: clawd-code-sdk
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Python SDK for Claude Code
|
|
5
5
|
Project-URL: Documentation, https://github.com/phil65/claude-agent-sdk-python
|
|
6
6
|
Project-URL: Homepage, https://github.com/phil65/claude-agent-sdk-python
|
|
@@ -20,6 +20,7 @@ from ._internal.transport import Transport
|
|
|
20
20
|
from ._version import __version__
|
|
21
21
|
from .anthropic_types import ToolResultContentBlock
|
|
22
22
|
from .client import ClaudeSDKClient
|
|
23
|
+
from .list_sessions import list_sessions
|
|
23
24
|
from .models import (
|
|
24
25
|
AgentDefinition,
|
|
25
26
|
ContinueLatest,
|
|
@@ -33,6 +34,7 @@ from .models import (
|
|
|
33
34
|
HookInput,
|
|
34
35
|
HookJSONOutput,
|
|
35
36
|
HookMatcher,
|
|
37
|
+
ListSessionsOptions,
|
|
36
38
|
McpSdkServerConfig,
|
|
37
39
|
McpServerConfig,
|
|
38
40
|
Message,
|
|
@@ -81,6 +83,7 @@ from .models import (
|
|
|
81
83
|
UserPromptSubmitHookInput,
|
|
82
84
|
NewSession,
|
|
83
85
|
ResumeSession,
|
|
86
|
+
SDKSessionInfo,
|
|
84
87
|
SessionConfig,
|
|
85
88
|
)
|
|
86
89
|
from .query import query
|
|
@@ -102,14 +105,17 @@ __cli_version__ = "2.1.11"
|
|
|
102
105
|
__all__ = [
|
|
103
106
|
# Main exports
|
|
104
107
|
"query",
|
|
108
|
+
"list_sessions",
|
|
105
109
|
"__version__",
|
|
106
110
|
# Transport
|
|
107
111
|
"Transport",
|
|
108
112
|
"ClaudeSDKClient",
|
|
109
113
|
# Session config
|
|
110
114
|
"ContinueLatest",
|
|
115
|
+
"ListSessionsOptions",
|
|
111
116
|
"NewSession",
|
|
112
117
|
"ResumeSession",
|
|
118
|
+
"SDKSessionInfo",
|
|
113
119
|
"SessionConfig",
|
|
114
120
|
# Types
|
|
115
121
|
"PermissionMode",
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""List stored Claude Code sessions with metadata."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from pathlib import Path # noqa: TC003
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
import anyenv
|
|
10
|
+
|
|
11
|
+
from clawd_code_sdk.models.messages import SDKSessionInfo
|
|
12
|
+
from clawd_code_sdk.storage.helpers import (
|
|
13
|
+
decode_project_path,
|
|
14
|
+
get_claude_projects_dir,
|
|
15
|
+
path_to_claude_dir_name,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from clawd_code_sdk.models.options import ListSessionsOptions
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_git_branch_from_tail(path: Path) -> str | None:
|
|
26
|
+
"""Read git branch from the last entries of a JSONL file.
|
|
27
|
+
|
|
28
|
+
Reads from the end of the file for efficiency. Scans backward
|
|
29
|
+
through the last chunk of lines to find an entry with a
|
|
30
|
+
``gitBranch`` field (not all entry types carry it).
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
path: Path to the JSONL file.
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
The git branch string, or None if not found.
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
with path.open("rb") as f:
|
|
40
|
+
f.seek(0, 2)
|
|
41
|
+
size = f.tell()
|
|
42
|
+
if size == 0:
|
|
43
|
+
return None
|
|
44
|
+
chunk_size = min(size, 32768)
|
|
45
|
+
f.seek(-chunk_size, 2)
|
|
46
|
+
data = f.read().decode("utf-8", errors="ignore")
|
|
47
|
+
lines = data.strip().split("\n")
|
|
48
|
+
for line in reversed(lines):
|
|
49
|
+
line = line.strip()
|
|
50
|
+
if not line:
|
|
51
|
+
continue
|
|
52
|
+
# Quick string check before parsing
|
|
53
|
+
if "gitBranch" not in line and "git_branch" not in line:
|
|
54
|
+
continue
|
|
55
|
+
try:
|
|
56
|
+
entry: dict[str, object] = anyenv.load_json(line, return_type=dict)
|
|
57
|
+
branch = entry.get("gitBranch") or entry.get("git_branch")
|
|
58
|
+
if isinstance(branch, str) and branch:
|
|
59
|
+
return branch
|
|
60
|
+
except anyenv.JsonLoadError:
|
|
61
|
+
continue
|
|
62
|
+
except OSError:
|
|
63
|
+
pass
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _extract_session_metadata(
|
|
68
|
+
session_path: Path,
|
|
69
|
+
) -> tuple[str | None, str | None]:
|
|
70
|
+
"""Extract custom_title and first_prompt from a session file.
|
|
71
|
+
|
|
72
|
+
Reads the file line by line, stopping as early as possible.
|
|
73
|
+
Summary entries provide the custom title; the first user message
|
|
74
|
+
provides the first prompt.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
session_path: Path to the JSONL session file.
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
A ``(custom_title, first_prompt)`` tuple.
|
|
81
|
+
"""
|
|
82
|
+
custom_title: str | None = None
|
|
83
|
+
first_prompt: str | None = None
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
with session_path.open(encoding="utf-8", errors="ignore") as fp:
|
|
87
|
+
for line in fp:
|
|
88
|
+
if '"type":"summary"' in line or '"type": "summary"' in line:
|
|
89
|
+
try:
|
|
90
|
+
entry: dict[str, object] = anyenv.load_json(line, return_type=dict)
|
|
91
|
+
if summary := entry.get("summary"):
|
|
92
|
+
custom_title = str(summary)
|
|
93
|
+
except anyenv.JsonLoadError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
elif first_prompt is None and ('"type":"user"' in line or '"type": "user"' in line):
|
|
97
|
+
try:
|
|
98
|
+
entry = anyenv.load_json(line, return_type=dict)
|
|
99
|
+
msg = entry.get("message")
|
|
100
|
+
if isinstance(msg, dict):
|
|
101
|
+
content = msg.get("content", "")
|
|
102
|
+
if isinstance(content, str) and content:
|
|
103
|
+
first_line = content.split("\n")[0].strip()
|
|
104
|
+
if first_line:
|
|
105
|
+
first_prompt = first_line
|
|
106
|
+
except anyenv.JsonLoadError:
|
|
107
|
+
pass
|
|
108
|
+
|
|
109
|
+
# Stop early when we have both
|
|
110
|
+
if custom_title is not None and first_prompt is not None:
|
|
111
|
+
break
|
|
112
|
+
except OSError:
|
|
113
|
+
pass
|
|
114
|
+
|
|
115
|
+
return custom_title, first_prompt
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _build_session_info(session_path: Path, project_cwd: str | None) -> SDKSessionInfo | None:
|
|
119
|
+
"""Build an SDKSessionInfo from a session JSONL file.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
session_path: Path to the ``.jsonl`` session file.
|
|
123
|
+
project_cwd: Working directory derived from the project folder name,
|
|
124
|
+
or None if unknown.
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
Populated SDKSessionInfo, or None if the file cannot be read.
|
|
128
|
+
"""
|
|
129
|
+
try:
|
|
130
|
+
stat = session_path.stat()
|
|
131
|
+
except OSError:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
session_id = session_path.stem
|
|
135
|
+
last_modified = int(stat.st_mtime * 1000) # milliseconds since epoch
|
|
136
|
+
file_size = stat.st_size
|
|
137
|
+
|
|
138
|
+
custom_title, first_prompt = _extract_session_metadata(session_path)
|
|
139
|
+
|
|
140
|
+
# Get git branch from the tail of the file.
|
|
141
|
+
# The raw JSONL uses camelCase ("gitBranch") per ClaudeCodeBaseModel alias config.
|
|
142
|
+
# Not all entry types carry gitBranch, so we scan backward until we find one.
|
|
143
|
+
git_branch = _read_git_branch_from_tail(session_path)
|
|
144
|
+
|
|
145
|
+
# Build display summary: prefer custom title, then first prompt, then session ID
|
|
146
|
+
summary = custom_title or first_prompt or session_id
|
|
147
|
+
|
|
148
|
+
return SDKSessionInfo(
|
|
149
|
+
session_id=session_id,
|
|
150
|
+
summary=summary,
|
|
151
|
+
last_modified=last_modified,
|
|
152
|
+
file_size=file_size,
|
|
153
|
+
custom_title=custom_title,
|
|
154
|
+
first_prompt=first_prompt,
|
|
155
|
+
git_branch=git_branch,
|
|
156
|
+
cwd=project_cwd,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _list_session_files_for_dir(directory: str) -> list[tuple[Path, str | None]]:
|
|
161
|
+
"""List session files for a specific project directory.
|
|
162
|
+
|
|
163
|
+
Args:
|
|
164
|
+
directory: Filesystem path of the project.
|
|
165
|
+
|
|
166
|
+
Returns:
|
|
167
|
+
List of ``(session_path, cwd)`` tuples.
|
|
168
|
+
"""
|
|
169
|
+
projects_dir = get_claude_projects_dir()
|
|
170
|
+
dir_name = path_to_claude_dir_name(directory)
|
|
171
|
+
project_dir = projects_dir / dir_name
|
|
172
|
+
if not project_dir.is_dir():
|
|
173
|
+
return []
|
|
174
|
+
return [(p, directory) for p in project_dir.glob("*.jsonl")]
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _list_all_session_files() -> list[tuple[Path, str | None]]:
|
|
178
|
+
"""List session files across all projects.
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
List of ``(session_path, cwd)`` tuples where cwd is decoded
|
|
182
|
+
from the project directory name.
|
|
183
|
+
"""
|
|
184
|
+
projects_dir = get_claude_projects_dir()
|
|
185
|
+
if not projects_dir.is_dir():
|
|
186
|
+
return []
|
|
187
|
+
results: list[tuple[Path, str | None]] = []
|
|
188
|
+
for project_dir in projects_dir.iterdir():
|
|
189
|
+
if not project_dir.is_dir():
|
|
190
|
+
continue
|
|
191
|
+
cwd = decode_project_path(project_dir.name)
|
|
192
|
+
for session_file in project_dir.glob("*.jsonl"):
|
|
193
|
+
results.append((session_file, cwd))
|
|
194
|
+
return results
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def list_sessions(options: ListSessionsOptions | None = None) -> list[SDKSessionInfo]:
|
|
198
|
+
"""List sessions with metadata.
|
|
199
|
+
|
|
200
|
+
When ``dir`` is provided in *options*, returns sessions for that project
|
|
201
|
+
directory and its git worktrees. When omitted, returns sessions across
|
|
202
|
+
all projects.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
options: Optional filtering/limiting options.
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
Session metadata sorted by last modified time (newest first).
|
|
209
|
+
|
|
210
|
+
Example::
|
|
211
|
+
|
|
212
|
+
from clawd_code_sdk import list_sessions
|
|
213
|
+
|
|
214
|
+
# List sessions for a specific project
|
|
215
|
+
sessions = list_sessions({"dir": "/path/to/project"})
|
|
216
|
+
|
|
217
|
+
# List all sessions across all projects
|
|
218
|
+
all_sessions = list_sessions()
|
|
219
|
+
"""
|
|
220
|
+
opts = options or {}
|
|
221
|
+
directory = opts.get("dir")
|
|
222
|
+
limit = opts.get("limit")
|
|
223
|
+
|
|
224
|
+
# Collect session files
|
|
225
|
+
session_files: list[tuple[Path, str | None]]
|
|
226
|
+
if directory is not None:
|
|
227
|
+
session_files = _list_session_files_for_dir(directory)
|
|
228
|
+
else:
|
|
229
|
+
session_files = _list_all_session_files()
|
|
230
|
+
|
|
231
|
+
# Build session info for each file
|
|
232
|
+
sessions: list[SDKSessionInfo] = []
|
|
233
|
+
for session_path, cwd in session_files:
|
|
234
|
+
info = _build_session_info(session_path, cwd)
|
|
235
|
+
if info is not None:
|
|
236
|
+
sessions.append(info)
|
|
237
|
+
|
|
238
|
+
# Sort by last_modified descending (newest first)
|
|
239
|
+
sessions.sort(key=lambda s: s.last_modified, reverse=True)
|
|
240
|
+
|
|
241
|
+
# Apply limit
|
|
242
|
+
if limit is not None and limit > 0:
|
|
243
|
+
sessions = sessions[:limit]
|
|
244
|
+
|
|
245
|
+
return sessions
|
|
@@ -213,6 +213,7 @@ from .messages import (
|
|
|
213
213
|
ResultMessage,
|
|
214
214
|
ResultSuccessMessage,
|
|
215
215
|
SDKPermissionDenial,
|
|
216
|
+
SDKSessionInfo,
|
|
216
217
|
StatusSystemMessage,
|
|
217
218
|
StreamEvent,
|
|
218
219
|
AuthStatusMessage,
|
|
@@ -235,6 +236,7 @@ from .options import ClaudeAgentOptions
|
|
|
235
236
|
from .options import (
|
|
236
237
|
BaseSessionConfig,
|
|
237
238
|
ContinueLatest,
|
|
239
|
+
ListSessionsOptions,
|
|
238
240
|
NewSession,
|
|
239
241
|
ResumeSession,
|
|
240
242
|
SessionConfig,
|
|
@@ -356,6 +358,7 @@ __all__ = [
|
|
|
356
358
|
"ResultMessage",
|
|
357
359
|
"ResultSuccessMessage",
|
|
358
360
|
"SDKPermissionDenial",
|
|
361
|
+
"SDKSessionInfo",
|
|
359
362
|
"StatusSystemMessage",
|
|
360
363
|
"StreamEvent",
|
|
361
364
|
"AuthStatusMessage",
|
|
@@ -382,6 +385,7 @@ __all__ = [
|
|
|
382
385
|
"ClaudeAgentOptions",
|
|
383
386
|
"BaseSessionConfig",
|
|
384
387
|
"ContinueLatest",
|
|
388
|
+
"ListSessionsOptions",
|
|
385
389
|
"NewSession",
|
|
386
390
|
"ResumeSession",
|
|
387
391
|
"SessionConfig",
|
|
@@ -33,7 +33,27 @@ StopReason = Literal[
|
|
|
33
33
|
]
|
|
34
34
|
ApiKeySource = Literal["none", "env", "config", "ANTHROPIC_API_KEY"]
|
|
35
35
|
SettingSource = Literal["user", "project", "local"]
|
|
36
|
-
|
|
36
|
+
ToolName = Literal[
|
|
37
|
+
"Task",
|
|
38
|
+
"TaskOutput",
|
|
39
|
+
"Bash",
|
|
40
|
+
"Glob",
|
|
41
|
+
"Grep",
|
|
42
|
+
"ExitPlanMode",
|
|
43
|
+
"Read",
|
|
44
|
+
"Edit",
|
|
45
|
+
"Write",
|
|
46
|
+
"NotebookEdit",
|
|
47
|
+
"WebFetch",
|
|
48
|
+
"TodoWrite",
|
|
49
|
+
"WebSearch",
|
|
50
|
+
"TaskStop",
|
|
51
|
+
"AskUserQuestion",
|
|
52
|
+
"Skill",
|
|
53
|
+
"EnterPlanMode",
|
|
54
|
+
"EnterWorktree",
|
|
55
|
+
"ToolSearch",
|
|
56
|
+
]
|
|
37
57
|
IS_DEV = "pytest" in sys.modules
|
|
38
58
|
|
|
39
59
|
|
|
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING, Annotated, Any, Literal
|
|
|
9
9
|
from pydantic import ConfigDict, Discriminator, TypeAdapter
|
|
10
10
|
|
|
11
11
|
from clawd_code_sdk.models import ToolInput # noqa: TC001
|
|
12
|
-
from clawd_code_sdk.models.base import ClaudeCodeBaseModel
|
|
12
|
+
from clawd_code_sdk.models.base import ClaudeCodeBaseModel, ToolName # noqa: TC001
|
|
13
13
|
|
|
14
14
|
|
|
15
15
|
if TYPE_CHECKING:
|
|
@@ -40,7 +40,7 @@ class ToolUseBlock:
|
|
|
40
40
|
|
|
41
41
|
type: Literal["tool_use"] = field(default="tool_use", repr=False)
|
|
42
42
|
id: str = ""
|
|
43
|
-
name: str = ""
|
|
43
|
+
name: ToolName | str = ""
|
|
44
44
|
input: ToolInput | dict[str, Any] = field(default_factory=dict)
|
|
45
45
|
caller: dict[str, str] | None = None
|
|
46
46
|
|
|
@@ -23,7 +23,7 @@ from clawd_code_sdk.models.content_blocks import ContentBlock, TextBlock # noqa
|
|
|
23
23
|
from clawd_code_sdk.models.mcp import McpConnectionStatus # noqa: TC001
|
|
24
24
|
from clawd_code_sdk.models.output_types import ToolUseResult # noqa: TC001
|
|
25
25
|
|
|
26
|
-
from .base import ApiKeySource, PermissionMode, StopReason, TaskStatus # noqa: TC001
|
|
26
|
+
from .base import ApiKeySource, PermissionMode, StopReason, TaskStatus, ToolName # noqa: TC001
|
|
27
27
|
|
|
28
28
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
@@ -48,6 +48,7 @@ ErrorSubType = Literal[
|
|
|
48
48
|
"error_max_budget_usd",
|
|
49
49
|
"error_max_structured_output_retries",
|
|
50
50
|
]
|
|
51
|
+
Outcome = Literal["success", "error", "cancelled"]
|
|
51
52
|
|
|
52
53
|
|
|
53
54
|
class UserPromptMessageContent(TypedDict):
|
|
@@ -115,7 +116,7 @@ class AssistantMessage:
|
|
|
115
116
|
|
|
116
117
|
type: Literal["assistant"] = "assistant"
|
|
117
118
|
content: Sequence[ContentBlock]
|
|
118
|
-
model: str
|
|
119
|
+
model: Model | str
|
|
119
120
|
parent_tool_use_id: str | None = None
|
|
120
121
|
error: AssistantMessageError | None = None
|
|
121
122
|
session_id: str | None = None # not sure these two are needed.
|
|
@@ -281,7 +282,7 @@ class TaskProgressSystemMessage(BaseSystemMessage):
|
|
|
281
282
|
tool_use_id: str | None = None
|
|
282
283
|
description: str = ""
|
|
283
284
|
usage: TaskProgressUsage | None = None
|
|
284
|
-
last_tool_name: str | None = None
|
|
285
|
+
last_tool_name: ToolName | str | None = None
|
|
285
286
|
|
|
286
287
|
|
|
287
288
|
class FilePersistedEntry(TypedDict):
|
|
@@ -303,11 +304,9 @@ class FilesPersistedSystemMessage(BaseSystemMessage):
|
|
|
303
304
|
"""System message emitted when files have been persisted."""
|
|
304
305
|
|
|
305
306
|
subtype: Literal["files_persisted"] = "files_persisted"
|
|
306
|
-
files: list[FilePersistedEntry]
|
|
307
|
-
failed: list[FilePersistedFailure]
|
|
308
|
-
processed_at: str
|
|
309
|
-
uuid: str = ""
|
|
310
|
-
session_id: str = ""
|
|
307
|
+
files: list[FilePersistedEntry]
|
|
308
|
+
failed: list[FilePersistedFailure]
|
|
309
|
+
processed_at: str
|
|
311
310
|
|
|
312
311
|
|
|
313
312
|
@dataclass(kw_only=True)
|
|
@@ -315,12 +314,12 @@ class HookProgressSystemMessage(BaseSystemMessage):
|
|
|
315
314
|
"""Progress update from a running hook."""
|
|
316
315
|
|
|
317
316
|
subtype: Literal["hook_progress"] = "hook_progress"
|
|
318
|
-
hook_id: str
|
|
319
|
-
hook_name: str
|
|
320
|
-
hook_event: str
|
|
321
|
-
stdout: str
|
|
322
|
-
stderr: str
|
|
323
|
-
output: str
|
|
317
|
+
hook_id: str
|
|
318
|
+
hook_name: str
|
|
319
|
+
hook_event: str
|
|
320
|
+
stdout: str
|
|
321
|
+
stderr: str
|
|
322
|
+
output: str
|
|
324
323
|
|
|
325
324
|
|
|
326
325
|
@dataclass(kw_only=True)
|
|
@@ -331,7 +330,7 @@ class HookResponseSystemMessage(BaseSystemMessage):
|
|
|
331
330
|
hook_id: str
|
|
332
331
|
hook_name: str
|
|
333
332
|
hook_event: str
|
|
334
|
-
outcome:
|
|
333
|
+
outcome: Outcome
|
|
335
334
|
exit_code: int | None = None
|
|
336
335
|
stderr: str
|
|
337
336
|
stdout: str
|
|
@@ -366,7 +365,7 @@ class ModelUsage(TypedDict):
|
|
|
366
365
|
|
|
367
366
|
|
|
368
367
|
class SDKPermissionDenial(TypedDict):
|
|
369
|
-
tool_name: str
|
|
368
|
+
tool_name: ToolName | str
|
|
370
369
|
tool_use_id: str
|
|
371
370
|
tool_input: ToolInput
|
|
372
371
|
|
|
@@ -456,6 +455,39 @@ class AuthStatusMessage(BaseMessage):
|
|
|
456
455
|
error: str | None = None
|
|
457
456
|
|
|
458
457
|
|
|
458
|
+
@dataclass(kw_only=True)
|
|
459
|
+
class SDKSessionInfo:
|
|
460
|
+
"""Session metadata returned by list_sessions.
|
|
461
|
+
|
|
462
|
+
Contains summary information about a stored session without
|
|
463
|
+
loading the full conversation history.
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
session_id: str
|
|
467
|
+
"""Unique session identifier (UUID)."""
|
|
468
|
+
|
|
469
|
+
summary: str
|
|
470
|
+
"""Display title for the session: custom title, auto-generated summary, or first prompt."""
|
|
471
|
+
|
|
472
|
+
last_modified: int
|
|
473
|
+
"""Last modified time in milliseconds since epoch."""
|
|
474
|
+
|
|
475
|
+
file_size: int
|
|
476
|
+
"""Session file size in bytes."""
|
|
477
|
+
|
|
478
|
+
custom_title: str | None = None
|
|
479
|
+
"""User-set session title via /rename."""
|
|
480
|
+
|
|
481
|
+
first_prompt: str | None = None
|
|
482
|
+
"""First meaningful user prompt in the session."""
|
|
483
|
+
|
|
484
|
+
git_branch: str | None = None
|
|
485
|
+
"""Git branch at the end of the session."""
|
|
486
|
+
|
|
487
|
+
cwd: str | None = None
|
|
488
|
+
"""Working directory for the session."""
|
|
489
|
+
|
|
490
|
+
|
|
459
491
|
SystemMessageUnion = Annotated[
|
|
460
492
|
InitSystemMessage
|
|
461
493
|
| HookStartedSystemMessage
|
|
@@ -6,7 +6,7 @@ from dataclasses import dataclass, field
|
|
|
6
6
|
import json
|
|
7
7
|
import logging
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import TYPE_CHECKING, Any, Literal
|
|
9
|
+
from typing import TYPE_CHECKING, Any, Literal, TypedDict
|
|
10
10
|
|
|
11
11
|
|
|
12
12
|
if TYPE_CHECKING:
|
|
@@ -85,6 +85,25 @@ Can also be specified as a plain ``str``, which is a shortcut for
|
|
|
85
85
|
"""
|
|
86
86
|
|
|
87
87
|
|
|
88
|
+
class ListSessionsOptions(TypedDict, total=False):
|
|
89
|
+
"""Options for listing sessions.
|
|
90
|
+
|
|
91
|
+
When ``dir`` is provided, returns sessions for that project directory
|
|
92
|
+
and its git worktrees. When omitted, returns sessions across all projects.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
dir: str
|
|
96
|
+
"""Directory to list sessions for.
|
|
97
|
+
|
|
98
|
+
When provided, returns sessions for this project directory
|
|
99
|
+
(and its git worktrees). When omitted, returns sessions
|
|
100
|
+
across all projects.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
limit: int
|
|
104
|
+
"""Maximum number of sessions to return."""
|
|
105
|
+
|
|
106
|
+
|
|
88
107
|
def resolve_session_config(value: str | SessionConfig | None) -> SessionConfig:
|
|
89
108
|
"""Normalize a session config value.
|
|
90
109
|
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""End-to-end tests for MCP tools over the wire.
|
|
2
|
+
|
|
3
|
+
Tests whether content blocks from an external MCP server
|
|
4
|
+
(FastMCP stdio) are correctly received through the Claude Code CLI.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import sys
|
|
11
|
+
from typing import TYPE_CHECKING, Any
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from clawd_code_sdk import (
|
|
16
|
+
AssistantMessage,
|
|
17
|
+
ClaudeAgentOptions,
|
|
18
|
+
ClaudeSDKClient,
|
|
19
|
+
ResultMessage,
|
|
20
|
+
UserMessage,
|
|
21
|
+
)
|
|
22
|
+
from clawd_code_sdk.models.content_blocks import TextBlock, ToolResultBlock, ToolUseBlock
|
|
23
|
+
from clawd_code_sdk.models.messages import ToolProgressMessage
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from clawd_code_sdk.models.messages import Message
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@pytest.mark.e2e
|
|
31
|
+
@pytest.mark.asyncio
|
|
32
|
+
async def test_mcp_image_tool_wire_format():
|
|
33
|
+
"""Test that image content from an MCP tool flows through the wire protocol.
|
|
34
|
+
|
|
35
|
+
Configures Claude Code with a FastMCP stdio server that has a single tool
|
|
36
|
+
returning a PNG image, then asks Claude to call it and inspects what
|
|
37
|
+
content blocks come back.
|
|
38
|
+
"""
|
|
39
|
+
mcp_server_path = str(Path(__file__).parent.parent / "mcp_server.py")
|
|
40
|
+
|
|
41
|
+
options = ClaudeAgentOptions(
|
|
42
|
+
mcp_servers={
|
|
43
|
+
"image_test": {"type": "stdio", "command": sys.executable, "args": [mcp_server_path]},
|
|
44
|
+
},
|
|
45
|
+
permission_mode="bypassPermissions",
|
|
46
|
+
allow_dangerously_skip_permissions=True,
|
|
47
|
+
max_turns=3,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
messages: list[Message] = []
|
|
51
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
52
|
+
await client.query(
|
|
53
|
+
"Call the mcp__image_test__get_test_image tool and describe what you see."
|
|
54
|
+
)
|
|
55
|
+
async for message in client.receive_response():
|
|
56
|
+
messages.append(message)
|
|
57
|
+
|
|
58
|
+
# Verify we got a result
|
|
59
|
+
result_messages = [m for m in messages if isinstance(m, ResultMessage)]
|
|
60
|
+
assert result_messages, f"No ResultMessage. Got: {[type(m).__name__ for m in messages]}"
|
|
61
|
+
assert not result_messages[0].is_error, f"Query error: {result_messages[0].errors}"
|
|
62
|
+
|
|
63
|
+
# Inspect all content blocks from assistant and user messages
|
|
64
|
+
all_content_blocks: list[dict[str, Any]] = []
|
|
65
|
+
for msg in messages:
|
|
66
|
+
if isinstance(msg, AssistantMessage):
|
|
67
|
+
for block in msg.content:
|
|
68
|
+
all_content_blocks.append(
|
|
69
|
+
{"source": "assistant", "type": block.type, "block": block}
|
|
70
|
+
)
|
|
71
|
+
elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
|
|
72
|
+
for block in msg.content:
|
|
73
|
+
all_content_blocks.append({"source": "user", "type": block.type, "block": block})
|
|
74
|
+
|
|
75
|
+
# We expect at least a tool_use block (Claude calling the tool)
|
|
76
|
+
tool_use_blocks = [b for b in all_content_blocks if b["type"] == "tool_use"]
|
|
77
|
+
assert tool_use_blocks, "No tool_use blocks found - Claude didn't call the MCP tool"
|
|
78
|
+
|
|
79
|
+
# Verify the tool_use targeted our MCP tool
|
|
80
|
+
tool_use_block = tool_use_blocks[0]["block"]
|
|
81
|
+
assert isinstance(tool_use_block, ToolUseBlock)
|
|
82
|
+
assert tool_use_block.name == "mcp__image_test__get_test_image"
|
|
83
|
+
|
|
84
|
+
# Find tool_result blocks and verify image content via get_parsed_content()
|
|
85
|
+
tool_result_blocks = [b for b in all_content_blocks if b["type"] == "tool_result"]
|
|
86
|
+
assert tool_result_blocks, "No tool_result blocks found"
|
|
87
|
+
|
|
88
|
+
result_block = tool_result_blocks[0]["block"]
|
|
89
|
+
assert isinstance(result_block, ToolResultBlock)
|
|
90
|
+
assert isinstance(result_block.content, list), "Expected list content in tool result"
|
|
91
|
+
|
|
92
|
+
# Parse into typed Anthropic SDK content blocks
|
|
93
|
+
parsed = result_block.get_parsed_content()
|
|
94
|
+
assert isinstance(parsed, list)
|
|
95
|
+
assert len(parsed) >= 1
|
|
96
|
+
|
|
97
|
+
# Find the image block in parsed content
|
|
98
|
+
image_params = [b for b in parsed if isinstance(b, dict) and b.get("type") == "image"]
|
|
99
|
+
assert image_params, f"No image block in parsed content: {[type(b).__name__ for b in parsed]}"
|
|
100
|
+
|
|
101
|
+
image_param = image_params[0]
|
|
102
|
+
# BetaImageBlockParam is a TypedDict with source.type, source.data, source.media_type
|
|
103
|
+
assert image_param["type"] == "image"
|
|
104
|
+
assert "source" in image_param
|
|
105
|
+
assert image_param["source"]["type"] == "base64"
|
|
106
|
+
assert image_param["source"]["media_type"] == "image/png"
|
|
107
|
+
assert len(image_param["source"]["data"]) > 0, "Image data should not be empty"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@pytest.mark.e2e
|
|
111
|
+
@pytest.mark.asyncio
|
|
112
|
+
async def test_mcp_progress_tool_wire_format():
|
|
113
|
+
"""Test that MCP tool progress notifications flow through the wire protocol.
|
|
114
|
+
|
|
115
|
+
Configures Claude Code with a FastMCP stdio server that has a tool
|
|
116
|
+
reporting progress via ctx.report_progress(), then asks Claude to call it
|
|
117
|
+
and verifies that ToolProgressMessage events are received and the tool
|
|
118
|
+
completes successfully.
|
|
119
|
+
"""
|
|
120
|
+
mcp_server_path = str(Path(__file__).parent.parent / "mcp_server.py")
|
|
121
|
+
|
|
122
|
+
options = ClaudeAgentOptions(
|
|
123
|
+
mcp_servers={
|
|
124
|
+
"progress_test": {
|
|
125
|
+
"type": "stdio",
|
|
126
|
+
"command": sys.executable,
|
|
127
|
+
"args": [mcp_server_path],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
permission_mode="bypassPermissions",
|
|
131
|
+
allow_dangerously_skip_permissions=True,
|
|
132
|
+
max_turns=3,
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
messages: list[Message] = []
|
|
136
|
+
async with ClaudeSDKClient(options=options) as client:
|
|
137
|
+
await client.query(
|
|
138
|
+
'Call the mcp__progress_test__test_progress tool with message "hello from test".'
|
|
139
|
+
)
|
|
140
|
+
async for message in client.receive_response():
|
|
141
|
+
messages.append(message)
|
|
142
|
+
|
|
143
|
+
# Verify we got a successful result
|
|
144
|
+
result_messages = [m for m in messages if isinstance(m, ResultMessage)]
|
|
145
|
+
assert result_messages, f"No ResultMessage. Got: {[type(m).__name__ for m in messages]}"
|
|
146
|
+
assert not result_messages[0].is_error, f"Query error: {result_messages[0].errors}"
|
|
147
|
+
|
|
148
|
+
# Collect assistant content blocks to verify tool_use
|
|
149
|
+
all_content_blocks: list[dict[str, Any]] = []
|
|
150
|
+
for msg in messages:
|
|
151
|
+
if isinstance(msg, AssistantMessage):
|
|
152
|
+
for block in msg.content:
|
|
153
|
+
all_content_blocks.append(
|
|
154
|
+
{"source": "assistant", "type": block.type, "block": block}
|
|
155
|
+
)
|
|
156
|
+
elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
|
|
157
|
+
for block in msg.content:
|
|
158
|
+
all_content_blocks.append({"source": "user", "type": block.type, "block": block})
|
|
159
|
+
|
|
160
|
+
# Verify Claude called the progress tool
|
|
161
|
+
tool_use_blocks = [b for b in all_content_blocks if b["type"] == "tool_use"]
|
|
162
|
+
assert tool_use_blocks, "No tool_use blocks found - Claude didn't call the MCP tool"
|
|
163
|
+
|
|
164
|
+
tool_use_block = tool_use_blocks[0]["block"]
|
|
165
|
+
assert isinstance(tool_use_block, ToolUseBlock)
|
|
166
|
+
assert tool_use_block.name == "mcp__progress_test__test_progress"
|
|
167
|
+
|
|
168
|
+
# Verify the tool result contains the expected completion message
|
|
169
|
+
tool_result_blocks = [b for b in all_content_blocks if b["type"] == "tool_result"]
|
|
170
|
+
assert tool_result_blocks, "No tool_result blocks found"
|
|
171
|
+
|
|
172
|
+
result_block = tool_result_blocks[0]["block"]
|
|
173
|
+
assert isinstance(result_block, ToolResultBlock)
|
|
174
|
+
parsed = result_block.get_parsed_content()
|
|
175
|
+
# The result should contain the completion message as text
|
|
176
|
+
if isinstance(parsed, str):
|
|
177
|
+
assert "hello from test" in parsed
|
|
178
|
+
elif isinstance(parsed, list):
|
|
179
|
+
text_parts = [b.text for b in parsed if isinstance(b, TextBlock)] # type: ignore[union-attr]
|
|
180
|
+
combined = " ".join(text_parts)
|
|
181
|
+
assert "hello from test" in combined, f"Expected message in result, got: {combined}"
|
|
182
|
+
|
|
183
|
+
progress_messages = [m for m in messages if isinstance(m, ToolProgressMessage)]
|
|
184
|
+
# Right now no progress messages emitted from the subprocess, seems to be a bug.
|
|
185
|
+
# rever assertion in case it changes
|
|
186
|
+
assert not progress_messages
|
|
187
|
+
for pm in progress_messages:
|
|
188
|
+
assert pm.tool_name, "Progress message should have a tool_name"
|
|
189
|
+
assert pm.elapsed_time_seconds >= 0, "elapsed_time_seconds should be non-negative"
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
if __name__ == "__main__":
|
|
193
|
+
pytest.main([__file__, "-vv", "-m", "e2e"])
|
|
@@ -4,7 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
import anyio
|
|
8
|
+
from fastmcp import Context, FastMCP
|
|
8
9
|
from fastmcp.utilities.types import Image
|
|
9
10
|
|
|
10
11
|
|
|
@@ -18,5 +19,17 @@ async def get_test_image() -> Image:
|
|
|
18
19
|
return Image(data=png_path.read_bytes(), format="png")
|
|
19
20
|
|
|
20
21
|
|
|
22
|
+
@mcp.tool
|
|
23
|
+
async def test_progress(ctx: Context, message: str) -> str:
|
|
24
|
+
"""Test progress reporting with the given message."""
|
|
25
|
+
await ctx.report_progress(0, 100, "first step")
|
|
26
|
+
await anyio.sleep(0.5)
|
|
27
|
+
await ctx.report_progress(50, 100, "second step")
|
|
28
|
+
await anyio.sleep(0.5)
|
|
29
|
+
await ctx.report_progress(99, 100, "third step")
|
|
30
|
+
await anyio.sleep(0.5)
|
|
31
|
+
return f"Progress test completed with message: {message}"
|
|
32
|
+
|
|
33
|
+
|
|
21
34
|
if __name__ == "__main__":
|
|
22
35
|
mcp.run(show_banner=False, log_level="error")
|
|
@@ -1,121 +0,0 @@
|
|
|
1
|
-
"""End-to-end test for MCP tool returning image content over the wire.
|
|
2
|
-
|
|
3
|
-
Tests whether image content blocks from an external MCP server
|
|
4
|
-
(FastMCP stdio) are correctly received through the Claude Code CLI.
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
from __future__ import annotations
|
|
8
|
-
|
|
9
|
-
from pathlib import Path
|
|
10
|
-
import sys
|
|
11
|
-
from typing import TYPE_CHECKING, Any
|
|
12
|
-
|
|
13
|
-
import pytest
|
|
14
|
-
|
|
15
|
-
from clawd_code_sdk import (
|
|
16
|
-
AssistantMessage,
|
|
17
|
-
ClaudeAgentOptions,
|
|
18
|
-
ClaudeSDKClient,
|
|
19
|
-
ResultMessage,
|
|
20
|
-
UserMessage,
|
|
21
|
-
)
|
|
22
|
-
from clawd_code_sdk.models.content_blocks import ToolResultBlock, ToolUseBlock
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
if TYPE_CHECKING:
|
|
26
|
-
from clawd_code_sdk.models.messages import Message
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
@pytest.mark.e2e
|
|
30
|
-
@pytest.mark.asyncio
|
|
31
|
-
async def test_mcp_image_tool_wire_format():
|
|
32
|
-
"""Test that image content from an MCP tool flows through the wire protocol.
|
|
33
|
-
|
|
34
|
-
Configures Claude Code with a FastMCP stdio server that has a single tool
|
|
35
|
-
returning a PNG image, then asks Claude to call it and inspects what
|
|
36
|
-
content blocks come back.
|
|
37
|
-
"""
|
|
38
|
-
mcp_server_path = str(Path(__file__).parent.parent / "tests" / "mcp_server.py")
|
|
39
|
-
|
|
40
|
-
options = ClaudeAgentOptions(
|
|
41
|
-
mcp_servers={
|
|
42
|
-
"image_test": {
|
|
43
|
-
"type": "stdio",
|
|
44
|
-
"command": sys.executable,
|
|
45
|
-
"args": [mcp_server_path],
|
|
46
|
-
},
|
|
47
|
-
},
|
|
48
|
-
allowed_tools=["mcp__image_test__get_test_image"],
|
|
49
|
-
permission_mode="bypassPermissions",
|
|
50
|
-
allow_dangerously_skip_permissions=True,
|
|
51
|
-
max_turns=3,
|
|
52
|
-
)
|
|
53
|
-
|
|
54
|
-
messages: list[Message] = []
|
|
55
|
-
async with ClaudeSDKClient(options=options) as client:
|
|
56
|
-
await client.query(
|
|
57
|
-
"Call the mcp__image_test__get_test_image tool and describe what you see."
|
|
58
|
-
)
|
|
59
|
-
async for message in client.receive_response():
|
|
60
|
-
messages.append(message)
|
|
61
|
-
|
|
62
|
-
# Verify we got a result
|
|
63
|
-
result_messages = [m for m in messages if isinstance(m, ResultMessage)]
|
|
64
|
-
assert result_messages, f"No ResultMessage. Got: {[type(m).__name__ for m in messages]}"
|
|
65
|
-
assert not result_messages[0].is_error, f"Query error: {result_messages[0].errors}"
|
|
66
|
-
|
|
67
|
-
# Inspect all content blocks from assistant and user messages
|
|
68
|
-
all_content_blocks: list[dict[str, Any]] = []
|
|
69
|
-
for msg in messages:
|
|
70
|
-
if isinstance(msg, AssistantMessage):
|
|
71
|
-
for block in msg.content:
|
|
72
|
-
all_content_blocks.append(
|
|
73
|
-
{
|
|
74
|
-
"source": "assistant",
|
|
75
|
-
"type": block.type,
|
|
76
|
-
"block": block,
|
|
77
|
-
}
|
|
78
|
-
)
|
|
79
|
-
elif isinstance(msg, UserMessage) and isinstance(msg.content, list):
|
|
80
|
-
for block in msg.content:
|
|
81
|
-
all_content_blocks.append(
|
|
82
|
-
{
|
|
83
|
-
"source": "user",
|
|
84
|
-
"type": block.type,
|
|
85
|
-
"block": block,
|
|
86
|
-
}
|
|
87
|
-
)
|
|
88
|
-
|
|
89
|
-
# We expect at least a tool_use block (Claude calling the tool)
|
|
90
|
-
tool_use_blocks = [b for b in all_content_blocks if b["type"] == "tool_use"]
|
|
91
|
-
assert tool_use_blocks, "No tool_use blocks found - Claude didn't call the MCP tool"
|
|
92
|
-
|
|
93
|
-
# Verify the tool_use targeted our MCP tool
|
|
94
|
-
tool_use_block = tool_use_blocks[0]["block"]
|
|
95
|
-
assert isinstance(tool_use_block, ToolUseBlock)
|
|
96
|
-
assert tool_use_block.name == "mcp__image_test__get_test_image"
|
|
97
|
-
|
|
98
|
-
# Find tool_result blocks and verify image content via get_parsed_content()
|
|
99
|
-
tool_result_blocks = [b for b in all_content_blocks if b["type"] == "tool_result"]
|
|
100
|
-
assert tool_result_blocks, "No tool_result blocks found"
|
|
101
|
-
|
|
102
|
-
result_block = tool_result_blocks[0]["block"]
|
|
103
|
-
assert isinstance(result_block, ToolResultBlock)
|
|
104
|
-
assert isinstance(result_block.content, list), "Expected list content in tool result"
|
|
105
|
-
|
|
106
|
-
# Parse into typed Anthropic SDK content blocks
|
|
107
|
-
parsed = result_block.get_parsed_content()
|
|
108
|
-
assert isinstance(parsed, list)
|
|
109
|
-
assert len(parsed) >= 1
|
|
110
|
-
|
|
111
|
-
# Find the image block in parsed content
|
|
112
|
-
image_params = [b for b in parsed if isinstance(b, dict) and b.get("type") == "image"]
|
|
113
|
-
assert image_params, f"No image block in parsed content: {[type(b).__name__ for b in parsed]}"
|
|
114
|
-
|
|
115
|
-
image_param = image_params[0]
|
|
116
|
-
# BetaImageBlockParam is a TypedDict with source.type, source.data, source.media_type
|
|
117
|
-
assert image_param["type"] == "image"
|
|
118
|
-
assert "source" in image_param
|
|
119
|
-
assert image_param["source"]["type"] == "base64"
|
|
120
|
-
assert image_param["source"]["media_type"] == "image/png"
|
|
121
|
-
assert len(image_param["source"]["data"]) > 0, "Image data should not be empty"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/message_parser.py
RENAMED
|
File without changes
|
|
File without changes
|
{clawd_code_sdk-0.3.0 → clawd_code_sdk-0.3.1}/src/clawd_code_sdk/_internal/transport/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|