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.
- llm_tool_cli/__init__.py +0 -0
- llm_tool_cli/cli/__init__.py +1 -0
- llm_tool_cli/cli/application.py +31 -0
- llm_tool_cli/cli/commands/__init__.py +0 -0
- llm_tool_cli/cli/commands/initialization.py +36 -0
- llm_tool_cli/cli/commands/skills.py +25 -0
- llm_tool_cli/cli/commands/tests/__init__.py +0 -0
- llm_tool_cli/cli/commands/tests/test_initialization.py +130 -0
- llm_tool_cli/cli/commands/tests/test_skills.py +154 -0
- llm_tool_cli/cli/commands/tests/test_version.py +98 -0
- llm_tool_cli/cli/commands/version.py +17 -0
- llm_tool_cli/cli/context.py +39 -0
- llm_tool_cli/cli/entities.py +3 -0
- llm_tool_cli/cli/errors.py +15 -0
- llm_tool_cli/cli/handling.py +41 -0
- llm_tool_cli/cli/options.py +48 -0
- llm_tool_cli/cli/tests/__init__.py +0 -0
- llm_tool_cli/cli/tests/test_application.py +167 -0
- llm_tool_cli/cli/tests/test_context.py +179 -0
- llm_tool_cli/cli/tests/test_handling.py +230 -0
- llm_tool_cli/cli/tests/test_options.py +125 -0
- llm_tool_cli/config/__init__.py +33 -0
- llm_tool_cli/config/errors.py +92 -0
- llm_tool_cli/config/fields.py +39 -0
- llm_tool_cli/config/files.py +175 -0
- llm_tool_cli/config/project.py +87 -0
- llm_tool_cli/config/runtime.py +14 -0
- llm_tool_cli/config/tests/__init__.py +0 -0
- llm_tool_cli/config/tests/fixtures.py +34 -0
- llm_tool_cli/config/tests/test_fields.py +124 -0
- llm_tool_cli/config/tests/test_files.py +826 -0
- llm_tool_cli/config/tests/test_project.py +257 -0
- llm_tool_cli/core/__init__.py +1 -0
- llm_tool_cli/core/context.py +50 -0
- llm_tool_cli/core/entities.py +35 -0
- llm_tool_cli/core/errors.py +97 -0
- llm_tool_cli/core/result.py +118 -0
- llm_tool_cli/core/settings.py +37 -0
- llm_tool_cli/core/tests/__init__.py +0 -0
- llm_tool_cli/core/tests/fixtures.py +13 -0
- llm_tool_cli/core/tests/test_context.py +152 -0
- llm_tool_cli/core/tests/test_entities.py +65 -0
- llm_tool_cli/core/tests/test_errors.py +211 -0
- llm_tool_cli/core/tests/test_result.py +256 -0
- llm_tool_cli/core/tests/test_settings.py +64 -0
- llm_tool_cli/output/__init__.py +3 -0
- llm_tool_cli/output/emitter.py +108 -0
- llm_tool_cli/output/errors.py +21 -0
- llm_tool_cli/output/journal.py +66 -0
- llm_tool_cli/output/tests/__init__.py +0 -0
- llm_tool_cli/output/tests/helpers.py +29 -0
- llm_tool_cli/output/tests/test_emitter.py +406 -0
- llm_tool_cli/output/tests/test_journal.py +141 -0
- llm_tool_cli/paths/__init__.py +51 -0
- llm_tool_cli/paths/enumeration.py +72 -0
- llm_tool_cli/paths/errors.py +42 -0
- llm_tool_cli/paths/filesystem.py +164 -0
- llm_tool_cli/paths/globs.py +124 -0
- llm_tool_cli/paths/normalization.py +58 -0
- llm_tool_cli/paths/tests/__init__.py +0 -0
- llm_tool_cli/paths/tests/test_enumeration.py +229 -0
- llm_tool_cli/paths/tests/test_filesystem.py +778 -0
- llm_tool_cli/paths/tests/test_globs.py +137 -0
- llm_tool_cli/paths/tests/test_normalization.py +163 -0
- llm_tool_cli/protocol/__init__.py +4 -0
- llm_tool_cli/protocol/cell_shortcuts.py +39 -0
- llm_tool_cli/protocol/entities.py +7 -0
- llm_tool_cli/protocol/errors.py +21 -0
- llm_tool_cli/protocol/journal.py +21 -0
- llm_tool_cli/protocol/logic_cells/__init__.py +4 -0
- llm_tool_cli/protocol/logic_cells/base.py +41 -0
- llm_tool_cli/protocol/logic_cells/content.py +26 -0
- llm_tool_cli/protocol/logic_cells/environment_error.py +36 -0
- llm_tool_cli/protocol/logic_cells/journal.py +44 -0
- llm_tool_cli/protocol/logic_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/logic_cells/tests/test_base.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_content.py +59 -0
- llm_tool_cli/protocol/logic_cells/tests/test_environment_error.py +130 -0
- llm_tool_cli/protocol/logic_cells/tests/test_journal.py +47 -0
- llm_tool_cli/protocol/logic_cells/tests/test_uniform.py +60 -0
- llm_tool_cli/protocol/logic_cells/tests/test_warning.py +34 -0
- llm_tool_cli/protocol/logic_cells/uniform.py +21 -0
- llm_tool_cli/protocol/logic_cells/warning.py +22 -0
- llm_tool_cli/protocol/output_cells/__init__.py +11 -0
- llm_tool_cli/protocol/output_cells/automation.py +14 -0
- llm_tool_cli/protocol/output_cells/base.py +65 -0
- llm_tool_cli/protocol/output_cells/human.py +24 -0
- llm_tool_cli/protocol/output_cells/journal.py +23 -0
- llm_tool_cli/protocol/output_cells/llm.py +25 -0
- llm_tool_cli/protocol/output_cells/tests/__init__.py +0 -0
- llm_tool_cli/protocol/output_cells/tests/make.py +15 -0
- llm_tool_cli/protocol/output_cells/tests/test_automation.py +58 -0
- llm_tool_cli/protocol/output_cells/tests/test_base.py +86 -0
- llm_tool_cli/protocol/output_cells/tests/test_human.py +112 -0
- llm_tool_cli/protocol/output_cells/tests/test_llm.py +65 -0
- llm_tool_cli/protocol/serialization.py +7 -0
- llm_tool_cli/protocol/tests/__init__.py +0 -0
- llm_tool_cli/protocol/tests/helpers.py +25 -0
- llm_tool_cli/protocol/tests/test_cell_shortcuts.py +126 -0
- llm_tool_cli/protocol/tests/test_journal.py +44 -0
- llm_tool_cli/protocol/tests/test_serialization.py +33 -0
- llm_tool_cli/py.typed +0 -0
- llm_tool_cli/skills/__init__.py +6 -0
- llm_tool_cli/skills/errors.py +12 -0
- llm_tool_cli/skills/fixtures.py +14 -0
- llm_tool_cli/skills/tests/__init__.py +0 -0
- llm_tool_cli/skills/tests/test_fixtures.py +58 -0
- llm_tool_cli-0.2.0.dist-info/METADATA +275 -0
- llm_tool_cli-0.2.0.dist-info/RECORD +111 -0
- llm_tool_cli-0.2.0.dist-info/WHEEL +4 -0
- llm_tool_cli-0.2.0.dist-info/licenses/LICENSE +28 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol.output_cells.base import RenderContext
|
|
4
|
+
from llm_tool_cli.protocol.output_cells.human import HumanErrorOutputCell, HumanOutputCell, HumanWarningOutputCell
|
|
5
|
+
from llm_tool_cli.protocol.output_cells.tests.make import cell
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class TestHumanOutputCell:
|
|
9
|
+
def test_render__renders_human_cell_with_sorted_metadata(self) -> None:
|
|
10
|
+
formatted = (
|
|
11
|
+
cell(
|
|
12
|
+
HumanOutputCell,
|
|
13
|
+
)
|
|
14
|
+
.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
15
|
+
.decode()
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
assert formatted == "alpha: first\nenabled: True\nmissing: None\nzeta: 2\n\nSample content.\n\n"
|
|
19
|
+
|
|
20
|
+
def test_render__omits_media_type_and_content_when_absent(self) -> None:
|
|
21
|
+
formatted = (
|
|
22
|
+
cell(HumanOutputCell, media_type=None, content=None, meta={})
|
|
23
|
+
.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
24
|
+
.decode()
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
assert formatted == "sample_status\n\n"
|
|
28
|
+
|
|
29
|
+
def test_render__omits_empty_content_section(self) -> None:
|
|
30
|
+
formatted = (
|
|
31
|
+
cell(HumanOutputCell, content="", meta={})
|
|
32
|
+
.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
33
|
+
.decode()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
assert formatted == "sample_status\n\n"
|
|
37
|
+
|
|
38
|
+
def test_render__preserves_unicode_content_and_list_metadata(self) -> None:
|
|
39
|
+
source = cell(HumanOutputCell, content="日本語\nSecond line.", meta={"labels": ["一", "two"]})
|
|
40
|
+
|
|
41
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
42
|
+
|
|
43
|
+
assert formatted == "labels: ['一', 'two']\n\n日本語\nSecond line.\n\n"
|
|
44
|
+
|
|
45
|
+
def test_render__omits_redundant_type_and_preserves_application_metadata(self) -> None:
|
|
46
|
+
source = HumanOutputCell.build_meta(kind="status", type="status", id="domain-id", count=0, labels=[])
|
|
47
|
+
original = source.model_dump()
|
|
48
|
+
|
|
49
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
50
|
+
|
|
51
|
+
assert formatted == "count: 0\nid: domain-id\nlabels: []\n\n"
|
|
52
|
+
assert source.model_dump() == original
|
|
53
|
+
|
|
54
|
+
def test_render__preserves_type_when_it_has_a_distinct_meaning(self) -> None:
|
|
55
|
+
source = HumanOutputCell.build_meta(kind="status", type="custom")
|
|
56
|
+
|
|
57
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
58
|
+
|
|
59
|
+
assert formatted == "type: custom\n\n"
|
|
60
|
+
|
|
61
|
+
def test_render__shows_content_without_metadata(self) -> None:
|
|
62
|
+
source = HumanOutputCell.build_markdown(kind="document", content="First paragraph.\n\nSecond paragraph.")
|
|
63
|
+
|
|
64
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
65
|
+
|
|
66
|
+
assert formatted == "First paragraph.\n\nSecond paragraph.\n\n"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class TestHumanErrorOutputCell:
|
|
70
|
+
def test_render__shows_message_without_diagnostic_context(self) -> None:
|
|
71
|
+
source = HumanErrorOutputCell.build_markdown(kind="error", content="失敗", type="error")
|
|
72
|
+
|
|
73
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
74
|
+
|
|
75
|
+
assert formatted == "Error: 失敗\n\n"
|
|
76
|
+
|
|
77
|
+
def test_render__shows_message_and_guidance_before_diagnostic_context(self) -> None:
|
|
78
|
+
source = HumanErrorOutputCell.build_markdown(
|
|
79
|
+
kind="error",
|
|
80
|
+
content="Service unavailable.\nWay to fix: Retry later.",
|
|
81
|
+
type="error",
|
|
82
|
+
code="unavailable",
|
|
83
|
+
path="/project",
|
|
84
|
+
)
|
|
85
|
+
original = source.model_dump()
|
|
86
|
+
|
|
87
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
88
|
+
|
|
89
|
+
assert formatted == (
|
|
90
|
+
"Error: Service unavailable.\nWay to fix: Retry later.\n\ncode: unavailable\npath: /project\n\n"
|
|
91
|
+
)
|
|
92
|
+
assert source.model_dump() == original
|
|
93
|
+
|
|
94
|
+
@pytest.mark.parametrize("content", [None, ""])
|
|
95
|
+
def test_render__retains_diagnostics_without_a_message(self, content: str | None) -> None:
|
|
96
|
+
source = HumanErrorOutputCell.build(kind="error", media_type="text/markdown", content=content, code="empty")
|
|
97
|
+
|
|
98
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
99
|
+
|
|
100
|
+
assert formatted == "Error:\n\ncode: empty\n\n"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class TestHumanWarningOutputCell:
|
|
104
|
+
@pytest.mark.parametrize("message", ["first\nsecond", "café", ""])
|
|
105
|
+
def test_render__shows_warning_message_without_record_fields(self, message: str) -> None:
|
|
106
|
+
source = HumanWarningOutputCell.build_meta(kind="warning", type="warning", message=message)
|
|
107
|
+
original = source.model_dump()
|
|
108
|
+
|
|
109
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
110
|
+
|
|
111
|
+
assert formatted == f"Warning: {message}".rstrip() + "\n\n"
|
|
112
|
+
assert source.model_dump() == original
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
from llm_tool_cli.protocol.output_cells.base import RenderContext
|
|
2
|
+
from llm_tool_cli.protocol.output_cells.llm import LLMOutputCell
|
|
3
|
+
from llm_tool_cli.protocol.output_cells.tests.make import cell
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestLLMOutputCell:
|
|
7
|
+
def test_render__renders_llm_cell_with_sorted_metadata(self) -> None:
|
|
8
|
+
formatted = (
|
|
9
|
+
cell(
|
|
10
|
+
LLMOutputCell,
|
|
11
|
+
)
|
|
12
|
+
.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
13
|
+
.decode()
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
assert formatted == (
|
|
17
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA BEGIN--\n"
|
|
18
|
+
"kind=sample_status\n"
|
|
19
|
+
"media_type=text/markdown\n"
|
|
20
|
+
"alpha=first\n"
|
|
21
|
+
"enabled=True\n"
|
|
22
|
+
"missing=None\n"
|
|
23
|
+
"zeta=2\n"
|
|
24
|
+
"\n"
|
|
25
|
+
"Sample content.\n"
|
|
26
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA END--\n"
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
def test_render__omits_media_type_and_content_when_absent(self) -> None:
|
|
30
|
+
formatted = (
|
|
31
|
+
cell(LLMOutputCell, media_type=None, content=None, meta={})
|
|
32
|
+
.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
33
|
+
.decode()
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
assert formatted == (
|
|
37
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA BEGIN--\n"
|
|
38
|
+
"kind=sample_status\n"
|
|
39
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA END--\n"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
def test_render__omits_empty_content_section(self) -> None:
|
|
43
|
+
formatted = (
|
|
44
|
+
cell(LLMOutputCell, content="", meta={})
|
|
45
|
+
.render(RenderContext(index=0, total=1, tool_label="TOOL"))
|
|
46
|
+
.decode()
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
assert formatted == (
|
|
50
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA BEGIN--\n"
|
|
51
|
+
"kind=sample_status\nmedia_type=text/markdown\n"
|
|
52
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA END--\n"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def test_render__preserves_unicode_content_and_list_metadata(self) -> None:
|
|
56
|
+
source = cell(LLMOutputCell, content="日本語\nSecond line.", meta={"labels": ["一", "two"]})
|
|
57
|
+
|
|
58
|
+
formatted = source.render(RenderContext(index=0, total=1, tool_label="TOOL")).decode()
|
|
59
|
+
|
|
60
|
+
assert formatted == (
|
|
61
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA BEGIN--\n"
|
|
62
|
+
"kind=sample_status\nmedia_type=text/markdown\n"
|
|
63
|
+
"labels=['一', 'two']\n\n日本語\nSecond line.\n"
|
|
64
|
+
"--TOOL-CELL EjRWeBI0VniSNFZ4EjRWeA END--\n"
|
|
65
|
+
)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from collections.abc import Mapping
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def to_jsonl(record: Mapping[str, object]) -> str:
|
|
6
|
+
"""Serialize a record as one compact, Unicode-preserving JSON line."""
|
|
7
|
+
return json.dumps(dict(record), ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n"
|
|
File without changes
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import uuid
|
|
3
|
+
|
|
4
|
+
from llm_tool_cli.core.errors import EnvironmentErrors
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def cell_payloads(records: list[dict[str, object]]) -> list[dict[str, object]]:
|
|
8
|
+
"""Validate generated cell IDs and return payload copies without them."""
|
|
9
|
+
payloads = []
|
|
10
|
+
for record in records:
|
|
11
|
+
payload = dict(record)
|
|
12
|
+
cell_id = payload.pop("id")
|
|
13
|
+
assert isinstance(cell_id, str)
|
|
14
|
+
assert uuid.UUID(bytes=base64.urlsafe_b64decode(cell_id + "==")).version == 4
|
|
15
|
+
payloads.append(payload)
|
|
16
|
+
return payloads
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assert_error_cells(records: list[dict[str, object]], errors: EnvironmentErrors) -> None:
|
|
20
|
+
"""Assert native automation diagnostics without corrective guidance."""
|
|
21
|
+
assert len(records) == len(errors)
|
|
22
|
+
for actual, error in zip(cell_payloads(records), errors):
|
|
23
|
+
expected = error.as_record()
|
|
24
|
+
expected["content"] = expected.pop("message")
|
|
25
|
+
assert actual == expected
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
from llm_tool_cli.core.errors import EnvironmentError
|
|
6
|
+
from llm_tool_cli.paths import ProjectConfigPath
|
|
7
|
+
from llm_tool_cli.protocol import Protocol, cell_shortcuts
|
|
8
|
+
from llm_tool_cli.protocol.logic_cells import EnvironmentErrorCell
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestOperationSucceeded:
|
|
12
|
+
def test_creates_markdown_operation_succeeded_cell(self) -> None:
|
|
13
|
+
cell = cell_shortcuts.operation_succeeded("Done.", operation="setup")
|
|
14
|
+
|
|
15
|
+
assert cell.kind == "operation_succeeded"
|
|
16
|
+
assert cell.media_type == "text/markdown"
|
|
17
|
+
assert cell.content == "Done."
|
|
18
|
+
assert cell.meta == {"type": "operation_succeeded", "operation": "setup"}
|
|
19
|
+
|
|
20
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
21
|
+
@pytest.mark.parametrize("message", ["Done.", ""])
|
|
22
|
+
def test_default_success_payload(self, protocol: Protocol, message: str) -> None:
|
|
23
|
+
cell = cell_shortcuts.operation_succeeded(message)
|
|
24
|
+
|
|
25
|
+
outputs = cell.render(protocol)
|
|
26
|
+
|
|
27
|
+
assert len(outputs) == 1
|
|
28
|
+
assert outputs[0].model_dump(exclude={"id"}) == {
|
|
29
|
+
"kind": "operation_succeeded",
|
|
30
|
+
"media_type": "text/markdown",
|
|
31
|
+
"content": message,
|
|
32
|
+
"meta": {"type": "operation_succeeded"},
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
def test_explicit_metadata_retains_precedence(self) -> None:
|
|
36
|
+
cell = cell_shortcuts.operation_succeeded("Done.", type="custom_success", path="config.toml")
|
|
37
|
+
|
|
38
|
+
assert cell.meta == {"type": "custom_success", "path": "config.toml"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestConfigurationCreated:
|
|
42
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
43
|
+
@pytest.mark.parametrize("path", ["/project/config.toml", "folder name/café.toml"])
|
|
44
|
+
def test_shared_creation_payload_preserves_path(self, protocol: Protocol, path: str) -> None:
|
|
45
|
+
cell = cell_shortcuts.configuration_created(ProjectConfigPath(Path(path)))
|
|
46
|
+
|
|
47
|
+
outputs = cell.render(protocol)
|
|
48
|
+
|
|
49
|
+
assert len(outputs) == 1
|
|
50
|
+
assert outputs[0].model_dump(exclude={"id"}) == {
|
|
51
|
+
"kind": "operation_succeeded",
|
|
52
|
+
"media_type": "text/markdown",
|
|
53
|
+
"content": "Configuration created.",
|
|
54
|
+
"meta": {"type": "operation_succeeded", "path": path},
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class TestOperationFailed:
|
|
59
|
+
def test_creates_markdown_operation_failed_cell(self) -> None:
|
|
60
|
+
cell = cell_shortcuts.operation_failed("Failed.", operation="setup")
|
|
61
|
+
|
|
62
|
+
assert cell.kind == "operation_failed"
|
|
63
|
+
assert cell.media_type == "text/markdown"
|
|
64
|
+
assert cell.content == "Failed."
|
|
65
|
+
assert cell.meta == {"operation": "setup"}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class TestInfo:
|
|
69
|
+
def test_creates_markdown_info_cell(self) -> None:
|
|
70
|
+
cell = cell_shortcuts.info("Ready.", scope="workspace")
|
|
71
|
+
|
|
72
|
+
assert cell.kind == "info"
|
|
73
|
+
assert cell.media_type == "text/markdown"
|
|
74
|
+
assert cell.content == "Ready."
|
|
75
|
+
assert cell.meta == {"scope": "workspace"}
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class TestSkill:
|
|
79
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
80
|
+
@pytest.mark.parametrize("content", ["# Custom guide\n\nInstructions for café.", ""])
|
|
81
|
+
def test_shared_skill_payload(self, protocol: Protocol, content: str) -> None:
|
|
82
|
+
cell = cell_shortcuts.skill(document="custom-guide", content=content)
|
|
83
|
+
|
|
84
|
+
outputs = cell.render(protocol)
|
|
85
|
+
|
|
86
|
+
assert len(outputs) == 1
|
|
87
|
+
assert outputs[0].model_dump(exclude={"id"}) == {
|
|
88
|
+
"kind": "skill",
|
|
89
|
+
"media_type": "text/markdown",
|
|
90
|
+
"content": content,
|
|
91
|
+
"meta": {"type": "skill", "document": "custom-guide"},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
def test_empty_document_name(self) -> None:
|
|
95
|
+
cell = cell_shortcuts.skill(document="", content="Instructions.")
|
|
96
|
+
|
|
97
|
+
assert cell.meta == {"type": "skill", "document": ""}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class TestVersion:
|
|
101
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
102
|
+
@pytest.mark.parametrize("value", ["1.2.3", "1.2.3rc1+local", ""])
|
|
103
|
+
def test_shared_version_payload(self, protocol: Protocol, value: str) -> None:
|
|
104
|
+
cell = cell_shortcuts.version(value)
|
|
105
|
+
|
|
106
|
+
outputs = cell.render(protocol)
|
|
107
|
+
|
|
108
|
+
assert len(outputs) == 1
|
|
109
|
+
assert outputs[0].model_dump(exclude={"id"}) == {
|
|
110
|
+
"kind": "version",
|
|
111
|
+
"media_type": None,
|
|
112
|
+
"content": None,
|
|
113
|
+
"meta": {"type": "version", "version": value},
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class TestEnvironmentError:
|
|
118
|
+
@pytest.mark.parametrize("protocol", list(Protocol))
|
|
119
|
+
def test_retains_error_until_projection(self, protocol: Protocol) -> None:
|
|
120
|
+
error = EnvironmentError(code="unavailable", message="Try later.", ways_to_fix=["Retry."])
|
|
121
|
+
|
|
122
|
+
cell = cell_shortcuts.environment_error(error)
|
|
123
|
+
|
|
124
|
+
assert isinstance(cell, EnvironmentErrorCell)
|
|
125
|
+
assert cell.error == error
|
|
126
|
+
assert cell.render(protocol)[0].content == "Try later.\nWay to fix: Retry."
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.protocol.journal import JournalRecord
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TestJournalRecord:
|
|
7
|
+
def test_as_dict__flattens_attributes_and_protects_common_fields(self) -> None:
|
|
8
|
+
timestamp = datetime.datetime.now(datetime.UTC)
|
|
9
|
+
record = JournalRecord(
|
|
10
|
+
message="message",
|
|
11
|
+
timestamp=timestamp,
|
|
12
|
+
attributes={
|
|
13
|
+
"message": "conflict",
|
|
14
|
+
"actor_id": "conflict",
|
|
15
|
+
"timestamp": "conflict",
|
|
16
|
+
"task_id": None,
|
|
17
|
+
"context": {"items": ["first", "second"]},
|
|
18
|
+
},
|
|
19
|
+
)
|
|
20
|
+
original = record.model_dump()
|
|
21
|
+
fields = record.as_dict()
|
|
22
|
+
assert fields == {
|
|
23
|
+
"message": "message",
|
|
24
|
+
"timestamp": timestamp.isoformat(),
|
|
25
|
+
"actor_id": None,
|
|
26
|
+
"task_id": None,
|
|
27
|
+
"context": {"items": ["first", "second"]},
|
|
28
|
+
}
|
|
29
|
+
assert record.model_dump() == original
|
|
30
|
+
assert "attributes" not in fields
|
|
31
|
+
|
|
32
|
+
def test_as_dict__returns_independent_nested_data(self) -> None:
|
|
33
|
+
record = JournalRecord(message="event", attributes={"items": ["one"]})
|
|
34
|
+
fields = record.as_dict()
|
|
35
|
+
items = fields["items"]
|
|
36
|
+
assert isinstance(items, list)
|
|
37
|
+
items.append("two")
|
|
38
|
+
assert record.as_dict()["items"] == ["one"]
|
|
39
|
+
|
|
40
|
+
def test_timestamp__defaults_to_current_utc_time(self) -> None:
|
|
41
|
+
start = datetime.datetime.now(datetime.UTC)
|
|
42
|
+
record = JournalRecord(message="event")
|
|
43
|
+
assert start <= record.timestamp <= datetime.datetime.now(datetime.UTC)
|
|
44
|
+
assert record.timestamp.utcoffset() == datetime.timedelta(0)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from types import MappingProxyType
|
|
3
|
+
|
|
4
|
+
import pytest
|
|
5
|
+
|
|
6
|
+
from llm_tool_cli.protocol import Protocol, to_jsonl
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class TestToJsonl:
|
|
10
|
+
def test_compact_sorted_unicode_record(self) -> None:
|
|
11
|
+
record = {"z": "日本語", "a": {"z": [True, None, "first\nsecond"], "a": 1}}
|
|
12
|
+
|
|
13
|
+
output = to_jsonl(record)
|
|
14
|
+
|
|
15
|
+
assert output == '{"a":{"a":1,"z":[true,null,"first\\nsecond"]},"z":"日本語"}\n'
|
|
16
|
+
assert json.loads(output) == record
|
|
17
|
+
assert len(output.splitlines()) == 1
|
|
18
|
+
|
|
19
|
+
def test_mapping(self) -> None:
|
|
20
|
+
record = MappingProxyType({"b": 2, "a": 1})
|
|
21
|
+
|
|
22
|
+
assert to_jsonl(record) == '{"a":1,"b":2}\n'
|
|
23
|
+
|
|
24
|
+
def test_empty_record(self) -> None:
|
|
25
|
+
assert to_jsonl({}) == "{}\n"
|
|
26
|
+
|
|
27
|
+
@pytest.mark.parametrize("mode", ["human", "llm", "automation"])
|
|
28
|
+
def test_protocol_wire_value(self, mode: str) -> None:
|
|
29
|
+
assert to_jsonl({"mode": Protocol(mode)}) == f'{{"mode":"{mode}"}}\n'
|
|
30
|
+
|
|
31
|
+
def test_unsupported_value(self) -> None:
|
|
32
|
+
with pytest.raises(TypeError):
|
|
33
|
+
to_jsonl({"value": object()})
|
llm_tool_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from llm_tool_cli.core import errors as core_errors
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class EnvironmentError(core_errors.EnvironmentError):
|
|
5
|
+
"""An expected failure while loading a packaged skill document."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class SkillUnreadable(EnvironmentError):
|
|
9
|
+
code: str = "skill_unreadable"
|
|
10
|
+
message: str = "could not read skill document `{error.document}`: {error.reason}"
|
|
11
|
+
document: str
|
|
12
|
+
reason: str
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import importlib.resources
|
|
2
|
+
|
|
3
|
+
from llm_tool_cli.core.result import Err, Ok, Result
|
|
4
|
+
from llm_tool_cli.skills.errors import SkillUnreadable
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def load_skill_text(package: str, document: str) -> Result[str]:
|
|
8
|
+
"""Read a selected document from an application-owned package's fixtures."""
|
|
9
|
+
try:
|
|
10
|
+
return Ok(
|
|
11
|
+
importlib.resources.files(package).joinpath("fixtures", f"{document}.md").read_text(encoding="utf-8")
|
|
12
|
+
)
|
|
13
|
+
except (OSError, UnicodeDecodeError) as error:
|
|
14
|
+
return Err([SkillUnreadable(document=document, reason=str(error)).with_cause(error)])
|
|
File without changes
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import importlib.util
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
from pytest_mock import MockerFixture
|
|
7
|
+
|
|
8
|
+
from llm_tool_cli.skills import load_skill_text
|
|
9
|
+
from llm_tool_cli.skills.errors import SkillUnreadable
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def skill_package(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> str:
|
|
14
|
+
package = "skill_fixture_package"
|
|
15
|
+
initializer = tmp_path / "__init__.py"
|
|
16
|
+
initializer.write_text("", encoding="utf-8")
|
|
17
|
+
spec = importlib.util.spec_from_file_location(package, initializer)
|
|
18
|
+
assert spec is not None
|
|
19
|
+
monkeypatch.setitem(sys.modules, package, importlib.util.module_from_spec(spec))
|
|
20
|
+
(tmp_path / "fixtures").mkdir()
|
|
21
|
+
return package
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class TestLoadSkillText:
|
|
25
|
+
@pytest.mark.parametrize(
|
|
26
|
+
("document", "content"),
|
|
27
|
+
[("usage", "# Usage\n"), ("workflows", ""), ("custom_document", " Привет 🌍\n\n")],
|
|
28
|
+
)
|
|
29
|
+
def test_reads_selected_utf8_resource(
|
|
30
|
+
self, tmp_path: Path, skill_package: str, document: str, content: str
|
|
31
|
+
) -> None:
|
|
32
|
+
(tmp_path / "fixtures" / f"{document}.md").write_text(content, encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
assert load_skill_text(skill_package, document).unwrap() == content
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize("content", [None, b"\xff"])
|
|
37
|
+
def test_read_failure_is_returned(self, tmp_path: Path, skill_package: str, content: bytes | None) -> None:
|
|
38
|
+
if content is not None:
|
|
39
|
+
(tmp_path / "fixtures" / "usage.md").write_bytes(content)
|
|
40
|
+
|
|
41
|
+
errors = load_skill_text(skill_package, "usage").unwrap_err()
|
|
42
|
+
|
|
43
|
+
assert len(errors) == 1
|
|
44
|
+
failure = errors[0]
|
|
45
|
+
assert isinstance(failure, SkillUnreadable)
|
|
46
|
+
assert failure.code == "skill_unreadable"
|
|
47
|
+
assert failure.document == "usage"
|
|
48
|
+
assert isinstance(failure.cause, FileNotFoundError if content is None else UnicodeDecodeError)
|
|
49
|
+
assert failure.reason == str(failure.cause)
|
|
50
|
+
assert "cause" not in failure.as_record()
|
|
51
|
+
|
|
52
|
+
def test_unexpected_failure_propagates(self, mocker: MockerFixture) -> None:
|
|
53
|
+
mocker.patch(
|
|
54
|
+
"llm_tool_cli.skills.fixtures.importlib.resources.files", side_effect=RuntimeError("broken loader")
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
with pytest.raises(RuntimeError, match="broken loader"):
|
|
58
|
+
load_skill_text("skill_fixture_package", "usage")
|