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,406 @@
1
+ import io
2
+ import json
3
+ import sys
4
+
5
+ import pytest
6
+ from pytest_mock import MockerFixture
7
+
8
+ from llm_tool_cli.config.runtime import RuntimeConfig, runtime_config
9
+ from llm_tool_cli.config.tests.fixtures import project_static_settings, runtime_settings
10
+ from llm_tool_cli.core.errors import ContextValueNotSet, EnvironmentError
11
+ from llm_tool_cli.core.result import Err, Ok, Result
12
+ from llm_tool_cli.core.settings import StaticConfig, ToolLabel, static_config
13
+ from llm_tool_cli.core.tests.fixtures import isolated_settings
14
+ from llm_tool_cli.output import StreamOutputEmitter
15
+ from llm_tool_cli.protocol import Protocol
16
+ from llm_tool_cli.protocol.journal import JournalRecord
17
+ from llm_tool_cli.protocol.logic_cells import ContentCell, EnvironmentErrorCell
18
+ from llm_tool_cli.protocol.logic_cells.base import LogicCell
19
+ from llm_tool_cli.protocol.logic_cells.journal import JournalCell
20
+ from llm_tool_cli.protocol.logic_cells.warning import WarningCell
21
+ from llm_tool_cli.protocol.output_cells import HumanOutputCell
22
+ from llm_tool_cli.protocol.output_cells.base import OutputCell, RenderContext
23
+
24
+ __all__ = ["isolated_settings", "project_static_settings", "runtime_settings"]
25
+ pytestmark = pytest.mark.usefixtures("project_static_settings", "runtime_settings")
26
+
27
+
28
+ class RecordingProcessor:
29
+ def __init__(self, result: Result[None] = Ok(None)) -> None:
30
+ self.records: list[JournalRecord] = []
31
+ self.result = result
32
+
33
+ def process(self, record: JournalRecord) -> Result[None]:
34
+ self.records.append(record)
35
+ return self.result
36
+
37
+
38
+ class PositionedOutputCell(OutputCell):
39
+ def render(self, context: RenderContext) -> bytes:
40
+ return f"{context.tool_label} {context.index}/{context.total}: {self.content}\n".encode()
41
+
42
+
43
+ class PositionedLogicCell(LogicCell):
44
+ items: tuple[str, ...]
45
+
46
+ def to_journal_record(self) -> JournalRecord:
47
+ return JournalRecord(message="\n".join(self.items))
48
+
49
+ def render_human(self) -> list[OutputCell]:
50
+ return [PositionedOutputCell.build_markdown(kind="item", content=text) for text in self.items]
51
+
52
+ def render_llm(self) -> list[OutputCell]:
53
+ return []
54
+
55
+ def render_automation(self) -> list[OutputCell]:
56
+ return []
57
+
58
+
59
+ class TestStreamOutputEmitter:
60
+ @pytest.mark.parametrize(
61
+ "protocol, actor", [(Protocol.human, "human"), (Protocol.llm, "agent"), (Protocol.automation, "automation")]
62
+ )
63
+ def test_journal__uses_default_factory_and_protocol_actor(self, protocol: Protocol, actor: str) -> None:
64
+ stream = io.StringIO()
65
+ processor = RecordingProcessor()
66
+ emitter = StreamOutputEmitter(stream=stream, journal_processor=processor)
67
+ with runtime_config.scope(RuntimeConfig(protocol=protocol)):
68
+ emitter.journal("starting").unwrap()
69
+ assert len(processor.records) == 1
70
+ assert processor.records[0].message == "starting"
71
+ assert processor.records[0].actor_id == actor
72
+ assert "starting" in stream.getvalue()
73
+
74
+ @pytest.mark.parametrize("actor", [None, "worker"])
75
+ def test_journal__custom_factory_receives_explicit_actor_at_each_call(self, actor: str | None) -> None:
76
+ calls: list[tuple[str, str | None]] = []
77
+
78
+ def factory(*, message: str, actor_id: str | None) -> JournalCell:
79
+ calls.append((message, actor_id))
80
+ return JournalCell(message=f"{len(calls)}: {message}", actor_id=actor_id)
81
+
82
+ processor = RecordingProcessor()
83
+ emitter = StreamOutputEmitter(stream=io.StringIO(), journal_factory=factory, journal_processor=processor)
84
+ emitter.journal("first", actor_id=actor).unwrap()
85
+ emitter.journal("second", actor_id=actor).unwrap()
86
+ assert calls == [("first", actor), ("second", actor)]
87
+ assert [record.message for record in processor.records] == ["1: first", "2: second"]
88
+
89
+ def test_emit__direct_cells_do_not_invoke_journal_factory(self) -> None:
90
+ def factory(*, message: str, actor_id: str | None) -> JournalCell:
91
+ raise AssertionError("factory must not run")
92
+
93
+ emitter = StreamOutputEmitter(stream=io.StringIO(), journal_factory=factory)
94
+ emitter.emit(
95
+ [EnvironmentErrorCell(error=EnvironmentError(code="failed", message="failure"), is_journal=True)]
96
+ ).unwrap()
97
+
98
+ def test_journal__processor_failure_propagates(self) -> None:
99
+ failure = EnvironmentError(code="forward_failed", message="delivery failed")
100
+ stream = io.StringIO()
101
+ emitter = StreamOutputEmitter(stream=stream, journal_processor=RecordingProcessor(Err([failure])))
102
+ assert emitter.journal("event").unwrap_err() == [failure]
103
+ assert not stream.getvalue()
104
+
105
+ @pytest.mark.parametrize("errors", [[], [EnvironmentError(code="custom_failure", message="unavailable")]])
106
+ def test_emit__processor_failure_disables_forwarding_but_preserves_console_routing(
107
+ self, errors: list[EnvironmentError]
108
+ ) -> None:
109
+ processor = RecordingProcessor(Err(errors))
110
+ stream = io.StringIO()
111
+ emitter = StreamOutputEmitter(stream=stream, journal_processor=processor)
112
+ with runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)):
113
+ assert emitter.journal("failed event").unwrap_err() == errors
114
+ emitter.discard()
115
+ emitter.flush()
116
+ assert not stream.getvalue()
117
+
118
+ emitter.journal("later event").unwrap()
119
+ emitter.emit(
120
+ [
121
+ ContentCell(kind="result", media_type="text/plain", content="result"),
122
+ WarningCell(message="warning", is_journal=True),
123
+ JournalCell(message="last event"),
124
+ ]
125
+ ).unwrap()
126
+ assert [json.loads(line)["message"] for line in stream.getvalue().splitlines()] == [
127
+ "later event",
128
+ "last event",
129
+ ]
130
+ emitter.flush()
131
+
132
+ records = [json.loads(line) for line in stream.getvalue().splitlines()]
133
+ assert len(records) == 4
134
+ assert records[2]["content"] == "result"
135
+ assert records[3]["message"] == "warning"
136
+ assert [record.message for record in processor.records] == ["failed event"]
137
+
138
+ def test_emit__processor_failure_is_local_to_emitter(self) -> None:
139
+ error = EnvironmentError(code="custom_failure", message="unavailable")
140
+ processor = RecordingProcessor(Err([error]))
141
+ first = StreamOutputEmitter(stream=io.StringIO(), journal_processor=processor)
142
+ second = StreamOutputEmitter(stream=io.StringIO(), journal_processor=processor)
143
+ assert first.journal("first").unwrap_err() == [error]
144
+ assert second.journal("second").unwrap_err() == [error]
145
+ assert [record.message for record in processor.records] == ["first", "second"]
146
+
147
+ @pytest.mark.parametrize("journal", [False, True])
148
+ def test_render__override_retains_buffering_and_stream_delivery(
149
+ self, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], journal: bool
150
+ ) -> None:
151
+ class CustomOutputEmitter(StreamOutputEmitter):
152
+ def _render(self, cells: list[LogicCell]) -> str:
153
+ return f"Batch of {len(cells)}\n"
154
+
155
+ stream = io.StringIO()
156
+ flush = mocker.spy(stream, "flush")
157
+ emitter = CustomOutputEmitter(stream=stream)
158
+ emitter.emit([JournalCell(message="result", is_journal=journal)]).unwrap()
159
+ assert stream.getvalue() == ("Batch of 1\n" if journal else "")
160
+ emitter.flush()
161
+ emitter.flush()
162
+ assert stream.getvalue() == "Batch of 1\n"
163
+ flush.assert_called_once()
164
+ assert not stream.closed
165
+ assert capsys.readouterr() == ("", "")
166
+
167
+ @pytest.mark.parametrize("protocol", list(Protocol))
168
+ def test_emit__custom_stream_receives_journals_then_results_and_warnings(
169
+ self, mocker: MockerFixture, capsys: pytest.CaptureFixture[str], protocol: Protocol
170
+ ) -> None:
171
+ stream = io.StringIO()
172
+ flush = mocker.spy(stream, "flush")
173
+ processor = RecordingProcessor()
174
+ emitter = StreamOutputEmitter(stream=stream, journal_processor=processor)
175
+ event = JournalCell(message="progress")
176
+ with runtime_config.scope(RuntimeConfig(protocol=protocol)):
177
+ emitter.emit(
178
+ [
179
+ WarningCell(message="warning"),
180
+ ContentCell(kind="result", media_type="text/plain", content="completed"),
181
+ event,
182
+ ]
183
+ ).unwrap()
184
+ assert "progress" in stream.getvalue()
185
+ assert "completed" not in stream.getvalue()
186
+ assert "warning" not in stream.getvalue()
187
+ assert processor.records == [event.to_journal_record()]
188
+ flush.assert_called_once()
189
+
190
+ emitter.flush()
191
+ delivered = stream.getvalue()
192
+ assert delivered.index("progress") < delivered.index("completed") < delivered.index("warning")
193
+ assert flush.call_count == 2
194
+ emitter.flush()
195
+ assert stream.getvalue() == delivered
196
+ assert flush.call_count == 2
197
+ assert not stream.closed
198
+ assert capsys.readouterr() == ("", "")
199
+
200
+ def test_emit__default_uses_current_stdout_at_delivery(self, mocker: MockerFixture) -> None:
201
+ first = io.StringIO()
202
+ second = io.StringIO()
203
+ mocker.patch.object(sys, "stdout", first)
204
+ emitter = StreamOutputEmitter()
205
+ emitter.emit([ContentCell(kind="result", media_type="text/plain", content="completed")]).unwrap()
206
+ mocker.patch.object(sys, "stdout", second)
207
+ emitter.emit([JournalCell(message="progress")]).unwrap()
208
+ emitter.flush()
209
+ assert not first.getvalue()
210
+ assert second.getvalue().index("progress") < second.getvalue().index("completed")
211
+
212
+ @pytest.mark.parametrize("operation", ["write", "flush"])
213
+ def test_flush__stream_failure_propagates_without_retry(self, mocker: MockerFixture, operation: str) -> None:
214
+ stream = io.StringIO()
215
+ failing_operation = mocker.patch.object(stream, operation, side_effect=OSError("delivery failed"))
216
+ emitter = StreamOutputEmitter(stream=stream)
217
+ emitter.emit([ContentCell(kind="result"), WarningCell(message="warning")]).unwrap()
218
+ with pytest.raises(OSError, match="delivery failed"):
219
+ emitter.flush()
220
+ emitter.flush()
221
+ failing_operation.assert_called_once()
222
+ assert not stream.closed
223
+
224
+ def test_emit__buffers_results_and_warnings_but_delivers_journals(
225
+ self, capsys: pytest.CaptureFixture[str]
226
+ ) -> None:
227
+ processor = RecordingProcessor()
228
+ emitter = StreamOutputEmitter(journal_processor=processor)
229
+ first = ContentCell(kind="result", media_type="text/plain", content="first")
230
+ event = JournalCell(message="progress")
231
+ last = first.replace(content="last")
232
+ with runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)):
233
+ emitter.emit(
234
+ [WarningCell(message="warning one"), first, event, last, WarningCell(message="warning two")]
235
+ ).unwrap()
236
+ immediate = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
237
+ assert [record["message"] for record in immediate] == ["progress"]
238
+ assert processor.records == [event.to_journal_record()]
239
+ emitter.flush()
240
+ records = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
241
+ assert [record["content"] for record in records[:2]] == ["first", "last"]
242
+ assert [record["message"] for record in records[2:]] == ["warning one", "warning two"]
243
+ emitter.flush()
244
+ assert not capsys.readouterr().out
245
+ assert processor.records == [event.to_journal_record()]
246
+
247
+ @pytest.mark.parametrize("journal", [False, True])
248
+ @pytest.mark.parametrize("warning", [False, True])
249
+ def test_emit__flags_override_subclass_defaults(
250
+ self, capsys: pytest.CaptureFixture[str], journal: bool, warning: bool
251
+ ) -> None:
252
+ processor = RecordingProcessor()
253
+ emitter = StreamOutputEmitter(journal_processor=processor)
254
+ cell = JournalCell(message="event", is_journal=journal, is_warning=warning)
255
+ emitter.emit([cell]).unwrap()
256
+ assert processor.records == ([cell.to_journal_record()] if journal else [])
257
+ assert bool(capsys.readouterr().out) == (journal and not warning)
258
+ emitter.flush()
259
+ assert bool(capsys.readouterr().out) == (not journal or warning)
260
+
261
+ def test_emit__journals_semantic_record_and_preserves_console_projection(
262
+ self, capsys: pytest.CaptureFixture[str]
263
+ ) -> None:
264
+ cell = EnvironmentErrorCell(
265
+ error=EnvironmentError(code="failed", message="failure", ways_to_fix=["Retry."]), is_journal=True
266
+ )
267
+ processor = RecordingProcessor()
268
+ emitter = StreamOutputEmitter(journal_processor=processor)
269
+ emitter.emit([cell]).unwrap()
270
+ assert len(processor.records) == 1
271
+ assert processor.records[0].message == "failure"
272
+ assert processor.records[0].attributes == {"type": "error", "code": "failed"}
273
+ assert "failure\nWay to fix: Retry." in capsys.readouterr().out
274
+
275
+ def test_emit__failure_stops_delivery_and_never_replays_processor(
276
+ self, capsys: pytest.CaptureFixture[str]
277
+ ) -> None:
278
+ error = EnvironmentError(code="forward_failed", message="failed")
279
+ processor = RecordingProcessor(Err([error]))
280
+ emitter = StreamOutputEmitter(journal_processor=processor)
281
+ cell = JournalCell(message="failed event", is_warning=True)
282
+ assert emitter.emit([cell, WarningCell(message="unreached")]).unwrap_err() == [error]
283
+ emitter.flush()
284
+ assert not capsys.readouterr().out
285
+ assert processor.records == [cell.to_journal_record()]
286
+
287
+ @pytest.mark.parametrize(
288
+ ("items", "expected"),
289
+ [
290
+ ([("café", "second"), (), ("third",)], " TOOL 0/3: café\n TOOL 1/3: second\n TOOL 2/3: third\n"),
291
+ ([("only",)], " TOOL 0/1: only\n"),
292
+ ([()], ""),
293
+ ([], ""),
294
+ ],
295
+ )
296
+ def test_flush__preserves_projection_order_and_supplies_complete_batch_context(
297
+ self, capsys: pytest.CaptureFixture[str], items: list[tuple[str, ...]], expected: str
298
+ ) -> None:
299
+ emitter = StreamOutputEmitter()
300
+ emitter.emit([PositionedLogicCell(items=values) for values in items]).unwrap()
301
+ with static_config.scope(StaticConfig(tool_label=ToolLabel(" TOOL "))):
302
+ emitter.flush()
303
+ assert capsys.readouterr() == (expected, "")
304
+
305
+ def test_flush__selects_protocol_at_delivery(self, capsys: pytest.CaptureFixture[str]) -> None:
306
+ emitter = StreamOutputEmitter()
307
+ emitter.emit([PositionedLogicCell(items=("hidden",))]).unwrap()
308
+ with runtime_config.scope(RuntimeConfig(protocol=Protocol.llm)):
309
+ emitter.flush()
310
+ assert capsys.readouterr() == ("", "")
311
+
312
+ @pytest.mark.parametrize("protocol", [Protocol.human, Protocol.llm])
313
+ @pytest.mark.parametrize("custom_stream", [False, True])
314
+ def test_flush__preserves_unicode_and_protocol_specific_text(
315
+ self, capsys: pytest.CaptureFixture[str], protocol: Protocol, custom_stream: bool
316
+ ) -> None:
317
+ stream = io.StringIO()
318
+ emitter = StreamOutputEmitter(stream=stream if custom_stream else None)
319
+ emitter.emit([ContentCell(kind="message", media_type="text/plain", content="café 日本語")]).unwrap()
320
+ with (
321
+ runtime_config.scope(RuntimeConfig(protocol=protocol)),
322
+ static_config.scope(StaticConfig(tool_label=ToolLabel(" TOOL "))),
323
+ ):
324
+ emitter.flush()
325
+ captured = capsys.readouterr()
326
+ text = stream.getvalue() if custom_stream else captured.out
327
+ assert (captured.out if custom_stream else stream.getvalue()) == ""
328
+ assert captured.err == ""
329
+ assert "café 日本語" in text
330
+ if protocol == Protocol.human:
331
+ assert text == "café 日本語\n\n"
332
+ else:
333
+ assert text.startswith("-- TOOL -CELL ")
334
+ assert text.endswith(" END--\n")
335
+
336
+ @pytest.mark.parametrize("label", ["FIRST", "SECOND"])
337
+ def test_flush__automation_preserves_cells_and_ignores_label(self, label: str) -> None:
338
+ stream = io.StringIO()
339
+ emitter = StreamOutputEmitter(stream=stream)
340
+ cell = ContentCell(kind="message", media_type="text/plain", content="café 日本語", meta={"type": "message"})
341
+ original = cell.model_dump()
342
+ with (
343
+ runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)),
344
+ static_config.scope(StaticConfig(tool_label=ToolLabel(label))),
345
+ ):
346
+ emitter.emit([cell]).unwrap()
347
+ emitter.flush()
348
+ emitter.emit([cell]).unwrap()
349
+ emitter.flush()
350
+ first, second = [json.loads(line) for line in stream.getvalue().splitlines()]
351
+ assert first.pop("id") != second.pop("id")
352
+ assert first == second == {"type": "message", "content": "café 日本語"}
353
+ assert cell.model_dump() == original
354
+ assert stream.getvalue().endswith("\n")
355
+
356
+ def test_flush__preserves_error_order_and_guidance(self, capsys: pytest.CaptureFixture[str]) -> None:
357
+ emitter = StreamOutputEmitter()
358
+ emitter.emit(
359
+ [
360
+ EnvironmentErrorCell(error=EnvironmentError(code="first", message="First", ways_to_fix=["Retry."])),
361
+ EnvironmentErrorCell(error=EnvironmentError(code="second", message="Second")),
362
+ ]
363
+ ).unwrap()
364
+ with runtime_config.scope(RuntimeConfig(protocol=Protocol.automation)):
365
+ emitter.flush()
366
+ records = [json.loads(line) for line in capsys.readouterr().out.splitlines()]
367
+ assert records[0].pop("id") != records[1].pop("id")
368
+ assert records == [
369
+ {"type": "error", "code": "first", "content": "First\nWay to fix: Retry."},
370
+ {"type": "error", "code": "second", "content": "Second"},
371
+ ]
372
+
373
+ def test_flush__missing_initialization_writes_nothing(
374
+ self, isolated_settings: None, capsys: pytest.CaptureFixture[str]
375
+ ) -> None:
376
+ emitter = StreamOutputEmitter()
377
+ emitter.emit([ContentCell(kind="message")]).unwrap()
378
+ with pytest.raises(ContextValueNotSet) as caught:
379
+ emitter.flush()
380
+ assert caught.value.details == {"name": "static_config"}
381
+ assert capsys.readouterr() == ("", "")
382
+
383
+ @pytest.mark.parametrize(
384
+ ("last_output", "exception_type"),
385
+ [(ValueError("bad render"), ValueError), (b"\xff", UnicodeDecodeError)],
386
+ )
387
+ def test_flush__render_or_decode_failure_writes_nothing(
388
+ self,
389
+ mocker: MockerFixture,
390
+ capsys: pytest.CaptureFixture[str],
391
+ last_output: bytes | Exception,
392
+ exception_type: type[Exception],
393
+ ) -> None:
394
+ emitter = StreamOutputEmitter()
395
+ emitter.emit([ContentCell(kind="first"), ContentCell(kind="last")]).unwrap()
396
+ mocker.patch.object(HumanOutputCell, "render", side_effect=[b"first", last_output])
397
+ with pytest.raises(exception_type):
398
+ emitter.flush()
399
+ assert not capsys.readouterr().out
400
+ emitter.flush()
401
+ assert not capsys.readouterr().out
402
+
403
+ def test_emit__flushes_live_journal_stream(self, mocker: MockerFixture) -> None:
404
+ flush = mocker.spy(sys.stdout, "flush")
405
+ StreamOutputEmitter().emit([JournalCell(message="live")]).unwrap()
406
+ flush.assert_called_once()
@@ -0,0 +1,141 @@
1
+ import datetime
2
+ import json
3
+ import sys
4
+ from pathlib import Path
5
+
6
+ import pydantic
7
+ import pytest
8
+ from pytest_mock import MockerFixture
9
+
10
+ from llm_tool_cli.core.errors import EnvironmentError
11
+ from llm_tool_cli.output.errors import JournalCommandConfigInvalid, JournalCommandFailed
12
+ from llm_tool_cli.output.journal import CommandJournalProcessor, JournalConfig, NoopJournalProcessor
13
+ from llm_tool_cli.protocol.journal import JournalRecord
14
+ from llm_tool_cli.protocol.logic_cells import EnvironmentErrorCell
15
+ from llm_tool_cli.protocol.logic_cells.journal import JournalCell
16
+
17
+
18
+ class TestNoopJournalProcessor:
19
+ def test_process__accepts_record_without_output(self, capsys: pytest.CaptureFixture[str]) -> None:
20
+ assert NoopJournalProcessor().process(JournalRecord(message="event")).is_ok()
21
+ assert capsys.readouterr() == ("", "")
22
+
23
+
24
+ class TestCommandJournalProcessor:
25
+ def test_process__preserves_literal_and_multiline_arguments(self, tmp_path: Path) -> None:
26
+ target = tmp_path / "arguments.json"
27
+ timestamp = datetime.datetime.now(datetime.UTC)
28
+ cell = JournalCell(message=" first\nsecond\rthird ", timestamp=timestamp)
29
+ processor = CommandJournalProcessor(
30
+ [
31
+ sys.executable,
32
+ "-c",
33
+ "import json, pathlib, sys; pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))",
34
+ str(target),
35
+ "{message}",
36
+ "{timestamp}",
37
+ "{actor_id}",
38
+ "literal:{message}",
39
+ "{message",
40
+ ]
41
+ )
42
+ processor.process(cell.to_journal_record()).unwrap()
43
+ assert json.loads(target.read_text()) == [
44
+ "first\nsecond\rthird",
45
+ timestamp.isoformat(),
46
+ "",
47
+ "literal:{message}",
48
+ "{message",
49
+ ]
50
+
51
+ def test_process__supports_error_cell_fields(self, tmp_path: Path) -> None:
52
+ target = tmp_path / "code.txt"
53
+ processor = CommandJournalProcessor(
54
+ [
55
+ sys.executable,
56
+ "-c",
57
+ "import pathlib, sys; pathlib.Path(sys.argv[1]).write_text(sys.argv[2])",
58
+ str(target),
59
+ "{code}",
60
+ ]
61
+ )
62
+ processor.process(
63
+ EnvironmentErrorCell(error=EnvironmentError(code="failed", message="failure")).to_journal_record()
64
+ ).unwrap()
65
+ assert target.read_text() == "failed"
66
+
67
+ def test_process__empty_command_does_not_execute(self, mocker: MockerFixture) -> None:
68
+ run = mocker.patch("llm_tool_cli.output.journal.subprocess.run")
69
+ failures = CommandJournalProcessor([]).process(JournalRecord(message="test")).unwrap_err()
70
+ assert isinstance(failures[0], JournalCommandConfigInvalid)
71
+ run.assert_not_called()
72
+
73
+ def test_process__missing_executable_is_expected_error(self, tmp_path: Path) -> None:
74
+ command = [str(tmp_path / "missing")]
75
+ error = CommandJournalProcessor(command).process(JournalRecord(message="test")).unwrap_err()[0]
76
+ assert isinstance(error, JournalCommandFailed)
77
+ assert error.command == command
78
+ assert error.returncode is None
79
+
80
+ @pytest.mark.parametrize("stderr", ["", "explanation"])
81
+ def test_process__nonzero_exit_preserves_context(self, stderr: str) -> None:
82
+ command = [sys.executable, "-c", "import sys; sys.stderr.write(sys.argv[1]); sys.exit(7)", stderr]
83
+ error = CommandJournalProcessor(command).process(JournalRecord(message="test")).unwrap_err()[0]
84
+ assert isinstance(error, JournalCommandFailed)
85
+ assert error.returncode == 7
86
+ assert error.command == command
87
+ assert error.details == ("exit code 7; stderr: explanation" if stderr else "exit code 7")
88
+
89
+ @pytest.mark.parametrize(
90
+ "attributes, expected",
91
+ [
92
+ ({}, ""),
93
+ ({"task_id": None}, ""),
94
+ ({"task_id": "T-1"}, "T-1"),
95
+ ({"task_id": 0}, "0"),
96
+ ({"task_id": False}, "False"),
97
+ ],
98
+ )
99
+ def test_process__substitutes_record_fields(
100
+ self, tmp_path: Path, attributes: dict[str, pydantic.JsonValue], expected: str
101
+ ) -> None:
102
+ target = tmp_path / "argument.txt"
103
+ processor = CommandJournalProcessor(
104
+ [
105
+ sys.executable,
106
+ "-c",
107
+ "import pathlib, sys; pathlib.Path(sys.argv[1]).write_text(sys.argv[2])",
108
+ str(target),
109
+ "{task_id}",
110
+ ]
111
+ )
112
+ processor.process(JournalRecord(message="event", attributes=attributes)).unwrap()
113
+ assert target.read_text() == expected
114
+
115
+
116
+ class TestJournalConfig:
117
+ @pytest.mark.parametrize("command", [[], ["tool", 1]])
118
+ def test_validation__rejects_invalid_command(self, command: list[object]) -> None:
119
+ with pytest.raises(pydantic.ValidationError):
120
+ JournalConfig.model_validate({"cmd": command})
121
+
122
+ def test_processor__defaults_to_noop(self) -> None:
123
+ assert JournalConfig().processor().process(JournalRecord(message="event")).is_ok()
124
+
125
+ def test_processor__uses_record_fields_without_declarations(self, tmp_path: Path) -> None:
126
+ target = tmp_path / "arguments.json"
127
+ config = JournalConfig(
128
+ cmd=[
129
+ sys.executable,
130
+ "-c",
131
+ "import json, pathlib, sys; pathlib.Path(sys.argv[1]).write_text(json.dumps(sys.argv[2:]))",
132
+ str(target),
133
+ "{missing}",
134
+ "{message}",
135
+ "{custom_field}",
136
+ "{attributes}",
137
+ "after",
138
+ ]
139
+ )
140
+ config.processor().process(JournalRecord(message="event", attributes={"custom_field": "value"})).unwrap()
141
+ assert json.loads(target.read_text()) == ["", "event", "value", "", "after"]
@@ -0,0 +1,51 @@
1
+ """Lexical identifiers, filesystem resolution, and project-root containment.
2
+
3
+ Lexical normalization is independent of the filesystem. Application-specific
4
+ syntax belongs to callers. ``paths.errors`` exposes public path failures.
5
+ """
6
+
7
+ from llm_tool_cli.paths.enumeration import enumerate_files
8
+ from llm_tool_cli.paths.filesystem import (
9
+ PathInput,
10
+ ProjectConfigPath,
11
+ ProjectRootPath,
12
+ RelativeProjectPath,
13
+ ResolvedProjectPath,
14
+ normalize_path,
15
+ project_path_id_from_filesystem,
16
+ project_path_id_from_resolved,
17
+ resolve_inside_project,
18
+ resolve_project_path,
19
+ resolve_project_root,
20
+ resolve_root_anchored_path,
21
+ )
22
+ from llm_tool_cli.paths.globs import FileGlobPattern, glob_capture_names, match_glob
23
+ from llm_tool_cli.paths.normalization import (
24
+ ProjectPathId,
25
+ is_project_path_id,
26
+ normalize_project_path_id,
27
+ project_path_parts,
28
+ )
29
+
30
+ __all__ = [
31
+ "FileGlobPattern",
32
+ "PathInput",
33
+ "ProjectConfigPath",
34
+ "ProjectPathId",
35
+ "ProjectRootPath",
36
+ "RelativeProjectPath",
37
+ "ResolvedProjectPath",
38
+ "enumerate_files",
39
+ "glob_capture_names",
40
+ "is_project_path_id",
41
+ "match_glob",
42
+ "normalize_path",
43
+ "normalize_project_path_id",
44
+ "project_path_id_from_filesystem",
45
+ "project_path_id_from_resolved",
46
+ "project_path_parts",
47
+ "resolve_inside_project",
48
+ "resolve_project_path",
49
+ "resolve_project_root",
50
+ "resolve_root_anchored_path",
51
+ ]
@@ -0,0 +1,72 @@
1
+ """Deterministic project-file discovery without symlink traversal."""
2
+
3
+ import stat
4
+ from collections.abc import Iterator
5
+ from pathlib import Path
6
+
7
+ from llm_tool_cli.core.result import Err, Ok, Result, unwrap_to_error
8
+ from llm_tool_cli.paths.errors import FilesUnreadable
9
+ from llm_tool_cli.paths.filesystem import (
10
+ ResolvedProjectPath,
11
+ project_path_id_from_resolved,
12
+ resolve_project_root,
13
+ )
14
+ from llm_tool_cli.paths.globs import FileGlobPattern, _compile_glob, _pattern_parts
15
+ from llm_tool_cli.paths.normalization import ProjectPathId
16
+
17
+
18
+ def _glob_files(path: Path, parts: tuple[str, ...]) -> Iterator[Path]: # noqa: CCR001
19
+ try:
20
+ mode = path.lstat().st_mode
21
+ except (FileNotFoundError, NotADirectoryError):
22
+ return
23
+
24
+ if not parts:
25
+ if stat.S_ISREG(mode):
26
+ yield path
27
+ return
28
+ if not stat.S_ISDIR(mode):
29
+ return
30
+
31
+ segment, *remaining = parts
32
+ compiled, recursive = _compile_glob(FileGlobPattern(segment)).unwrap()
33
+ if recursive:
34
+ compiled, _ = _compile_glob(FileGlobPattern("/".join(parts))).unwrap()
35
+ for entry in _walk_files(path):
36
+ if compiled.fullmatch(entry.relative_to(path).as_posix()):
37
+ yield entry
38
+ return
39
+ for entry in sorted(path.iterdir()):
40
+ if compiled.fullmatch(entry.name):
41
+ yield from _glob_files(entry, tuple(remaining))
42
+
43
+
44
+ def _walk_files(path: Path) -> Iterator[Path]:
45
+ """Walk regular files without following links, including broken links."""
46
+ try:
47
+ mode = path.lstat().st_mode
48
+ except (FileNotFoundError, NotADirectoryError):
49
+ return
50
+ if stat.S_ISREG(mode):
51
+ yield path
52
+ elif stat.S_ISDIR(mode):
53
+ for entry in sorted(path.iterdir()):
54
+ yield from _walk_files(entry)
55
+
56
+
57
+ @unwrap_to_error
58
+ def enumerate_files(root: Path, pattern: FileGlobPattern = FileGlobPattern("**/*")) -> Result[list[ProjectPathId]]:
59
+ """Find regular files matching a project-relative or root-anchored glob.
60
+
61
+ Support absolute and home-relative patterns through shared path resolution.
62
+ Include hidden entries, skip all discovered symlinks, and reject symlinks
63
+ in explicit pattern components. Return unique ids in path-component order.
64
+ """
65
+ project_root = resolve_project_root(root).unwrap()
66
+ parts = _pattern_parts(pattern, project_root).unwrap()
67
+ _compile_glob(FileGlobPattern("/".join(parts))).unwrap()
68
+ try:
69
+ paths = sorted(set(_glob_files(project_root, parts)))
70
+ except OSError as error:
71
+ return Err([FilesUnreadable(path=str(project_root), reason=str(error)).with_cause(error)])
72
+ return Ok([project_path_id_from_resolved(ResolvedProjectPath(path), project_root) for path in paths])