welt-io 0.1.0__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.
@@ -0,0 +1,9 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.so
4
+ dist/
5
+ .venv*/
6
+ .coverage
7
+ .pytest_cache/
8
+ .ruff_cache/
9
+ src/welt_io/_version.py
@@ -0,0 +1,48 @@
1
+ # Contributing
2
+
3
+ We welcome contributions! Please follow these guidelines when submitting pull requests.
4
+
5
+ ## Recommended setup
6
+
7
+ After cloning, enable the git hooks:
8
+
9
+ ```bash
10
+ mise run enable-git-hooks
11
+ ```
12
+
13
+ This wires `./validate.sh` to `pre-commit`, plus DCO and Conventional Commits checks to `commit-msg`, so the requirements below run automatically on each commit.
14
+
15
+ ## Before Submitting a PR
16
+
17
+ ### 1. Run Validation
18
+
19
+ ```bash
20
+ ./validate.sh
21
+ ```
22
+
23
+ Runs the project's validation pipeline.
24
+
25
+ ### 2. Sign Commits with DCO
26
+
27
+ All commits must be signed with DCO. Use `git commit -s` to sign your commits.
28
+
29
+ - Use your real name (no pseudonyms)
30
+ - All commits in a PR must be signed
31
+ - If the DCO check fails, see the check details page for instructions on fixing unsigned commits
32
+
33
+ For more about DCO: https://developercertificate.org/
34
+
35
+ ### 3. Follow Conventional Commits
36
+
37
+ Commit subjects (first line) must follow `<type>(<scope>)?!?: <description>`. Allowed types: `feat`, `fix`, `chore`, `docs`. Append `!` for breaking changes.
38
+
39
+ ```
40
+ feat: add dark-mode toggle
41
+ fix(auth): handle expired token
42
+ chore: bump dependencies
43
+ feat!: drop legacy API
44
+ ```
45
+
46
+ Other types (`refactor`, `ci`, `test`, etc.) are intentionally rejected — use `chore:` instead.
47
+
48
+ For more about Conventional Commits: https://www.conventionalcommits.org/
welt_io-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Takashi Iwamoto
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
welt_io-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,72 @@
1
+ Metadata-Version: 2.4
2
+ Name: welt-io
3
+ Version: 0.1.0
4
+ Summary: Agent-side adapters for Welt's wire contract
5
+ Project-URL: Repository, https://github.com/iwamot/welt-io
6
+ Project-URL: Issues, https://github.com/iwamot/welt-io/issues
7
+ Author: Takashi Iwamoto
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: agentcore,bedrock,slack,strands,welt
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Programming Language :: Python :: 3.13
16
+ Classifier: Programming Language :: Python :: 3.14
17
+ Classifier: Topic :: Software Development
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.12
20
+ Provides-Extra: dev
21
+ Requires-Dist: pip-licenses==5.5.5; extra == 'dev'
22
+ Requires-Dist: pytest-cov==7.1.0; extra == 'dev'
23
+ Requires-Dist: pytest==9.1.1; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # welt-io
27
+
28
+ [![pypi](https://img.shields.io/pypi/v/welt-io.svg)](https://pypi.org/project/welt-io/)
29
+ [![python](https://img.shields.io/pypi/pyversions/welt-io.svg)](https://pypi.org/project/welt-io/)
30
+
31
+ Agent-side adapters for [Welt](https://github.com/iwamot/welt)'s wire contract.
32
+
33
+ The wire between Welt and your agent is JSON, and plain Strands values do not fit it in either direction:
34
+
35
+ - **Inbound**, JSON cannot carry raw bytes, so Welt base64-encodes the `bytes` slot of the Converse image/document/video blocks it builds from Slack uploads. `decode_file_blocks` restores them before Strands (Bedrock Converse) sees the messages; without uploads it is a no-op.
36
+ - **Outbound**, raw `stream_async` events carry values that are not JSON-serializable (the Agent itself, UUIDs, traces), which the AgentCore Runtime SDK would degrade to a plain string on the SSE wire. `renderable_events` reduces the stream to the events Welt renders: text chunks (`data`), tool-use starts (`current_tool_use`), and tool completions (`tool_result`, slimmed to the toolUseId and status so tool output stays off the wire).
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ uv add welt-io
42
+ ```
43
+
44
+ ## Usage
45
+
46
+ ```python
47
+ from collections.abc import AsyncIterator
48
+
49
+ from bedrock_agentcore.runtime import BedrockAgentCoreApp
50
+ from strands import Agent
51
+ from welt_io import decode_file_blocks, renderable_events
52
+
53
+ app = BedrockAgentCoreApp()
54
+
55
+
56
+ @app.entrypoint
57
+ async def invoke(payload: dict) -> AsyncIterator[dict]:
58
+ messages = payload["messages"]
59
+ decode_file_blocks(messages) # base64 file bytes -> raw bytes, in place
60
+ agent = Agent()
61
+ # Reduce the stream to the JSON-serializable events Welt renders
62
+ async for event in renderable_events(agent.stream_async(messages)):
63
+ yield event
64
+
65
+
66
+ if __name__ == "__main__":
67
+ app.run()
68
+ ```
69
+
70
+ ## License
71
+
72
+ MIT
@@ -0,0 +1,47 @@
1
+ # welt-io
2
+
3
+ [![pypi](https://img.shields.io/pypi/v/welt-io.svg)](https://pypi.org/project/welt-io/)
4
+ [![python](https://img.shields.io/pypi/pyversions/welt-io.svg)](https://pypi.org/project/welt-io/)
5
+
6
+ Agent-side adapters for [Welt](https://github.com/iwamot/welt)'s wire contract.
7
+
8
+ The wire between Welt and your agent is JSON, and plain Strands values do not fit it in either direction:
9
+
10
+ - **Inbound**, JSON cannot carry raw bytes, so Welt base64-encodes the `bytes` slot of the Converse image/document/video blocks it builds from Slack uploads. `decode_file_blocks` restores them before Strands (Bedrock Converse) sees the messages; without uploads it is a no-op.
11
+ - **Outbound**, raw `stream_async` events carry values that are not JSON-serializable (the Agent itself, UUIDs, traces), which the AgentCore Runtime SDK would degrade to a plain string on the SSE wire. `renderable_events` reduces the stream to the events Welt renders: text chunks (`data`), tool-use starts (`current_tool_use`), and tool completions (`tool_result`, slimmed to the toolUseId and status so tool output stays off the wire).
12
+
13
+ ## Install
14
+
15
+ ```bash
16
+ uv add welt-io
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```python
22
+ from collections.abc import AsyncIterator
23
+
24
+ from bedrock_agentcore.runtime import BedrockAgentCoreApp
25
+ from strands import Agent
26
+ from welt_io import decode_file_blocks, renderable_events
27
+
28
+ app = BedrockAgentCoreApp()
29
+
30
+
31
+ @app.entrypoint
32
+ async def invoke(payload: dict) -> AsyncIterator[dict]:
33
+ messages = payload["messages"]
34
+ decode_file_blocks(messages) # base64 file bytes -> raw bytes, in place
35
+ agent = Agent()
36
+ # Reduce the stream to the JSON-serializable events Welt renders
37
+ async for event in renderable_events(agent.stream_async(messages)):
38
+ yield event
39
+
40
+
41
+ if __name__ == "__main__":
42
+ app.run()
43
+ ```
44
+
45
+ ## License
46
+
47
+ MIT
@@ -0,0 +1,9 @@
1
+ # Security Policy
2
+
3
+ ## Disclaimer
4
+
5
+ This software is provided "as is" without warranty. Security issues will be addressed on a best-effort basis.
6
+
7
+ ## Reporting a Vulnerability
8
+
9
+ Please report security vulnerabilities through GitHub's Security Advisory "Report a Vulnerability" feature.
@@ -0,0 +1,99 @@
1
+ [project]
2
+ name = "welt-io"
3
+ dynamic = ["version"]
4
+ description = "Agent-side adapters for Welt's wire contract"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ authors = [{ name = "Takashi Iwamoto" }]
9
+ keywords = ["welt", "slack", "agentcore", "bedrock", "strands"]
10
+ classifiers = [
11
+ "Development Status :: 3 - Alpha",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.12",
15
+ "Programming Language :: Python :: 3.13",
16
+ "Programming Language :: Python :: 3.14",
17
+ "Topic :: Software Development",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = []
21
+
22
+ [project.urls]
23
+ Repository = "https://github.com/iwamot/welt-io"
24
+ Issues = "https://github.com/iwamot/welt-io/issues"
25
+
26
+ [project.optional-dependencies]
27
+ dev = [
28
+ "pip-licenses==5.5.5",
29
+ "pytest==9.1.1",
30
+ "pytest-cov==7.1.0",
31
+ ]
32
+
33
+ [build-system]
34
+ requires = ["hatchling", "hatch-vcs"]
35
+ build-backend = "hatchling.build"
36
+
37
+ [tool.hatch.version]
38
+ source = "vcs"
39
+
40
+ [tool.hatch.build.hooks.vcs]
41
+ version-file = "src/welt_io/_version.py"
42
+
43
+ [tool.hatch.build.targets.wheel]
44
+ packages = ["src/welt_io"]
45
+
46
+ [tool.hatch.build.targets.sdist]
47
+ exclude = [
48
+ "/.codecov.yml",
49
+ "/.github",
50
+ "/compatibility.sh",
51
+ "/mise.toml",
52
+ "/uv.lock",
53
+ "/validate.sh",
54
+ ]
55
+
56
+ [tool.uv]
57
+ exclude-newer = "1 day"
58
+
59
+ [tool.ruff]
60
+ include = ["*.py", "src/**/*.py", "tests/**/*.py"]
61
+
62
+ [tool.ruff.lint]
63
+ # Reference: https://pydevtools.com/handbook/how-to/how-to-configure-recommended-ruff-defaults/
64
+ extend-select = [
65
+ "F", # Pyflakes rules
66
+ "W", # PyCodeStyle warnings
67
+ # "E", # PyCodeStyle errors
68
+ "I", # Sort imports properly
69
+ "UP", # Warn if certain things can changed due to newer Python versions
70
+ "C4", # Catch incorrect use of comprehensions, dict, list, etc
71
+ "FA", # Enforce from __future__ import annotations
72
+ "ISC", # Good use of string concatenation
73
+ "ICN", # Use common import conventions
74
+ "RET", # Good return practices
75
+ # "SIM", # Common simplification rules
76
+ "TID", # Some good import practices
77
+ # "TC", # Enforce importing certain types in a TYPE_CHECKING block
78
+ "PTH", # Use pathlib instead of os.path
79
+ "TD", # Be diligent with TODO comments
80
+ # "NPY", # Some numpy-specific things
81
+ # Not in the reference URL
82
+ "S", # flake8-bandit (security)
83
+ ]
84
+ ignore = [
85
+ "S101", # Allow assert in tests (pytest standard practice)
86
+ ]
87
+
88
+ [tool.ty.src]
89
+ include = ["src", "tests"]
90
+
91
+ [tool.pytest.ini_options]
92
+ testpaths = ["tests"]
93
+
94
+ [tool.coverage.run]
95
+ source = ["welt_io"]
96
+ branch = true
97
+
98
+ [tool.coverage.report]
99
+ show_missing = true
@@ -0,0 +1,110 @@
1
+ """Adapters for the two directions of Welt's wire contract.
2
+
3
+ The wire between Welt and the agent is JSON, and plain Strands values do not
4
+ fit it in either direction:
5
+
6
+ - Inbound, JSON cannot carry raw bytes, so Welt base64-encodes the `bytes`
7
+ slot of the Converse image/document/video blocks it builds from Slack
8
+ uploads. `decode_file_blocks` restores them before Strands (Bedrock
9
+ Converse) sees the messages; without uploads it is a no-op.
10
+ - Outbound, raw `stream_async` events carry values that are not
11
+ JSON-serializable (the Agent itself, UUIDs, traces), which the AgentCore
12
+ Runtime SDK would degrade to a plain string on the SSE wire.
13
+ `renderable_events` reduces the stream to the events Welt renders.
14
+ """
15
+
16
+ import base64
17
+ from collections.abc import AsyncIterator
18
+
19
+ try:
20
+ from ._version import __version__
21
+ except ImportError:
22
+ __version__ = "0.0.0+unknown"
23
+
24
+ __all__ = ["decode_file_blocks", "renderable_events"]
25
+
26
+
27
+ def decode_file_blocks(messages: list) -> None:
28
+ """
29
+ Decode base64 image/document/video bytes back to raw bytes, in place.
30
+
31
+ Args:
32
+ messages (list): The Converse-shaped messages from Welt's payload.
33
+
34
+ Returns:
35
+ None
36
+ """
37
+ for message in messages:
38
+ if not isinstance(message, dict):
39
+ continue
40
+ content = message.get("content")
41
+ if not isinstance(content, list):
42
+ continue
43
+ for block in content:
44
+ if not isinstance(block, dict):
45
+ continue
46
+ for key in ("image", "document", "video"):
47
+ media = block.get(key)
48
+ if not isinstance(media, dict):
49
+ continue
50
+ source = media.get("source")
51
+ if isinstance(source, dict) and isinstance(source.get("bytes"), str):
52
+ source["bytes"] = base64.b64decode(source["bytes"])
53
+
54
+
55
+ async def renderable_events(events: AsyncIterator[dict]) -> AsyncIterator[dict]:
56
+ """
57
+ Reduce Strands `stream_async` events to the subset Welt renders.
58
+
59
+ Args:
60
+ events (AsyncIterator[dict]): Raw `stream_async` events.
61
+
62
+ Yields:
63
+ dict: A `data` event per text chunk, a `current_tool_use` event per
64
+ tool-use update, and a `tool_result` event — slimmed to the
65
+ toolUseId and status, so tool output (arbitrarily large, possibly
66
+ raw bytes) stays off the wire — per completed tool.
67
+ """
68
+ async for event in events:
69
+ if "data" in event:
70
+ yield {"data": event["data"]}
71
+ elif "current_tool_use" in event:
72
+ yield {"current_tool_use": event["current_tool_use"]}
73
+ elif "message" in event:
74
+ for tool_result in _tool_results(event["message"]):
75
+ yield {"tool_result": tool_result}
76
+
77
+
78
+ def _tool_results(message: object) -> list[dict]:
79
+ """
80
+ Extract slimmed toolResult entries from a Strands message event.
81
+
82
+ Strands adds tool results to the conversation as a message whose content
83
+ blocks each carry a `toolResult`; model messages carry none, so they
84
+ yield an empty list.
85
+
86
+ Args:
87
+ message (object): The `message` value of a stream event.
88
+
89
+ Returns:
90
+ list[dict]: One `{"toolUseId", "status"}` entry per tool result.
91
+ """
92
+ if not isinstance(message, dict):
93
+ return []
94
+ content = message.get("content")
95
+ if not isinstance(content, list):
96
+ return []
97
+ results: list[dict] = []
98
+ for block in content:
99
+ if not isinstance(block, dict):
100
+ continue
101
+ tool_result = block.get("toolResult")
102
+ if not isinstance(tool_result, dict):
103
+ continue
104
+ results.append(
105
+ {
106
+ "toolUseId": tool_result.get("toolUseId"),
107
+ "status": tool_result.get("status"),
108
+ }
109
+ )
110
+ return results
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
File without changes
@@ -0,0 +1,110 @@
1
+ import base64
2
+
3
+ from welt_io import decode_file_blocks
4
+
5
+
6
+ def encoded(raw: bytes) -> str:
7
+ return base64.b64encode(raw).decode()
8
+
9
+
10
+ def test_decodes_image_document_and_video_blocks() -> None:
11
+ image_source: dict[str, object] = {"bytes": encoded(b"img")}
12
+ document_source: dict[str, object] = {"bytes": encoded(b"doc")}
13
+ video_source: dict[str, object] = {"bytes": encoded(b"vid")}
14
+ messages = [
15
+ {
16
+ "role": "user",
17
+ "content": [
18
+ {"image": {"format": "png", "source": image_source}},
19
+ {"document": {"format": "pdf", "source": document_source}},
20
+ {"video": {"format": "mp4", "source": video_source}},
21
+ ],
22
+ }
23
+ ]
24
+
25
+ decode_file_blocks(messages)
26
+
27
+ assert image_source["bytes"] == b"img"
28
+ assert document_source["bytes"] == b"doc"
29
+ assert video_source["bytes"] == b"vid"
30
+
31
+
32
+ def test_decodes_across_multiple_messages() -> None:
33
+ first_source: dict[str, object] = {"bytes": encoded(b"a")}
34
+ second_source: dict[str, object] = {"bytes": encoded(b"b")}
35
+ messages = [
36
+ {"role": "user", "content": [{"image": {"source": first_source}}]},
37
+ {"role": "user", "content": [{"image": {"source": second_source}}]},
38
+ ]
39
+
40
+ decode_file_blocks(messages)
41
+
42
+ assert first_source["bytes"] == b"a"
43
+ assert second_source["bytes"] == b"b"
44
+
45
+
46
+ def test_leaves_text_blocks_alone() -> None:
47
+ messages = [{"role": "user", "content": [{"text": "hello"}]}]
48
+
49
+ decode_file_blocks(messages)
50
+
51
+ assert messages == [{"role": "user", "content": [{"text": "hello"}]}]
52
+
53
+
54
+ def test_no_op_on_empty_messages() -> None:
55
+ messages: list = []
56
+
57
+ decode_file_blocks(messages)
58
+
59
+ assert messages == []
60
+
61
+
62
+ def test_skips_non_dict_message() -> None:
63
+ messages = ["not a dict"]
64
+
65
+ decode_file_blocks(messages)
66
+
67
+ assert messages == ["not a dict"]
68
+
69
+
70
+ def test_skips_non_list_content() -> None:
71
+ messages = [{"role": "user", "content": "not a list"}]
72
+
73
+ decode_file_blocks(messages)
74
+
75
+ assert messages == [{"role": "user", "content": "not a list"}]
76
+
77
+
78
+ def test_skips_non_dict_block() -> None:
79
+ messages = [{"role": "user", "content": ["not a dict"]}]
80
+
81
+ decode_file_blocks(messages)
82
+
83
+ assert messages == [{"role": "user", "content": ["not a dict"]}]
84
+
85
+
86
+ def test_skips_non_dict_media() -> None:
87
+ messages = [{"role": "user", "content": [{"image": "not a dict"}]}]
88
+
89
+ decode_file_blocks(messages)
90
+
91
+ assert messages == [{"role": "user", "content": [{"image": "not a dict"}]}]
92
+
93
+
94
+ def test_skips_non_dict_source() -> None:
95
+ messages = [{"role": "user", "content": [{"image": {"source": "not a dict"}}]}]
96
+
97
+ decode_file_blocks(messages)
98
+
99
+ assert messages == [
100
+ {"role": "user", "content": [{"image": {"source": "not a dict"}}]}
101
+ ]
102
+
103
+
104
+ def test_skips_bytes_that_are_not_str() -> None:
105
+ source: dict[str, object] = {"bytes": b"raw"}
106
+ messages = [{"role": "user", "content": [{"image": {"source": source}}]}]
107
+
108
+ decode_file_blocks(messages)
109
+
110
+ assert source["bytes"] == b"raw"
@@ -0,0 +1,137 @@
1
+ import asyncio
2
+ from collections.abc import AsyncIterator
3
+
4
+ from welt_io import renderable_events
5
+
6
+
7
+ def rendered(events: list) -> list[dict]:
8
+ async def source() -> AsyncIterator[dict]:
9
+ for event in events:
10
+ yield event
11
+
12
+ async def gather() -> list[dict]:
13
+ return [event async for event in renderable_events(source())]
14
+
15
+ return asyncio.run(gather())
16
+
17
+
18
+ def test_data_event_is_reduced_to_its_data_key() -> None:
19
+ events = [{"data": "hello", "delta": {"text": "hello"}, "agent": object()}]
20
+
21
+ assert rendered(events) == [{"data": "hello"}]
22
+
23
+
24
+ def test_current_tool_use_event_is_reduced_to_its_key() -> None:
25
+ tool_use = {"toolUseId": "id-1", "name": "current_time", "input": {}}
26
+ events = [{"current_tool_use": tool_use, "agent": object()}]
27
+
28
+ assert rendered(events) == [{"current_tool_use": tool_use}]
29
+
30
+
31
+ def test_data_takes_precedence_over_current_tool_use() -> None:
32
+ events = [{"data": "hello", "current_tool_use": {"toolUseId": "id-1"}}]
33
+
34
+ assert rendered(events) == [{"data": "hello"}]
35
+
36
+
37
+ def test_tool_result_message_is_slimmed_to_id_and_status() -> None:
38
+ events = [
39
+ {
40
+ "message": {
41
+ "role": "user",
42
+ "content": [
43
+ {
44
+ "toolResult": {
45
+ "toolUseId": "id-1",
46
+ "status": "success",
47
+ "content": [{"text": "arbitrarily large output"}],
48
+ }
49
+ }
50
+ ],
51
+ }
52
+ }
53
+ ]
54
+
55
+ assert rendered(events) == [
56
+ {"tool_result": {"toolUseId": "id-1", "status": "success"}}
57
+ ]
58
+
59
+
60
+ def test_yields_one_tool_result_per_block() -> None:
61
+ events = [
62
+ {
63
+ "message": {
64
+ "role": "user",
65
+ "content": [
66
+ {"toolResult": {"toolUseId": "id-1", "status": "success"}},
67
+ {"toolResult": {"toolUseId": "id-2", "status": "error"}},
68
+ ],
69
+ }
70
+ }
71
+ ]
72
+
73
+ assert rendered(events) == [
74
+ {"tool_result": {"toolUseId": "id-1", "status": "success"}},
75
+ {"tool_result": {"toolUseId": "id-2", "status": "error"}},
76
+ ]
77
+
78
+
79
+ def test_missing_tool_result_fields_become_none() -> None:
80
+ events = [{"message": {"role": "user", "content": [{"toolResult": {}}]}}]
81
+
82
+ assert rendered(events) == [{"tool_result": {"toolUseId": None, "status": None}}]
83
+
84
+
85
+ def test_model_message_without_tool_results_yields_nothing() -> None:
86
+ events = [{"message": {"role": "assistant", "content": [{"text": "hi"}]}}]
87
+
88
+ assert rendered(events) == []
89
+
90
+
91
+ def test_non_dict_message_yields_nothing() -> None:
92
+ assert rendered([{"message": "not a dict"}]) == []
93
+
94
+
95
+ def test_non_list_message_content_yields_nothing() -> None:
96
+ assert rendered([{"message": {"role": "user", "content": "not a list"}}]) == []
97
+
98
+
99
+ def test_non_dict_content_block_is_skipped() -> None:
100
+ assert rendered([{"message": {"role": "user", "content": ["not a dict"]}}]) == []
101
+
102
+
103
+ def test_non_dict_tool_result_is_skipped() -> None:
104
+ events = [{"message": {"role": "user", "content": [{"toolResult": "not a dict"}]}}]
105
+
106
+ assert rendered(events) == []
107
+
108
+
109
+ def test_unrenderable_events_are_dropped() -> None:
110
+ events = [
111
+ {"init_event_loop": True},
112
+ {"delta": {"text": "chunk"}},
113
+ {"result": object()},
114
+ ]
115
+
116
+ assert rendered(events) == []
117
+
118
+
119
+ def test_stream_order_is_preserved() -> None:
120
+ events = [
121
+ {"data": "a"},
122
+ {"current_tool_use": {"toolUseId": "id-1"}},
123
+ {
124
+ "message": {
125
+ "role": "user",
126
+ "content": [{"toolResult": {"toolUseId": "id-1", "status": "success"}}],
127
+ }
128
+ },
129
+ {"data": "b"},
130
+ ]
131
+
132
+ assert rendered(events) == [
133
+ {"data": "a"},
134
+ {"current_tool_use": {"toolUseId": "id-1"}},
135
+ {"tool_result": {"toolUseId": "id-1", "status": "success"}},
136
+ {"data": "b"},
137
+ ]