sefia-typer 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sefia_typer-0.1.0/.gitignore +175 -0
- sefia_typer-0.1.0/LICENSE +21 -0
- sefia_typer-0.1.0/PKG-INFO +86 -0
- sefia_typer-0.1.0/README.md +36 -0
- sefia_typer-0.1.0/pyproject.toml +60 -0
- sefia_typer-0.1.0/src/sefia_typer/__init__.py +36 -0
- sefia_typer-0.1.0/src/sefia_typer/_input.py +230 -0
- sefia_typer-0.1.0/src/sefia_typer/_kv.py +16 -0
- sefia_typer-0.1.0/src/sefia_typer/_reporter.py +113 -0
- sefia_typer-0.1.0/src/sefia_typer/exceptions.py +25 -0
- sefia_typer-0.1.0/src/sefia_typer/py.typed +0 -0
- sefia_typer-0.1.0/tests/conftest.py +24 -0
- sefia_typer-0.1.0/tests/units/test_input.py +182 -0
- sefia_typer-0.1.0/tests/units/test_reporter.py +92 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
*.manifest
|
|
31
|
+
*.spec
|
|
32
|
+
|
|
33
|
+
# Installer logs
|
|
34
|
+
pip-log.txt
|
|
35
|
+
pip-delete-this-directory.txt
|
|
36
|
+
|
|
37
|
+
# Unit test / coverage reports
|
|
38
|
+
htmlcov/
|
|
39
|
+
.tox/
|
|
40
|
+
.nox/
|
|
41
|
+
.coverage
|
|
42
|
+
.coverage.*
|
|
43
|
+
.cache
|
|
44
|
+
nosetests.xml
|
|
45
|
+
coverage.xml
|
|
46
|
+
*.cover
|
|
47
|
+
*.py.cover
|
|
48
|
+
.hypothesis/
|
|
49
|
+
.pytest_cache/
|
|
50
|
+
cover/
|
|
51
|
+
|
|
52
|
+
# Translations
|
|
53
|
+
*.mo
|
|
54
|
+
*.pot
|
|
55
|
+
|
|
56
|
+
# Django stuff:
|
|
57
|
+
*.log
|
|
58
|
+
local_settings.py
|
|
59
|
+
db.sqlite3
|
|
60
|
+
db.sqlite3-journal
|
|
61
|
+
|
|
62
|
+
# Flask stuff:
|
|
63
|
+
instance/
|
|
64
|
+
.webassets-cache
|
|
65
|
+
|
|
66
|
+
# Scrapy stuff:
|
|
67
|
+
.scrapy
|
|
68
|
+
|
|
69
|
+
# Sphinx documentation
|
|
70
|
+
docs/_build/
|
|
71
|
+
|
|
72
|
+
# PyBuilder
|
|
73
|
+
.pybuilder/
|
|
74
|
+
target/
|
|
75
|
+
|
|
76
|
+
# Jupyter Notebook
|
|
77
|
+
.ipynb_checkpoints
|
|
78
|
+
|
|
79
|
+
# IPython
|
|
80
|
+
profile_default/
|
|
81
|
+
ipython_config.py
|
|
82
|
+
|
|
83
|
+
# pyenv
|
|
84
|
+
# .python-version
|
|
85
|
+
|
|
86
|
+
# pipenv
|
|
87
|
+
#Pipfile.lock
|
|
88
|
+
|
|
89
|
+
# UV
|
|
90
|
+
#uv.lock
|
|
91
|
+
|
|
92
|
+
# poetry
|
|
93
|
+
#poetry.lock
|
|
94
|
+
#poetry.toml
|
|
95
|
+
|
|
96
|
+
# pdm
|
|
97
|
+
#pdm.lock
|
|
98
|
+
#pdm.toml
|
|
99
|
+
.pdm-python
|
|
100
|
+
.pdm-build/
|
|
101
|
+
|
|
102
|
+
# pixi
|
|
103
|
+
#pixi.lock
|
|
104
|
+
.pixi
|
|
105
|
+
|
|
106
|
+
# PEP 582
|
|
107
|
+
__pypackages__/
|
|
108
|
+
|
|
109
|
+
# Celery stuff
|
|
110
|
+
celerybeat-schedule
|
|
111
|
+
celerybeat.pid
|
|
112
|
+
|
|
113
|
+
# SageMath parsed files
|
|
114
|
+
*.sage.py
|
|
115
|
+
|
|
116
|
+
# Environments
|
|
117
|
+
.env
|
|
118
|
+
.envrc
|
|
119
|
+
.venv
|
|
120
|
+
env/
|
|
121
|
+
venv/
|
|
122
|
+
ENV/
|
|
123
|
+
env.bak/
|
|
124
|
+
venv.bak/
|
|
125
|
+
|
|
126
|
+
# Spyder project settings
|
|
127
|
+
.spyderproject
|
|
128
|
+
.spyproject
|
|
129
|
+
|
|
130
|
+
# Rope project settings
|
|
131
|
+
.ropeproject
|
|
132
|
+
|
|
133
|
+
# mkdocs documentation
|
|
134
|
+
/site
|
|
135
|
+
|
|
136
|
+
# mypy
|
|
137
|
+
.mypy_cache/
|
|
138
|
+
.dmypy.json
|
|
139
|
+
dmypy.json
|
|
140
|
+
|
|
141
|
+
# Pyre type checker
|
|
142
|
+
.pyre/
|
|
143
|
+
|
|
144
|
+
# pytype static type analyzer
|
|
145
|
+
.pytype/
|
|
146
|
+
|
|
147
|
+
# Cython debug symbols
|
|
148
|
+
cython_debug/
|
|
149
|
+
|
|
150
|
+
# PyCharm
|
|
151
|
+
#.idea/
|
|
152
|
+
|
|
153
|
+
# Abstra
|
|
154
|
+
.abstra/
|
|
155
|
+
|
|
156
|
+
# Visual Studio Code
|
|
157
|
+
# .vscode/
|
|
158
|
+
|
|
159
|
+
# Ruff stuff:
|
|
160
|
+
.ruff_cache/
|
|
161
|
+
|
|
162
|
+
# PyPI configuration file
|
|
163
|
+
.pypirc
|
|
164
|
+
|
|
165
|
+
# Cursor
|
|
166
|
+
.cursorignore
|
|
167
|
+
.cursorindexingignore
|
|
168
|
+
|
|
169
|
+
# Marimo
|
|
170
|
+
marimo/_static/
|
|
171
|
+
marimo/_lsp/
|
|
172
|
+
__marimo__/
|
|
173
|
+
|
|
174
|
+
/out/
|
|
175
|
+
/.local/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 nueruyu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: sefia-typer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Typer (CLI) building blocks for Sefia applications.
|
|
5
|
+
Project-URL: Homepage, https://github.com/nueruyu/sefia
|
|
6
|
+
Project-URL: Repository, https://github.com/nueruyu/sefia
|
|
7
|
+
Project-URL: Issues, https://github.com/nueruyu/sefia/issues
|
|
8
|
+
Project-URL: Documentation, https://github.com/nueruyu/sefia/tree/main/docs
|
|
9
|
+
Author: nueruyu
|
|
10
|
+
License: MIT License
|
|
11
|
+
|
|
12
|
+
Copyright (c) 2026 nueruyu
|
|
13
|
+
|
|
14
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
15
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
16
|
+
in the Software without restriction, including without limitation the rights
|
|
17
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
18
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
19
|
+
furnished to do so, subject to the following conditions:
|
|
20
|
+
|
|
21
|
+
The above copyright notice and this permission notice shall be included in all
|
|
22
|
+
copies or substantial portions of the Software.
|
|
23
|
+
|
|
24
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
25
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
26
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
27
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
28
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
29
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
30
|
+
SOFTWARE.
|
|
31
|
+
License-File: LICENSE
|
|
32
|
+
Keywords: cli,human-in-the-loop,sefia,typer
|
|
33
|
+
Classifier: Development Status :: 3 - Alpha
|
|
34
|
+
Classifier: Environment :: Console
|
|
35
|
+
Classifier: Framework :: AsyncIO
|
|
36
|
+
Classifier: Intended Audience :: Developers
|
|
37
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
38
|
+
Classifier: Operating System :: OS Independent
|
|
39
|
+
Classifier: Programming Language :: Python :: 3
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
41
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
42
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
43
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
44
|
+
Classifier: Typing :: Typed
|
|
45
|
+
Requires-Python: >=3.11
|
|
46
|
+
Requires-Dist: rich>=13.0
|
|
47
|
+
Requires-Dist: sefia>=0.1.0
|
|
48
|
+
Requires-Dist: typer>=0.12
|
|
49
|
+
Description-Content-Type: text/markdown
|
|
50
|
+
|
|
51
|
+
# sefia-typer
|
|
52
|
+
|
|
53
|
+
Typer (CLI) building blocks for [Sefia](https://pypi.org/project/sefia/)
|
|
54
|
+
applications.
|
|
55
|
+
|
|
56
|
+
This package holds the CLI-side pieces that depend only on `sefia` and Typer:
|
|
57
|
+
the input core (an `InputChannel` persisted over a `KeyValueStore`), the
|
|
58
|
+
reporter surface, and the exceptions applications catch. The runtime wiring —
|
|
59
|
+
session management, persistence, and the pausing tool — is provided by an
|
|
60
|
+
integration layer such as `sefios.cli` from
|
|
61
|
+
[`sefios`](https://pypi.org/project/sefios/).
|
|
62
|
+
|
|
63
|
+
## Install
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install sefia-typer
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Most applications install it through the stack instead:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
pip install 'sefios[cli]'
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Documentation
|
|
76
|
+
|
|
77
|
+
See the [repository](https://github.com/nueruyu/sefia) for the full README,
|
|
78
|
+
tutorial, and architecture docs.
|
|
79
|
+
|
|
80
|
+
## Status
|
|
81
|
+
|
|
82
|
+
Early development. APIs may change before v1.0.
|
|
83
|
+
|
|
84
|
+
## License
|
|
85
|
+
|
|
86
|
+
MIT
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# sefia-typer
|
|
2
|
+
|
|
3
|
+
Typer (CLI) building blocks for [Sefia](https://pypi.org/project/sefia/)
|
|
4
|
+
applications.
|
|
5
|
+
|
|
6
|
+
This package holds the CLI-side pieces that depend only on `sefia` and Typer:
|
|
7
|
+
the input core (an `InputChannel` persisted over a `KeyValueStore`), the
|
|
8
|
+
reporter surface, and the exceptions applications catch. The runtime wiring —
|
|
9
|
+
session management, persistence, and the pausing tool — is provided by an
|
|
10
|
+
integration layer such as `sefios.cli` from
|
|
11
|
+
[`sefios`](https://pypi.org/project/sefios/).
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install sefia-typer
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Most applications install it through the stack instead:
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install 'sefios[cli]'
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Documentation
|
|
26
|
+
|
|
27
|
+
See the [repository](https://github.com/nueruyu/sefia) for the full README,
|
|
28
|
+
tutorial, and architecture docs.
|
|
29
|
+
|
|
30
|
+
## Status
|
|
31
|
+
|
|
32
|
+
Early development. APIs may change before v1.0.
|
|
33
|
+
|
|
34
|
+
## License
|
|
35
|
+
|
|
36
|
+
MIT
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "sefia-typer"
|
|
3
|
+
description = "Typer (CLI) building blocks for Sefia applications."
|
|
4
|
+
readme = "README.md"
|
|
5
|
+
license = { file = "LICENSE" }
|
|
6
|
+
authors = [{ name = "nueruyu" }]
|
|
7
|
+
requires-python = ">=3.11"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"sefia>=0.1.0",
|
|
10
|
+
"typer>=0.12",
|
|
11
|
+
"rich>=13.0",
|
|
12
|
+
]
|
|
13
|
+
dynamic = ["version"]
|
|
14
|
+
keywords = [
|
|
15
|
+
"cli",
|
|
16
|
+
"typer",
|
|
17
|
+
"human-in-the-loop",
|
|
18
|
+
"sefia",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"Programming Language :: Python :: 3.11",
|
|
23
|
+
"Programming Language :: Python :: 3.12",
|
|
24
|
+
"Programming Language :: Python :: 3.13",
|
|
25
|
+
"License :: OSI Approved :: MIT License",
|
|
26
|
+
"Operating System :: OS Independent",
|
|
27
|
+
"Development Status :: 3 - Alpha",
|
|
28
|
+
"Environment :: Console",
|
|
29
|
+
"Framework :: AsyncIO",
|
|
30
|
+
"Intended Audience :: Developers",
|
|
31
|
+
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.urls]
|
|
36
|
+
Homepage = "https://github.com/nueruyu/sefia"
|
|
37
|
+
Repository = "https://github.com/nueruyu/sefia"
|
|
38
|
+
Issues = "https://github.com/nueruyu/sefia/issues"
|
|
39
|
+
Documentation = "https://github.com/nueruyu/sefia/tree/main/docs"
|
|
40
|
+
|
|
41
|
+
[build-system]
|
|
42
|
+
requires = ["hatchling", "hatch-vcs"]
|
|
43
|
+
build-backend = "hatchling.build"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.build.targets.wheel]
|
|
46
|
+
packages = ["src/sefia_typer"]
|
|
47
|
+
|
|
48
|
+
[tool.hatch.version]
|
|
49
|
+
source = "vcs"
|
|
50
|
+
raw-options = { root = "../.." }
|
|
51
|
+
|
|
52
|
+
[tool.pytest.ini_options]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
testpaths = ["tests"]
|
|
55
|
+
|
|
56
|
+
[tool.uv.sources]
|
|
57
|
+
sefia = { workspace = true }
|
|
58
|
+
|
|
59
|
+
[dependency-groups]
|
|
60
|
+
dev = ["pytest>=8.0", "pytest-asyncio>=0.23", "pytest-mock>=3.12.0"]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Typer (CLI) building blocks for Sefia applications.
|
|
2
|
+
|
|
3
|
+
This package holds the CLI-side pieces that depend only on ``sefia`` and
|
|
4
|
+
Typer: the input core (an :class:`InputChannel` persisted over a
|
|
5
|
+
:class:`KeyValueStore`), the reporter surface, and the exceptions
|
|
6
|
+
applications catch. The runtime wiring — session management, persistence,
|
|
7
|
+
and the pausing tool — is provided by an integration layer such as
|
|
8
|
+
``sefios.cli``.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from ._input import InputChannel, InputRequest
|
|
12
|
+
from ._kv import KeyValueStore
|
|
13
|
+
from ._reporter import (
|
|
14
|
+
CLIReporter,
|
|
15
|
+
DefaultCLIReporter,
|
|
16
|
+
OutputMessage,
|
|
17
|
+
ResolvedSession,
|
|
18
|
+
)
|
|
19
|
+
from .exceptions import (
|
|
20
|
+
AmbiguousInputError,
|
|
21
|
+
UnknownInputError,
|
|
22
|
+
UnknownSessionError,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"InputChannel",
|
|
27
|
+
"InputRequest",
|
|
28
|
+
"KeyValueStore",
|
|
29
|
+
"CLIReporter",
|
|
30
|
+
"DefaultCLIReporter",
|
|
31
|
+
"OutputMessage",
|
|
32
|
+
"ResolvedSession",
|
|
33
|
+
"UnknownSessionError",
|
|
34
|
+
"UnknownInputError",
|
|
35
|
+
"AmbiguousInputError",
|
|
36
|
+
]
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""The CLI-side input core.
|
|
2
|
+
|
|
3
|
+
Pending prompts, provided inputs, and queued inputs are persisted through a
|
|
4
|
+
:class:`KeyValueStore`, so a paused CLI invocation can be resumed by a later
|
|
5
|
+
one. The channel only sees primitives; how the runtime provides persistence
|
|
6
|
+
(and which tool raises the pause) is wired up by the integration layer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import inspect
|
|
10
|
+
from collections.abc import Awaitable, Callable
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from contextvars import ContextVar
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from typing import TypeVar
|
|
15
|
+
|
|
16
|
+
from ._kv import KeyValueStore
|
|
17
|
+
from .exceptions import AmbiguousInputError, UnknownInputError
|
|
18
|
+
|
|
19
|
+
_DEFAULT_NAMESPACE = "input_channel"
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
MaybeAwaitable = T | Awaitable[T]
|
|
23
|
+
InputRequestHandler = Callable[["InputRequest"], MaybeAwaitable[None]]
|
|
24
|
+
InputPromptDeltaHandler = Callable[[str], MaybeAwaitable[None]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class InputRequest:
|
|
29
|
+
"""A pending request for external input."""
|
|
30
|
+
|
|
31
|
+
interaction_id: str
|
|
32
|
+
prompt: str
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class InputChannel:
|
|
36
|
+
"""The input pipe between a CLI application and a paused agent.
|
|
37
|
+
|
|
38
|
+
One object owns the whole lifecycle. The tool-facing side records prompts
|
|
39
|
+
and picks up provided input (:meth:`record_request` / :meth:`provide_input`
|
|
40
|
+
/ :meth:`complete_request`); the application-facing side routes arriving
|
|
41
|
+
input to pending requests (:meth:`receive_input`); :meth:`use_store` binds
|
|
42
|
+
the persistence both sides share. ``on_request`` and ``on_prompt_delta``
|
|
43
|
+
are the rendering hooks (e.g. a :class:`CLIReporter`).
|
|
44
|
+
|
|
45
|
+
Reads observe writes made earlier in the same session because the bound
|
|
46
|
+
:class:`KeyValueStore` is expected to provide read-your-writes consistency.
|
|
47
|
+
The active binding is held in a :class:`~contextvars.ContextVar` rather
|
|
48
|
+
than a plain attribute so that a single shared channel stays correct when
|
|
49
|
+
several sessions run concurrently: each task binds and reads its own store.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
def __init__(
|
|
53
|
+
self,
|
|
54
|
+
*,
|
|
55
|
+
on_request: InputRequestHandler | None = None,
|
|
56
|
+
on_prompt_delta: InputPromptDeltaHandler | None = None,
|
|
57
|
+
namespace: str = _DEFAULT_NAMESPACE,
|
|
58
|
+
):
|
|
59
|
+
namespace = namespace.strip("/")
|
|
60
|
+
if not namespace:
|
|
61
|
+
raise ValueError("Input channel namespace must not be empty.")
|
|
62
|
+
self._namespace = namespace
|
|
63
|
+
self._active_store: ContextVar[KeyValueStore | None] = ContextVar(
|
|
64
|
+
"input_active_store", default=None
|
|
65
|
+
)
|
|
66
|
+
self._on_request = on_request
|
|
67
|
+
self._on_prompt_delta = on_prompt_delta
|
|
68
|
+
|
|
69
|
+
@contextmanager
|
|
70
|
+
def use_store(self, store: KeyValueStore):
|
|
71
|
+
"""Bind the persistence backing this channel for the enclosed block."""
|
|
72
|
+
token = self._active_store.set(store)
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
self._active_store.reset(token)
|
|
77
|
+
|
|
78
|
+
async def pending(self) -> list[InputRequest]:
|
|
79
|
+
"""The requests still waiting for input, ordered by interaction id."""
|
|
80
|
+
pending = await self._pending_map()
|
|
81
|
+
return [
|
|
82
|
+
InputRequest(interaction_id=entry["id"], prompt=entry["prompt"])
|
|
83
|
+
for _, entry in sorted(pending.items())
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
async def receive_input(
|
|
87
|
+
self,
|
|
88
|
+
input_value: str | list[str] | None,
|
|
89
|
+
*,
|
|
90
|
+
reply_to: str | None = None,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Route CLI input to a pending request, or queue it for the next one.
|
|
93
|
+
|
|
94
|
+
``None`` and blank input are ignored. With ``reply_to`` the input
|
|
95
|
+
resolves that specific request; otherwise a single pending request is
|
|
96
|
+
resolved directly, multiple pending requests raise
|
|
97
|
+
:class:`AmbiguousInputError`, and no pending request queues the input
|
|
98
|
+
for the next prompt.
|
|
99
|
+
"""
|
|
100
|
+
if input_value is None:
|
|
101
|
+
return
|
|
102
|
+
input_text = _to_input_text(input_value)
|
|
103
|
+
if not input_text:
|
|
104
|
+
return
|
|
105
|
+
|
|
106
|
+
pending = await self._pending_map()
|
|
107
|
+
|
|
108
|
+
if reply_to is not None:
|
|
109
|
+
if reply_to not in pending:
|
|
110
|
+
raise UnknownInputError(reply_to)
|
|
111
|
+
await self._store_input(reply_to, input_text)
|
|
112
|
+
return
|
|
113
|
+
|
|
114
|
+
if len(pending) == 1:
|
|
115
|
+
await self._store_input(next(iter(pending)), input_text)
|
|
116
|
+
return
|
|
117
|
+
|
|
118
|
+
if len(pending) > 1:
|
|
119
|
+
raise AmbiguousInputError(sorted(pending))
|
|
120
|
+
|
|
121
|
+
await self._queue_input(input_text)
|
|
122
|
+
|
|
123
|
+
async def provide_input(self, interaction_id: str) -> str | None:
|
|
124
|
+
"""Return the stored input, or claim a queued one if unambiguous."""
|
|
125
|
+
provided = await self._stored_input(interaction_id)
|
|
126
|
+
if provided is not None:
|
|
127
|
+
return provided
|
|
128
|
+
|
|
129
|
+
pending = await self._pending_map()
|
|
130
|
+
if any(other_id != interaction_id for other_id in pending):
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
return await self._pop_queued_input()
|
|
134
|
+
|
|
135
|
+
async def record_request(self, interaction_id: str, prompt: str) -> None:
|
|
136
|
+
pending = await self._pending_map()
|
|
137
|
+
pending[interaction_id] = {"id": interaction_id, "prompt": prompt}
|
|
138
|
+
await self._save_pending(pending)
|
|
139
|
+
if self._on_request is not None:
|
|
140
|
+
await _maybe_await(
|
|
141
|
+
self._on_request(
|
|
142
|
+
InputRequest(interaction_id=interaction_id, prompt=prompt)
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
async def complete_request(self, interaction_id: str) -> None:
|
|
147
|
+
pending = await self._pending_map()
|
|
148
|
+
pending.pop(interaction_id, None)
|
|
149
|
+
await self._save_pending(pending)
|
|
150
|
+
|
|
151
|
+
async def notify_prompt_delta(self, text: str) -> None:
|
|
152
|
+
if self._on_prompt_delta is not None:
|
|
153
|
+
await _maybe_await(self._on_prompt_delta(text))
|
|
154
|
+
|
|
155
|
+
async def _pending_map(self) -> dict[str, dict]:
|
|
156
|
+
store = self._store()
|
|
157
|
+
pending = await store.get(self._pending_key, dict) or {}
|
|
158
|
+
if not pending:
|
|
159
|
+
return {}
|
|
160
|
+
|
|
161
|
+
unresolved = {}
|
|
162
|
+
for interaction_id, request in pending.items():
|
|
163
|
+
provided = await self._stored_input(interaction_id)
|
|
164
|
+
if provided is None:
|
|
165
|
+
unresolved[interaction_id] = request
|
|
166
|
+
|
|
167
|
+
await self._save_pending(unresolved)
|
|
168
|
+
return dict(unresolved)
|
|
169
|
+
|
|
170
|
+
async def _save_pending(self, pending: dict[str, dict]) -> None:
|
|
171
|
+
store = self._store()
|
|
172
|
+
if pending:
|
|
173
|
+
await store.set(self._pending_key, pending, dict)
|
|
174
|
+
return
|
|
175
|
+
|
|
176
|
+
await store.delete(self._pending_key)
|
|
177
|
+
|
|
178
|
+
async def _stored_input(self, interaction_id: str) -> str | None:
|
|
179
|
+
return await self._store().get(self._input_key(interaction_id), str)
|
|
180
|
+
|
|
181
|
+
async def _store_input(self, interaction_id: str, input_text: str) -> None:
|
|
182
|
+
await self._store().set(self._input_key(interaction_id), input_text, str)
|
|
183
|
+
|
|
184
|
+
async def _queue_input(self, input_text: str) -> None:
|
|
185
|
+
store = self._store()
|
|
186
|
+
queue = await store.get(self._queued_key, list) or []
|
|
187
|
+
queue.append(input_text)
|
|
188
|
+
await store.set(self._queued_key, queue, list)
|
|
189
|
+
|
|
190
|
+
async def _pop_queued_input(self) -> str | None:
|
|
191
|
+
store = self._store()
|
|
192
|
+
queue = await store.get(self._queued_key, list)
|
|
193
|
+
if not queue:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
next_input = queue.pop(0)
|
|
197
|
+
if queue:
|
|
198
|
+
await store.set(self._queued_key, queue, list)
|
|
199
|
+
else:
|
|
200
|
+
await store.delete(self._queued_key)
|
|
201
|
+
return next_input
|
|
202
|
+
|
|
203
|
+
def _store(self) -> KeyValueStore:
|
|
204
|
+
store = self._active_store.get()
|
|
205
|
+
if store is None:
|
|
206
|
+
raise RuntimeError("Input channel is not bound to a store.")
|
|
207
|
+
return store
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def _pending_key(self) -> str:
|
|
211
|
+
return f"{self._namespace}/pending"
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def _queued_key(self) -> str:
|
|
215
|
+
return f"{self._namespace}/queued"
|
|
216
|
+
|
|
217
|
+
def _input_key(self, interaction_id: str) -> str:
|
|
218
|
+
return f"{self._namespace}/input/{interaction_id}"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _to_input_text(input_value: str | list[str]) -> str:
|
|
222
|
+
if isinstance(input_value, str):
|
|
223
|
+
return input_value.strip()
|
|
224
|
+
return " ".join(input_value).strip()
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
async def _maybe_await(value: MaybeAwaitable[T]) -> T:
|
|
228
|
+
if inspect.isawaitable(value):
|
|
229
|
+
return await value
|
|
230
|
+
return value
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from typing import Any, Protocol
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class KeyValueStore(Protocol):
|
|
5
|
+
"""Async key-value persistence required by the CLI input state.
|
|
6
|
+
|
|
7
|
+
Structurally matches ``sefios.SessionStorage``, so a bound session storage
|
|
8
|
+
can be passed in directly; any other implementation with the same shape
|
|
9
|
+
works too.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
async def get(self, key: str, type_hint: type) -> Any | None: ...
|
|
13
|
+
|
|
14
|
+
async def set(self, key: str, value: Any, type_hint: type) -> None: ...
|
|
15
|
+
|
|
16
|
+
async def delete(self, key: str) -> None: ...
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
from typing import Protocol
|
|
3
|
+
|
|
4
|
+
import typer
|
|
5
|
+
from sefia.exceptions import InferenceError
|
|
6
|
+
|
|
7
|
+
from ._input import InputRequest, MaybeAwaitable
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True)
|
|
11
|
+
class OutputMessage:
|
|
12
|
+
"""A message the agent emitted to the human without waiting for a reply."""
|
|
13
|
+
|
|
14
|
+
interaction_id: str
|
|
15
|
+
message: str
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ResolvedSession(Protocol):
|
|
19
|
+
"""The session-resolution facts a reporter renders.
|
|
20
|
+
|
|
21
|
+
Read-only by design: any object with these attributes (such as the
|
|
22
|
+
integration layer's resolved-session type) satisfies it structurally.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def session_id(self) -> str: ...
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def source(self) -> str: ...
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CLIReporter(Protocol):
|
|
33
|
+
"""Receives CLI lifecycle events and renders them for the host application."""
|
|
34
|
+
|
|
35
|
+
def on_session_resolved(
|
|
36
|
+
self,
|
|
37
|
+
session: ResolvedSession,
|
|
38
|
+
) -> MaybeAwaitable[None]: ...
|
|
39
|
+
|
|
40
|
+
def on_input_request(
|
|
41
|
+
self,
|
|
42
|
+
request: InputRequest,
|
|
43
|
+
) -> MaybeAwaitable[None]: ...
|
|
44
|
+
|
|
45
|
+
def on_input_prompt_delta(self, text: str) -> MaybeAwaitable[None]: ...
|
|
46
|
+
|
|
47
|
+
def on_output(self, message: OutputMessage) -> MaybeAwaitable[None]: ...
|
|
48
|
+
|
|
49
|
+
def on_output_message_delta(self, text: str) -> MaybeAwaitable[None]: ...
|
|
50
|
+
|
|
51
|
+
def on_interrupted(
|
|
52
|
+
self,
|
|
53
|
+
session: ResolvedSession,
|
|
54
|
+
) -> MaybeAwaitable[None]: ...
|
|
55
|
+
|
|
56
|
+
def on_inference_error(self, error: InferenceError) -> MaybeAwaitable[None]: ...
|
|
57
|
+
|
|
58
|
+
def on_session_finished(self) -> MaybeAwaitable[None]: ...
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DefaultCLIReporter(CLIReporter):
|
|
62
|
+
"""Default CLI reporter using Typer's standard terminal output helpers."""
|
|
63
|
+
|
|
64
|
+
def on_session_resolved(self, session: ResolvedSession) -> None:
|
|
65
|
+
if session.source == "created":
|
|
66
|
+
typer.secho(
|
|
67
|
+
f"> No active session. Starting new session: {session.session_id}",
|
|
68
|
+
bold=True,
|
|
69
|
+
)
|
|
70
|
+
elif session.source == "active":
|
|
71
|
+
typer.secho(f"> Resuming session {session.session_id}", bold=True)
|
|
72
|
+
|
|
73
|
+
def on_input_request(self, request: InputRequest) -> None:
|
|
74
|
+
typer.echo()
|
|
75
|
+
typer.secho(
|
|
76
|
+
f"[INPUT_REQUIRED:{request.interaction_id}]",
|
|
77
|
+
fg=typer.colors.YELLOW,
|
|
78
|
+
bold=True,
|
|
79
|
+
nl=False,
|
|
80
|
+
)
|
|
81
|
+
typer.echo(f" {request.prompt}")
|
|
82
|
+
typer.echo()
|
|
83
|
+
|
|
84
|
+
def on_input_prompt_delta(self, text: str) -> None:
|
|
85
|
+
typer.echo(text, nl=False)
|
|
86
|
+
|
|
87
|
+
def on_output(self, message: OutputMessage) -> None:
|
|
88
|
+
typer.echo()
|
|
89
|
+
typer.secho(
|
|
90
|
+
f"[OUTPUT:{message.interaction_id}]",
|
|
91
|
+
fg=typer.colors.CYAN,
|
|
92
|
+
bold=True,
|
|
93
|
+
nl=False,
|
|
94
|
+
)
|
|
95
|
+
typer.echo(f" {message.message}")
|
|
96
|
+
typer.echo()
|
|
97
|
+
|
|
98
|
+
def on_output_message_delta(self, text: str) -> None:
|
|
99
|
+
typer.echo(text, nl=False)
|
|
100
|
+
|
|
101
|
+
def on_interrupted(self, session: ResolvedSession) -> None:
|
|
102
|
+
typer.echo()
|
|
103
|
+
typer.secho("WAITING FOR INPUT", fg=typer.colors.YELLOW, bold=True)
|
|
104
|
+
typer.echo("Session interrupted to wait for your input.")
|
|
105
|
+
typer.echo("To resume, run the script again with your input.")
|
|
106
|
+
|
|
107
|
+
def on_inference_error(self, error: InferenceError) -> None:
|
|
108
|
+
typer.echo()
|
|
109
|
+
typer.secho("INFERENCE ERROR", fg=typer.colors.RED, bold=True)
|
|
110
|
+
typer.echo(str(error))
|
|
111
|
+
|
|
112
|
+
def on_session_finished(self) -> None:
|
|
113
|
+
pass
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
class UnknownSessionError(Exception):
|
|
2
|
+
"""Raised when a requested CLI session is not known."""
|
|
3
|
+
|
|
4
|
+
def __init__(self, session_id: str):
|
|
5
|
+
super().__init__(f"Unknown session: {session_id}")
|
|
6
|
+
self.session_id = session_id
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class UnknownInputError(Exception):
|
|
10
|
+
"""Raised when a CLI input targets an unknown pending input."""
|
|
11
|
+
|
|
12
|
+
def __init__(self, interaction_id: str):
|
|
13
|
+
super().__init__(f"Unknown pending input: {interaction_id}")
|
|
14
|
+
self.interaction_id = interaction_id
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class AmbiguousInputError(Exception):
|
|
18
|
+
"""Raised when multiple pending inputs need an explicit reply target."""
|
|
19
|
+
|
|
20
|
+
def __init__(self, interaction_ids: list[str]):
|
|
21
|
+
super().__init__(
|
|
22
|
+
"Multiple pending inputs exist. Specify one with --reply-to: "
|
|
23
|
+
+ ", ".join(interaction_ids)
|
|
24
|
+
)
|
|
25
|
+
self.interaction_ids = interaction_ids
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class InMemoryKeyValueStore:
|
|
7
|
+
"""A dict-backed KeyValueStore for exercising the input core."""
|
|
8
|
+
|
|
9
|
+
def __init__(self):
|
|
10
|
+
self._data: dict[str, Any] = {}
|
|
11
|
+
|
|
12
|
+
async def get(self, key: str, type_hint: type) -> Any | None:
|
|
13
|
+
return self._data.get(key)
|
|
14
|
+
|
|
15
|
+
async def set(self, key: str, value: Any, type_hint: type) -> None:
|
|
16
|
+
self._data[key] = value
|
|
17
|
+
|
|
18
|
+
async def delete(self, key: str) -> None:
|
|
19
|
+
self._data.pop(key, None)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def kv_store() -> InMemoryKeyValueStore:
|
|
24
|
+
return InMemoryKeyValueStore()
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
from sefia_typer import (
|
|
3
|
+
AmbiguousInputError,
|
|
4
|
+
InputChannel,
|
|
5
|
+
InputRequest,
|
|
6
|
+
UnknownInputError,
|
|
7
|
+
)
|
|
8
|
+
from sefia_typer._input import _to_input_text
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class TestToInputText:
|
|
12
|
+
def test_plain_string_is_stripped(self):
|
|
13
|
+
assert _to_input_text(" hello ") == "hello"
|
|
14
|
+
|
|
15
|
+
def test_list_is_joined_with_spaces(self):
|
|
16
|
+
assert _to_input_text(["hello", "world"]) == "hello world"
|
|
17
|
+
|
|
18
|
+
def test_list_result_is_stripped(self):
|
|
19
|
+
assert _to_input_text([" hello "]) == "hello"
|
|
20
|
+
|
|
21
|
+
def test_empty_list_is_empty_string(self):
|
|
22
|
+
assert _to_input_text([]) == ""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@pytest.fixture
|
|
26
|
+
def channel(kv_store):
|
|
27
|
+
channel = InputChannel()
|
|
28
|
+
with channel.use_store(kv_store):
|
|
29
|
+
yield channel
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestBinding:
|
|
33
|
+
async def test_requires_bound_store(self):
|
|
34
|
+
channel = InputChannel()
|
|
35
|
+
|
|
36
|
+
with pytest.raises(RuntimeError):
|
|
37
|
+
await channel.pending()
|
|
38
|
+
|
|
39
|
+
def test_empty_namespace_raises(self):
|
|
40
|
+
with pytest.raises(ValueError, match="namespace"):
|
|
41
|
+
InputChannel(namespace="/")
|
|
42
|
+
|
|
43
|
+
async def test_namespace_scopes_persisted_keys(self, kv_store):
|
|
44
|
+
channel = InputChannel(namespace="custom/input")
|
|
45
|
+
|
|
46
|
+
with channel.use_store(kv_store):
|
|
47
|
+
await channel.record_request("x", "why?")
|
|
48
|
+
await channel.receive_input("answer", reply_to="x")
|
|
49
|
+
|
|
50
|
+
assert "custom/input/pending" in kv_store._data
|
|
51
|
+
assert kv_store._data["custom/input/input/x"] == "answer"
|
|
52
|
+
assert "input_channel/pending" not in kv_store._data
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TestPending:
|
|
56
|
+
async def test_empty_by_default(self, channel):
|
|
57
|
+
assert await channel.pending() == []
|
|
58
|
+
|
|
59
|
+
async def test_recorded_request_is_pending(self, channel):
|
|
60
|
+
await channel.record_request("a", "prompt a?")
|
|
61
|
+
|
|
62
|
+
assert await channel.pending() == [
|
|
63
|
+
InputRequest(interaction_id="a", prompt="prompt a?")
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
async def test_pending_is_ordered_by_interaction_id(self, channel):
|
|
67
|
+
await channel.record_request("b", "prompt b?")
|
|
68
|
+
await channel.record_request("a", "prompt a?")
|
|
69
|
+
|
|
70
|
+
pending = await channel.pending()
|
|
71
|
+
|
|
72
|
+
assert [request.interaction_id for request in pending] == ["a", "b"]
|
|
73
|
+
|
|
74
|
+
async def test_resolved_requests_are_dropped_from_pending(self, channel):
|
|
75
|
+
await channel.record_request("a", "prompt a?")
|
|
76
|
+
await channel.record_request("b", "prompt b?")
|
|
77
|
+
|
|
78
|
+
await channel.receive_input("resolved", reply_to="a")
|
|
79
|
+
|
|
80
|
+
pending = await channel.pending()
|
|
81
|
+
assert [request.interaction_id for request in pending] == ["b"]
|
|
82
|
+
|
|
83
|
+
async def test_complete_request_removes_pending(self, channel):
|
|
84
|
+
await channel.record_request("x", "why?")
|
|
85
|
+
|
|
86
|
+
await channel.complete_request("x")
|
|
87
|
+
|
|
88
|
+
assert await channel.pending() == []
|
|
89
|
+
|
|
90
|
+
async def test_record_request_notifies(self, kv_store):
|
|
91
|
+
seen: list[InputRequest] = []
|
|
92
|
+
channel = InputChannel(on_request=seen.append)
|
|
93
|
+
|
|
94
|
+
with channel.use_store(kv_store):
|
|
95
|
+
await channel.record_request("x", "why?")
|
|
96
|
+
|
|
97
|
+
assert seen == [InputRequest(interaction_id="x", prompt="why?")]
|
|
98
|
+
|
|
99
|
+
async def test_prompt_delta_notifies(self, kv_store):
|
|
100
|
+
seen: list[str] = []
|
|
101
|
+
channel = InputChannel(on_prompt_delta=seen.append)
|
|
102
|
+
|
|
103
|
+
with channel.use_store(kv_store):
|
|
104
|
+
await channel.notify_prompt_delta("What ")
|
|
105
|
+
await channel.notify_prompt_delta("topic?")
|
|
106
|
+
|
|
107
|
+
assert seen == ["What ", "topic?"]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class TestReceiveInput:
|
|
111
|
+
async def test_none_input_is_ignored(self, channel):
|
|
112
|
+
await channel.receive_input(None)
|
|
113
|
+
|
|
114
|
+
assert await channel.provide_input("any") is None
|
|
115
|
+
|
|
116
|
+
async def test_blank_input_is_ignored(self, channel):
|
|
117
|
+
await channel.receive_input(" ")
|
|
118
|
+
|
|
119
|
+
assert await channel.provide_input("any") is None
|
|
120
|
+
|
|
121
|
+
async def test_list_input_is_joined(self, channel):
|
|
122
|
+
await channel.receive_input(["hello", "world"])
|
|
123
|
+
|
|
124
|
+
assert await channel.provide_input("any") == "hello world"
|
|
125
|
+
|
|
126
|
+
async def test_input_is_queued_when_nothing_pending(self, channel):
|
|
127
|
+
await channel.receive_input("hello")
|
|
128
|
+
|
|
129
|
+
assert await channel.provide_input("any") == "hello"
|
|
130
|
+
|
|
131
|
+
async def test_single_pending_request_is_resolved(self, channel):
|
|
132
|
+
await channel.record_request("only", "prompt?")
|
|
133
|
+
|
|
134
|
+
await channel.receive_input("the input")
|
|
135
|
+
|
|
136
|
+
assert await channel.provide_input("only") == "the input"
|
|
137
|
+
|
|
138
|
+
async def test_multiple_pending_requires_reply_to(self, channel):
|
|
139
|
+
await channel.record_request("a", "prompt a?")
|
|
140
|
+
await channel.record_request("b", "prompt b?")
|
|
141
|
+
|
|
142
|
+
with pytest.raises(AmbiguousInputError) as exc_info:
|
|
143
|
+
await channel.receive_input("ambiguous")
|
|
144
|
+
|
|
145
|
+
assert sorted(exc_info.value.interaction_ids) == ["a", "b"]
|
|
146
|
+
|
|
147
|
+
async def test_reply_to_targets_specific_request(self, channel):
|
|
148
|
+
await channel.record_request("a", "prompt a?")
|
|
149
|
+
await channel.record_request("b", "prompt b?")
|
|
150
|
+
|
|
151
|
+
await channel.receive_input("for b", reply_to="b")
|
|
152
|
+
|
|
153
|
+
assert await channel.provide_input("b") == "for b"
|
|
154
|
+
|
|
155
|
+
async def test_reply_to_unknown_request_raises(self, channel):
|
|
156
|
+
await channel.record_request("a", "prompt a?")
|
|
157
|
+
|
|
158
|
+
with pytest.raises(UnknownInputError) as exc_info:
|
|
159
|
+
await channel.receive_input("oops", reply_to="missing")
|
|
160
|
+
|
|
161
|
+
assert exc_info.value.interaction_id == "missing"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class TestProvideInput:
|
|
165
|
+
async def test_returns_none_when_nothing_available(self, channel):
|
|
166
|
+
assert await channel.provide_input("x") is None
|
|
167
|
+
|
|
168
|
+
async def test_queued_inputs_are_claimed_in_order(self, channel):
|
|
169
|
+
await channel.receive_input("first")
|
|
170
|
+
await channel.receive_input("second")
|
|
171
|
+
|
|
172
|
+
assert await channel.provide_input("i1") == "first"
|
|
173
|
+
assert await channel.provide_input("i2") == "second"
|
|
174
|
+
assert await channel.provide_input("i3") is None
|
|
175
|
+
|
|
176
|
+
async def test_does_not_claim_queue_with_other_pending(self, channel):
|
|
177
|
+
await channel.receive_input("queued")
|
|
178
|
+
await channel.record_request("other", "other prompt?")
|
|
179
|
+
|
|
180
|
+
assert await channel.provide_input("mine") is None
|
|
181
|
+
# The queued input is left for the pending request.
|
|
182
|
+
assert await channel.provide_input("other") == "queued"
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
|
|
3
|
+
from sefia.exceptions import InvalidInferenceResponseError
|
|
4
|
+
from sefia_typer import DefaultCLIReporter, InputRequest, OutputMessage
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
@dataclass(frozen=True)
|
|
8
|
+
class _StubResolvedSession:
|
|
9
|
+
session_id: str
|
|
10
|
+
source: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class TestDefaultCLIReporter:
|
|
14
|
+
def test_created_session_is_announced(self, capsys):
|
|
15
|
+
reporter = DefaultCLIReporter()
|
|
16
|
+
|
|
17
|
+
reporter.on_session_resolved(
|
|
18
|
+
_StubResolvedSession(session_id="abc", source="created")
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
assert "abc" in capsys.readouterr().out
|
|
22
|
+
|
|
23
|
+
def test_active_session_is_announced(self, capsys):
|
|
24
|
+
reporter = DefaultCLIReporter()
|
|
25
|
+
|
|
26
|
+
reporter.on_session_resolved(
|
|
27
|
+
_StubResolvedSession(session_id="abc", source="active")
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
assert "abc" in capsys.readouterr().out
|
|
31
|
+
|
|
32
|
+
def test_explicit_session_is_quiet(self, capsys):
|
|
33
|
+
reporter = DefaultCLIReporter()
|
|
34
|
+
|
|
35
|
+
reporter.on_session_resolved(
|
|
36
|
+
_StubResolvedSession(session_id="abc", source="explicit")
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
assert capsys.readouterr().out == ""
|
|
40
|
+
|
|
41
|
+
def test_input_request_includes_marker(self, capsys):
|
|
42
|
+
reporter = DefaultCLIReporter()
|
|
43
|
+
|
|
44
|
+
reporter.on_input_request(
|
|
45
|
+
InputRequest(interaction_id="xyz", prompt="What topic?")
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
output = capsys.readouterr().out
|
|
49
|
+
assert "INPUT_REQUIRED:xyz" in output
|
|
50
|
+
assert "What topic?" in output
|
|
51
|
+
|
|
52
|
+
def test_input_prompt_delta_is_printed_without_newline(self, capsys):
|
|
53
|
+
reporter = DefaultCLIReporter()
|
|
54
|
+
|
|
55
|
+
reporter.on_input_prompt_delta("What ")
|
|
56
|
+
reporter.on_input_prompt_delta("topic?")
|
|
57
|
+
|
|
58
|
+
assert capsys.readouterr().out == "What topic?"
|
|
59
|
+
|
|
60
|
+
def test_output_includes_marker_and_message(self, capsys):
|
|
61
|
+
reporter = DefaultCLIReporter()
|
|
62
|
+
|
|
63
|
+
reporter.on_output(OutputMessage(interaction_id="xyz", message="Hello there!"))
|
|
64
|
+
|
|
65
|
+
output = capsys.readouterr().out
|
|
66
|
+
assert "OUTPUT:xyz" in output
|
|
67
|
+
assert "Hello there!" in output
|
|
68
|
+
|
|
69
|
+
def test_output_message_delta_is_printed_without_newline(self, capsys):
|
|
70
|
+
reporter = DefaultCLIReporter()
|
|
71
|
+
|
|
72
|
+
reporter.on_output_message_delta("Hello ")
|
|
73
|
+
reporter.on_output_message_delta("there!")
|
|
74
|
+
|
|
75
|
+
assert capsys.readouterr().out == "Hello there!"
|
|
76
|
+
|
|
77
|
+
def test_interrupted_announces_waiting_state(self, capsys):
|
|
78
|
+
reporter = DefaultCLIReporter()
|
|
79
|
+
|
|
80
|
+
reporter.on_interrupted(_StubResolvedSession(session_id="abc", source="active"))
|
|
81
|
+
|
|
82
|
+
assert "WAITING FOR INPUT" in capsys.readouterr().out
|
|
83
|
+
|
|
84
|
+
def test_inference_error_is_reported_as_error(self, capsys):
|
|
85
|
+
reporter = DefaultCLIReporter()
|
|
86
|
+
|
|
87
|
+
reporter.on_inference_error(InvalidInferenceResponseError("bad model response"))
|
|
88
|
+
|
|
89
|
+
output = capsys.readouterr().out
|
|
90
|
+
assert "INFERENCE ERROR" in output
|
|
91
|
+
assert "bad model response" in output
|
|
92
|
+
assert "WAITING FOR INPUT" not in output
|