llm-tool-cli 0.2.0__py3-none-any.whl

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.
Files changed (111) hide show
  1. llm_tool_cli/__init__.py +0 -0
  2. llm_tool_cli/cli/__init__.py +1 -0
  3. llm_tool_cli/cli/application.py +31 -0
  4. llm_tool_cli/cli/commands/__init__.py +0 -0
  5. llm_tool_cli/cli/commands/initialization.py +36 -0
  6. llm_tool_cli/cli/commands/skills.py +25 -0
  7. llm_tool_cli/cli/commands/tests/__init__.py +0 -0
  8. llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
  9. llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
  10. llm_tool_cli/cli/commands/tests/test_version.py +98 -0
  11. llm_tool_cli/cli/commands/version.py +17 -0
  12. llm_tool_cli/cli/context.py +39 -0
  13. llm_tool_cli/cli/entities.py +3 -0
  14. llm_tool_cli/cli/errors.py +15 -0
  15. llm_tool_cli/cli/handling.py +41 -0
  16. llm_tool_cli/cli/options.py +48 -0
  17. llm_tool_cli/cli/tests/__init__.py +0 -0
  18. llm_tool_cli/cli/tests/test_application.py +167 -0
  19. llm_tool_cli/cli/tests/test_context.py +179 -0
  20. llm_tool_cli/cli/tests/test_handling.py +230 -0
  21. llm_tool_cli/cli/tests/test_options.py +125 -0
  22. llm_tool_cli/config/__init__.py +33 -0
  23. llm_tool_cli/config/errors.py +92 -0
  24. llm_tool_cli/config/fields.py +39 -0
  25. llm_tool_cli/config/files.py +175 -0
  26. llm_tool_cli/config/project.py +87 -0
  27. llm_tool_cli/config/runtime.py +14 -0
  28. llm_tool_cli/config/tests/__init__.py +0 -0
  29. llm_tool_cli/config/tests/fixtures.py +34 -0
  30. llm_tool_cli/config/tests/test_fields.py +124 -0
  31. llm_tool_cli/config/tests/test_files.py +826 -0
  32. llm_tool_cli/config/tests/test_project.py +257 -0
  33. llm_tool_cli/core/__init__.py +1 -0
  34. llm_tool_cli/core/context.py +50 -0
  35. llm_tool_cli/core/entities.py +35 -0
  36. llm_tool_cli/core/errors.py +97 -0
  37. llm_tool_cli/core/result.py +118 -0
  38. llm_tool_cli/core/settings.py +37 -0
  39. llm_tool_cli/core/tests/__init__.py +0 -0
  40. llm_tool_cli/core/tests/fixtures.py +13 -0
  41. llm_tool_cli/core/tests/test_context.py +152 -0
  42. llm_tool_cli/core/tests/test_entities.py +65 -0
  43. llm_tool_cli/core/tests/test_errors.py +211 -0
  44. llm_tool_cli/core/tests/test_result.py +256 -0
  45. llm_tool_cli/core/tests/test_settings.py +64 -0
  46. llm_tool_cli/output/__init__.py +3 -0
  47. llm_tool_cli/output/emitter.py +108 -0
  48. llm_tool_cli/output/errors.py +21 -0
  49. llm_tool_cli/output/journal.py +66 -0
  50. llm_tool_cli/output/tests/__init__.py +0 -0
  51. llm_tool_cli/output/tests/helpers.py +29 -0
  52. llm_tool_cli/output/tests/test_emitter.py +406 -0
  53. llm_tool_cli/output/tests/test_journal.py +141 -0
  54. llm_tool_cli/paths/__init__.py +51 -0
  55. llm_tool_cli/paths/enumeration.py +72 -0
  56. llm_tool_cli/paths/errors.py +42 -0
  57. llm_tool_cli/paths/filesystem.py +164 -0
  58. llm_tool_cli/paths/globs.py +124 -0
  59. llm_tool_cli/paths/normalization.py +58 -0
  60. llm_tool_cli/paths/tests/__init__.py +0 -0
  61. llm_tool_cli/paths/tests/test_enumeration.py +229 -0
  62. llm_tool_cli/paths/tests/test_filesystem.py +778 -0
  63. llm_tool_cli/paths/tests/test_globs.py +137 -0
  64. llm_tool_cli/paths/tests/test_normalization.py +163 -0
  65. llm_tool_cli/protocol/__init__.py +4 -0
  66. llm_tool_cli/protocol/cell_shortcuts.py +39 -0
  67. llm_tool_cli/protocol/entities.py +7 -0
  68. llm_tool_cli/protocol/errors.py +21 -0
  69. llm_tool_cli/protocol/journal.py +21 -0
  70. llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
  71. llm_tool_cli/protocol/logic_cells/base.py +41 -0
  72. llm_tool_cli/protocol/logic_cells/content.py +26 -0
  73. llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
  74. llm_tool_cli/protocol/logic_cells/journal.py +44 -0
  75. llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
  76. llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
  77. llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
  78. llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
  79. llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
  80. llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
  81. llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
  82. llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
  83. llm_tool_cli/protocol/logic_cells/warning.py +22 -0
  84. llm_tool_cli/protocol/output_cells/__init__.py +11 -0
  85. llm_tool_cli/protocol/output_cells/automation.py +14 -0
  86. llm_tool_cli/protocol/output_cells/base.py +65 -0
  87. llm_tool_cli/protocol/output_cells/human.py +24 -0
  88. llm_tool_cli/protocol/output_cells/journal.py +23 -0
  89. llm_tool_cli/protocol/output_cells/llm.py +25 -0
  90. llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
  91. llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
  92. llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
  93. llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
  94. llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
  95. llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
  96. llm_tool_cli/protocol/serialization.py +7 -0
  97. llm_tool_cli/protocol/tests/__init__.py +0 -0
  98. llm_tool_cli/protocol/tests/helpers.py +25 -0
  99. llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
  100. llm_tool_cli/protocol/tests/test_journal.py +44 -0
  101. llm_tool_cli/protocol/tests/test_serialization.py +33 -0
  102. llm_tool_cli/py.typed +0 -0
  103. llm_tool_cli/skills/__init__.py +6 -0
  104. llm_tool_cli/skills/errors.py +12 -0
  105. llm_tool_cli/skills/fixtures.py +14 -0
  106. llm_tool_cli/skills/tests/__init__.py +0 -0
  107. llm_tool_cli/skills/tests/test_fixtures.py +58 -0
  108. llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
  109. llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
  110. llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
  111. llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
@@ -0,0 +1,130 @@
1
+ from decimal import Decimal
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from llm_tool_cli.core.errors import EnvironmentError
7
+ from llm_tool_cli.protocol import Protocol
8
+ from llm_tool_cli.protocol.logic_cells import EnvironmentErrorCell
9
+ from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanErrorOutputCell, LLMOutputCell
10
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
11
+
12
+
13
+ class ContextFailure(EnvironmentError):
14
+ path: Path
15
+ labels: list[str]
16
+ retryable: bool
17
+ attempts: int
18
+ detail: str | None
19
+ amount: Decimal
20
+
21
+
22
+ class TestEnvironmentErrorCell:
23
+ def test_to_journal_record__keeps_structured_error_without_private_cause(self) -> None:
24
+ error = ContextFailure(
25
+ code="unavailable",
26
+ message="{error.path}: failed",
27
+ path=Path("/project"),
28
+ labels=["first"],
29
+ retryable=False,
30
+ attempts=0,
31
+ detail=None,
32
+ amount=Decimal("1.5"),
33
+ ).with_cause(OSError("private"))
34
+ original = error.model_dump()
35
+ record = EnvironmentErrorCell(error=error).to_journal_record()
36
+ assert record.message == "/project: failed"
37
+ assert record.attributes == {
38
+ "type": "error",
39
+ "code": "unavailable",
40
+ "path": "/project",
41
+ "labels": ["first"],
42
+ "retryable": False,
43
+ "attempts": 0,
44
+ "detail": None,
45
+ "amount": "1.5",
46
+ }
47
+ assert error.model_dump() == original
48
+
49
+ @pytest.mark.parametrize(
50
+ ("protocol", "output_type"),
51
+ [
52
+ (Protocol.human, HumanErrorOutputCell),
53
+ (Protocol.llm, LLMOutputCell),
54
+ (Protocol.automation, AutomationOutputCell),
55
+ ],
56
+ )
57
+ def test_render__retains_typed_error_and_projects_serialized_context(
58
+ self, protocol: Protocol, output_type: type[OutputCell]
59
+ ) -> None:
60
+ error = ContextFailure(
61
+ code="unavailable",
62
+ message="{error.path}: café\nTry again.",
63
+ path=Path("/project"),
64
+ labels=["one", "two"],
65
+ retryable=False,
66
+ attempts=0,
67
+ detail=None,
68
+ amount=Decimal("1.5"),
69
+ ).with_cause(OSError("private"))
70
+ original = error.model_dump()
71
+ logic_cell = EnvironmentErrorCell(error=error)
72
+
73
+ cells = logic_cell.render(protocol)
74
+
75
+ assert isinstance(logic_cell.error, ContextFailure)
76
+ assert logic_cell.error.path == Path("/project")
77
+ assert len(cells) == 1
78
+ cell = cells[0]
79
+ assert isinstance(cell, output_type)
80
+ assert cell.kind == "error"
81
+ assert cell.media_type == "text/markdown"
82
+ assert cell.content == "/project: café\nTry again."
83
+ assert cell.meta == {
84
+ "type": "error",
85
+ "code": "unavailable",
86
+ "path": "/project",
87
+ "labels": ["one", "two"],
88
+ "retryable": False,
89
+ "attempts": 0,
90
+ "detail": None,
91
+ "amount": "1.5",
92
+ }
93
+ assert error.model_dump() == original
94
+ assert logic_cell.error.model_dump() == original
95
+ assert isinstance(logic_cell.error.cause, OSError)
96
+
97
+ @pytest.mark.parametrize("protocol", list(Protocol))
98
+ @pytest.mark.parametrize(
99
+ ("guidance", "suffix"),
100
+ [
101
+ ([], ""),
102
+ ([" Retry {error.code}. "], "\nWay to fix: Retry unavailable."),
103
+ (
104
+ [" Retry {error.code}. ", "Check configuration."],
105
+ "\n\nWays to fix:\n\n- Retry unavailable.\n- Check configuration.",
106
+ ),
107
+ ],
108
+ )
109
+ def test_render__includes_formatted_guidance(self, protocol: Protocol, guidance: list[str], suffix: str) -> None:
110
+ error = EnvironmentError(code="unavailable", message="Service unavailable", ways_to_fix=guidance)
111
+ original = error.model_dump()
112
+
113
+ cell = EnvironmentErrorCell(error=error).render(protocol)[0]
114
+
115
+ assert cell.content == "Service unavailable" + suffix
116
+ assert cell.meta == {"type": "error", "code": "unavailable"}
117
+ assert error.model_dump() == original
118
+
119
+ @pytest.mark.parametrize("protocol", list(Protocol))
120
+ def test_render__recomputes_output_with_fresh_identifiers(self, protocol: Protocol) -> None:
121
+ error = EnvironmentError(code="empty", message="")
122
+ cell = EnvironmentErrorCell(error=error)
123
+
124
+ first = cell.render(protocol)[0]
125
+ second = cell.render(protocol)[0]
126
+
127
+ assert first.id != second.id
128
+ assert first.model_dump(exclude={"id"}) == second.model_dump(exclude={"id"})
129
+ assert first.content == ""
130
+ assert first.meta == {"type": "error", "code": "empty"}
@@ -0,0 +1,47 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from llm_tool_cli.core.settings import ToolLabel
6
+ from llm_tool_cli.protocol import Protocol
7
+ from llm_tool_cli.protocol.logic_cells.journal import JournalCell
8
+ from llm_tool_cli.protocol.output_cells.base import RenderContext
9
+
10
+
11
+ class TestJournalCell:
12
+ def test_to_journal_record__preserves_timestamp_and_subclass_fields(self) -> None:
13
+ class EventCell(JournalCell):
14
+ task_id: str | None = None
15
+
16
+ cell = EventCell(message="event", actor_id="actor", task_id="T-1", is_warning=True)
17
+ original = cell.model_dump()
18
+ record = cell.to_journal_record()
19
+ assert record.timestamp == cell.timestamp
20
+ assert record.message == cell.message
21
+ assert record.actor_id == "actor"
22
+ assert record.attributes == {"task_id": "T-1"}
23
+ assert cell.model_dump() == original
24
+
25
+ @pytest.mark.parametrize("protocol", list(Protocol))
26
+ def test_render__uses_normalized_multiline_data_and_emits_one_line(self, protocol: Protocol) -> None:
27
+ message = " first\nsecond\rthird\\n "
28
+ cell = JournalCell(message=message, actor_id=" actor\nname ")
29
+ context = RenderContext(index=0, total=1, tool_label=ToolLabel("TEST"))
30
+ rendered = cell.render(protocol)[0].render(context).decode()
31
+ assert cell.message == message.strip()
32
+ assert len(rendered.splitlines()) == 1
33
+ if protocol == Protocol.automation:
34
+ record = json.loads(rendered)
35
+ assert record["message"] == message.strip()
36
+ assert record["actor_id"] == "actor\nname"
37
+ assert "is_journal" not in record
38
+ assert "is_warning" not in record
39
+ else:
40
+ assert r"first\nsecond\rthird\\n" in rendered
41
+ assert r"actor\nname" in rendered
42
+
43
+ def test_render__absent_actor_and_empty_message(self) -> None:
44
+ cell = JournalCell(message="")
45
+ context = RenderContext(index=0, total=1, tool_label=ToolLabel("TEST"))
46
+ for protocol in [Protocol.human, Protocol.llm]:
47
+ assert "<->" in cell.render(protocol)[0].render(context).decode()
@@ -0,0 +1,60 @@
1
+ import pytest
2
+ from pytest_mock import MockerFixture
3
+
4
+ from llm_tool_cli.protocol import Protocol
5
+ from llm_tool_cli.protocol.errors import JournalConversionUnsupported
6
+ from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
7
+ from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell, LLMOutputCell
8
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
9
+
10
+
11
+ class ExampleUniformCell(UniformCell):
12
+ items: tuple[str, ...]
13
+
14
+ def _render(self, cell_type: type[OutputCell]) -> OutputCell:
15
+ return cell_type.build_markdown(kind="items", content="\n".join(self.items), items=list(self.items))
16
+
17
+
18
+ class TestUniformCell:
19
+ def test_to_journal_record__does_not_fall_back_to_rendering(self, mocker: MockerFixture) -> None:
20
+ cell = ExampleUniformCell(items=("first", "second"))
21
+ render = mocker.spy(ExampleUniformCell, "_render")
22
+
23
+ with pytest.raises(JournalConversionUnsupported):
24
+ cell.to_journal_record()
25
+
26
+ render.assert_not_called()
27
+
28
+ @pytest.mark.parametrize(
29
+ ("protocol", "cell_type"),
30
+ [
31
+ (Protocol.human, HumanOutputCell),
32
+ (Protocol.llm, LLMOutputCell),
33
+ (Protocol.automation, AutomationOutputCell),
34
+ ],
35
+ )
36
+ @pytest.mark.parametrize("items", [(), ("first", "café")])
37
+ def test_render__projects_one_fresh_cell_without_mutating_input(
38
+ self, protocol: Protocol, cell_type: type[OutputCell], items: tuple[str, ...]
39
+ ) -> None:
40
+ cell = ExampleUniformCell(items=items)
41
+ original = cell.model_dump()
42
+
43
+ first = cell.render(protocol)
44
+ second = cell.render(protocol)
45
+
46
+ assert len(first) == len(second) == 1
47
+ assert isinstance(first[0], cell_type)
48
+ assert isinstance(second[0], cell_type)
49
+ assert (
50
+ first[0].model_dump(exclude={"id"})
51
+ == second[0].model_dump(exclude={"id"})
52
+ == {
53
+ "kind": "items",
54
+ "media_type": "text/markdown",
55
+ "content": "\n".join(items),
56
+ "meta": {"items": list(items)},
57
+ }
58
+ )
59
+ assert first[0].id != second[0].id
60
+ assert cell.model_dump() == original
@@ -0,0 +1,34 @@
1
+ import pytest
2
+
3
+ from llm_tool_cli.protocol import Protocol
4
+ from llm_tool_cli.protocol.logic_cells.warning import WarningCell
5
+ from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanWarningOutputCell, LLMOutputCell
6
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
7
+
8
+
9
+ class TestWarningCell:
10
+ def test_to_journal_record__retains_warning_message(self) -> None:
11
+ cell = WarningCell(message="first\nsecond", is_journal=True)
12
+ record = cell.to_journal_record()
13
+ assert record.message == "first\nsecond"
14
+ assert record.attributes == {"type": "warning"}
15
+
16
+ @pytest.mark.parametrize(
17
+ ("protocol", "output_type"),
18
+ [
19
+ (Protocol.human, HumanWarningOutputCell),
20
+ (Protocol.llm, LLMOutputCell),
21
+ (Protocol.automation, AutomationOutputCell),
22
+ ],
23
+ )
24
+ def test_render__preserves_warning_payload_without_delivery_flags(
25
+ self, protocol: Protocol, output_type: type[OutputCell]
26
+ ) -> None:
27
+ cell = WarningCell(message="first\nsecond", is_warning=False)
28
+ rendered = cell.render(protocol)
29
+ assert len(rendered) == 1
30
+ assert isinstance(rendered[0], output_type)
31
+ assert rendered[0].kind == "warning"
32
+ assert rendered[0].content is None
33
+ assert rendered[0].meta == {"type": "warning", "message": "first\nsecond"}
34
+ assert "is_warning" not in cell.model_dump()
@@ -0,0 +1,21 @@
1
+ from abc import abstractmethod
2
+
3
+ from llm_tool_cli.protocol.logic_cells.base import LogicCell
4
+ from llm_tool_cli.protocol.output_cells import AutomationOutputCell, HumanOutputCell, LLMOutputCell
5
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
6
+
7
+
8
+ class UniformCell(LogicCell):
9
+ """One output cell with the same payload in each output protocol."""
10
+
11
+ def render_human(self) -> list[OutputCell]:
12
+ return [self._render(HumanOutputCell)]
13
+
14
+ def render_llm(self) -> list[OutputCell]:
15
+ return [self._render(LLMOutputCell)]
16
+
17
+ def render_automation(self) -> list[OutputCell]:
18
+ return [self._render(AutomationOutputCell)]
19
+
20
+ @abstractmethod
21
+ def _render(self, cell_type: type[OutputCell]) -> OutputCell: ... # noqa: E704
@@ -0,0 +1,22 @@
1
+ import pydantic
2
+
3
+ from llm_tool_cli.protocol.journal import JournalRecord
4
+ from llm_tool_cli.protocol.logic_cells.uniform import UniformCell
5
+ from llm_tool_cli.protocol.output_cells import HumanWarningOutputCell
6
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
7
+
8
+
9
+ class WarningCell(UniformCell):
10
+ """A non-fatal diagnostic delivered after ordinary results."""
11
+
12
+ is_warning: bool = pydantic.Field(default=True, exclude=True)
13
+ message: str
14
+
15
+ def render_human(self) -> list[OutputCell]:
16
+ return [self._render(HumanWarningOutputCell)]
17
+
18
+ def to_journal_record(self) -> JournalRecord:
19
+ return JournalRecord(message=self.message, attributes={"type": "warning"})
20
+
21
+ def _render(self, cell_type: type[OutputCell]) -> OutputCell:
22
+ return cell_type.build_meta(kind="warning", type="warning", message=self.message)
@@ -0,0 +1,11 @@
1
+ from llm_tool_cli.protocol.output_cells.automation import AutomationOutputCell
2
+ from llm_tool_cli.protocol.output_cells.human import HumanErrorOutputCell, HumanOutputCell, HumanWarningOutputCell
3
+ from llm_tool_cli.protocol.output_cells.llm import LLMOutputCell
4
+
5
+ __all__ = [
6
+ "AutomationOutputCell",
7
+ "HumanErrorOutputCell",
8
+ "HumanOutputCell",
9
+ "HumanWarningOutputCell",
10
+ "LLMOutputCell",
11
+ ]
@@ -0,0 +1,14 @@
1
+ from llm_tool_cli.protocol import to_jsonl
2
+ from llm_tool_cli.protocol.output_cells.base import MetaValue, OutputCell, RenderContext
3
+
4
+
5
+ class AutomationOutputCell(OutputCell):
6
+ def render(self, context: RenderContext) -> bytes:
7
+ data: dict[str, MetaValue] = {"id": self.short_id}
8
+
9
+ for meta_key, meta_value in sorted(self.meta.items()):
10
+ data[meta_key] = meta_value
11
+
12
+ data["content"] = self.content.strip() if self.content else None
13
+
14
+ return to_jsonl(data).encode("utf-8")
@@ -0,0 +1,65 @@
1
+ import base64
2
+ import uuid
3
+ from abc import ABC, abstractmethod
4
+ from typing import Self
5
+
6
+ import pydantic
7
+
8
+ from llm_tool_cli.core.entities import BaseEntity
9
+
10
+ MetaValue = str | int | bool | None | list[str]
11
+
12
+
13
+ def to_meta_value(value: object) -> MetaValue:
14
+ if isinstance(value, (str, int, bool)) or value is None:
15
+ return value
16
+ if isinstance(value, list) and all(isinstance(item, str) for item in value):
17
+ return value
18
+
19
+ return str(value)
20
+
21
+
22
+ class RenderContext(BaseEntity):
23
+ model_config = pydantic.ConfigDict(str_strip_whitespace=False)
24
+
25
+ index: int = pydantic.Field(ge=0)
26
+ total: int = pydantic.Field(ge=1)
27
+ tool_label: str
28
+
29
+ @pydantic.model_validator(mode="after")
30
+ def validate_position(self) -> Self:
31
+ if self.index >= self.total:
32
+ raise ValueError("Cell index must be smaller than the sequence size")
33
+ return self
34
+
35
+
36
+ class OutputCell(BaseEntity, ABC):
37
+ id: uuid.UUID = pydantic.Field(default_factory=uuid.uuid4)
38
+ kind: str
39
+ media_type: str | None
40
+ content: str | None
41
+ meta: dict[str, MetaValue] = pydantic.Field(default_factory=dict)
42
+
43
+ @classmethod
44
+ def build(cls, kind: str, media_type: str | None, content: str | None, **meta: MetaValue) -> Self:
45
+ if media_type is None and content is not None:
46
+ from llm_tool_cli.protocol.errors import ContentWithoutMediaType
47
+
48
+ raise ContentWithoutMediaType()
49
+
50
+ return cls(kind=kind, media_type=media_type, content=content, meta=meta)
51
+
52
+ @classmethod
53
+ def build_meta(cls, kind: str, **meta: MetaValue) -> Self:
54
+ return cls.build(kind=kind, media_type=None, content=None, **meta)
55
+
56
+ @classmethod
57
+ def build_markdown(cls, kind: str, content: str, **meta: MetaValue) -> Self:
58
+ return cls.build(kind=kind, media_type="text/markdown", content=content, **meta)
59
+
60
+ @property
61
+ def short_id(self) -> str:
62
+ return base64.urlsafe_b64encode(self.id.bytes).rstrip(b"=").decode()
63
+
64
+ @abstractmethod
65
+ def render(self, context: RenderContext) -> bytes: ... # noqa: E704
@@ -0,0 +1,24 @@
1
+ from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
2
+
3
+
4
+ class HumanOutputCell(OutputCell):
5
+ def render(self, context: RenderContext) -> bytes:
6
+ sections = [self._render_metadata(), (self.content or "").strip()]
7
+ text = "\n\n".join(section for section in sections if section) or self.kind
8
+ return (text + "\n\n").encode("utf-8")
9
+
10
+ def _render_metadata(self) -> str:
11
+ return "\n".join(
12
+ f"{key}: {value}" for key, value in sorted(self.meta.items()) if not (key == "type" and value == self.kind)
13
+ )
14
+
15
+
16
+ class HumanErrorOutputCell(HumanOutputCell):
17
+ def render(self, context: RenderContext) -> bytes:
18
+ sections = [f"Error: {(self.content or '').strip()}".rstrip(), self._render_metadata()]
19
+ return ("\n\n".join(section for section in sections if section) + "\n\n").encode("utf-8")
20
+
21
+
22
+ class HumanWarningOutputCell(HumanOutputCell):
23
+ def render(self, context: RenderContext) -> bytes:
24
+ return (f"Warning: {self.meta['message']}".rstrip() + "\n\n").encode("utf-8")
@@ -0,0 +1,23 @@
1
+ import pydantic
2
+
3
+ from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
4
+ from llm_tool_cli.protocol.serialization import to_jsonl
5
+
6
+
7
+ class JournalTextOutputCell(OutputCell):
8
+ """Compact journal presentation without changing the logical message."""
9
+
10
+ model_config = pydantic.ConfigDict(str_strip_whitespace=False)
11
+
12
+ def render(self, context: RenderContext) -> bytes:
13
+ text = (self.content or "").replace("\\", "\\\\").replace("\r", "\\r").replace("\n", "\\n")
14
+ return (text + "\n").encode("utf-8")
15
+
16
+
17
+ class JournalAutomationOutputCell(OutputCell):
18
+ """One structured journal record with its original field values."""
19
+
20
+ model_config = pydantic.ConfigDict(str_strip_whitespace=False)
21
+
22
+ def render(self, context: RenderContext) -> bytes:
23
+ return to_jsonl(self.meta).encode("utf-8")
@@ -0,0 +1,25 @@
1
+ from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
2
+
3
+
4
+ class LLMOutputCell(OutputCell):
5
+ def render(self, context: RenderContext) -> bytes: # noqa: CCR001
6
+ id = self.short_id
7
+
8
+ lines = [f"--{context.tool_label}-CELL {id} BEGIN--"]
9
+
10
+ lines.append(f"kind={self.kind}")
11
+
12
+ if self.media_type is not None:
13
+ lines.append(f"media_type={self.media_type}")
14
+
15
+ for meta_key, meta_value in sorted(self.meta.items()):
16
+ lines.append(f"{meta_key}={meta_value}")
17
+
18
+ if self.content:
19
+ lines.append("")
20
+ lines.append(self.content.strip())
21
+
22
+ lines.append(f"--{context.tool_label}-CELL {id} END--")
23
+ lines.append("")
24
+
25
+ return "\n".join(lines).encode()
File without changes
@@ -0,0 +1,15 @@
1
+ import uuid
2
+
3
+ from llm_tool_cli.protocol.output_cells.base import OutputCell
4
+
5
+
6
+ def cell(cell_type: type[OutputCell], **kwargs: object) -> OutputCell:
7
+ values = {
8
+ "id": uuid.UUID("12345678-1234-5678-9234-567812345678"),
9
+ "kind": "sample_status",
10
+ "media_type": "text/markdown",
11
+ "content": " Sample content. ",
12
+ "meta": {"zeta": 2, "alpha": "first", "enabled": True, "missing": None},
13
+ }
14
+ values.update(kwargs)
15
+ return cell_type.model_validate(values)
@@ -0,0 +1,58 @@
1
+ import json
2
+
3
+ import pytest
4
+
5
+ from llm_tool_cli.protocol.output_cells.automation import AutomationOutputCell
6
+ from llm_tool_cli.protocol.output_cells.base import RenderContext
7
+ from llm_tool_cli.protocol.output_cells.tests.make import cell
8
+
9
+
10
+ class TestAutomationOutputCell:
11
+ def test_render__serializes_cell_as_sorted_json_line(self) -> None:
12
+ formatted = cell(
13
+ AutomationOutputCell,
14
+ ).render(RenderContext(index=0, total=1, tool_label="TOOL"))
15
+
16
+ assert json.loads(formatted) == {
17
+ "alpha": "first",
18
+ "content": "Sample content.",
19
+ "enabled": True,
20
+ "id": "EjRWeBI0VniSNFZ4EjRWeA",
21
+ "missing": None,
22
+ "zeta": 2,
23
+ }
24
+ assert formatted == (
25
+ b'{"alpha":"first","content":"Sample content.","enabled":true,'
26
+ b'"id":"EjRWeBI0VniSNFZ4EjRWeA","missing":null,"zeta":2}\n'
27
+ )
28
+
29
+ @pytest.mark.parametrize("content", [None, ""])
30
+ def test_render__serializes_missing_content_as_null(self, content: str | None) -> None:
31
+ formatted = cell(AutomationOutputCell, content=content).render(
32
+ RenderContext(index=0, total=1, tool_label="TOOL")
33
+ )
34
+
35
+ assert json.loads(formatted)["content"] is None
36
+
37
+ def test_render__preserves_metadata_field_precedence(self) -> None:
38
+ source = cell(AutomationOutputCell, content="Actual content", meta={"id": "metadata-id", "content": "ignored"})
39
+
40
+ formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL"))
41
+
42
+ assert json.loads(formatted) == {"id": "metadata-id", "content": "Actual content"}
43
+
44
+ def test_render__preserves_unicode_and_list_metadata(self) -> None:
45
+ source = cell(
46
+ AutomationOutputCell, content="日本語\nSecond line.", meta={"labels": ["一", "two"], "enabled": False}
47
+ )
48
+
49
+ formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL"))
50
+
51
+ assert json.loads(formatted) == {
52
+ "id": source.short_id,
53
+ "content": "日本語\nSecond line.",
54
+ "labels": ["一", "two"],
55
+ "enabled": False,
56
+ }
57
+ assert "日本語".encode() in formatted
58
+ assert len(formatted.splitlines()) == 1
@@ -0,0 +1,86 @@
1
+ import uuid
2
+
3
+ import pydantic
4
+ import pytest
5
+
6
+ from llm_tool_cli.protocol.errors import ContentWithoutMediaType
7
+ from llm_tool_cli.protocol.output_cells import HumanOutputCell
8
+ from llm_tool_cli.protocol.output_cells.base import MetaValue, RenderContext, to_meta_value
9
+
10
+
11
+ class TestToMetaValue:
12
+ @pytest.mark.parametrize(
13
+ ("value", "expected"),
14
+ (
15
+ ("value", "value"),
16
+ (" Заметка ", " Заметка "),
17
+ (1, 1),
18
+ (True, True),
19
+ (None, None),
20
+ ([], []),
21
+ (["first", " second "], ["first", " second "]),
22
+ (["first", 2], "['first', 2]"),
23
+ ({"count": 2}, "{'count': 2}"),
24
+ (1.5, "1.5"),
25
+ (uuid.UUID("12345678-1234-5678-9234-567812345678"), "12345678-1234-5678-9234-567812345678"),
26
+ ),
27
+ )
28
+ def test_converts_metadata_values(self, value: object, expected: MetaValue) -> None:
29
+ assert to_meta_value(value) == expected
30
+
31
+
32
+ class TestOutputCell:
33
+ @pytest.mark.parametrize("content", ("content", ""))
34
+ def test_build__requires_media_type_when_content_is_present(self, content: str) -> None:
35
+ with pytest.raises(ContentWithoutMediaType):
36
+ HumanOutputCell.build(kind="status", media_type=None, content=content)
37
+
38
+ def test_build__stores_metadata_as_cell_metadata(self) -> None:
39
+ cell = HumanOutputCell.build(kind="status", media_type="text/plain", content="content", count=2, label="ready")
40
+
41
+ assert cell.meta == {"count": 2, "label": "ready"}
42
+
43
+ def test_build__accepts_media_type_without_content(self) -> None:
44
+ cell = HumanOutputCell.build(kind="status", media_type="text/plain", content=None)
45
+
46
+ assert cell.media_type == "text/plain"
47
+ assert cell.content is None
48
+
49
+ def test_build_meta__generates_fresh_uuid4_identifiers(self) -> None:
50
+ first = HumanOutputCell.build_meta(kind="status")
51
+ second = HumanOutputCell.build_meta(kind="status")
52
+
53
+ assert first.id.version == 4
54
+ assert second.id.version == 4
55
+ assert first.id != second.id
56
+
57
+ def test_build_meta__creates_contentless_cell(self) -> None:
58
+ cell = HumanOutputCell.build_meta(kind="status", count=0)
59
+
60
+ assert cell.media_type is None
61
+ assert cell.content is None
62
+ assert cell.meta == {"count": 0}
63
+
64
+ def test_build_markdown__uses_markdown_media_type(self) -> None:
65
+ cell = HumanOutputCell.build_markdown(kind="status", content="# Done", count=1)
66
+
67
+ assert cell.media_type == "text/markdown"
68
+ assert cell.meta == {"count": 1}
69
+
70
+ def test_short_id__returns_unpadded_urlsafe_base64_id(self) -> None:
71
+ cell = HumanOutputCell.build_meta(kind="status").replace(id=uuid.UUID("12345678-1234-5678-9234-567812345678"))
72
+
73
+ assert cell.short_id == "EjRWeBI0VniSNFZ4EjRWeA"
74
+
75
+
76
+ class TestRenderContext:
77
+ @pytest.mark.parametrize(("index", "total"), [(-1, 1), (0, 0), (1, 1), (3, 2)])
78
+ def test_validate_position__rejects_invalid_sequence_position(self, index: int, total: int) -> None:
79
+ with pytest.raises(pydantic.ValidationError):
80
+ RenderContext(index=index, total=total, tool_label="TOOL")
81
+
82
+ @pytest.mark.parametrize(("index", "total"), [(0, 1), (0, 2), (1, 2)])
83
+ def test_validate_position__accepts_sequence_boundaries(self, index: int, total: int) -> None:
84
+ context = RenderContext(index=index, total=total, tool_label="TOOL")
85
+
86
+ assert (context.index, context.total) == (index, total)