dkitchen 0.2.2__tar.gz → 0.3.1__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.
- {dkitchen-0.2.2/src/dkitchen.egg-info → dkitchen-0.3.1}/PKG-INFO +2 -1
- {dkitchen-0.2.2 → dkitchen-0.3.1}/README.md +1 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/pyproject.toml +1 -1
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen/config.py +40 -1
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen/ding.py +89 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1/src/dkitchen.egg-info}/PKG-INFO +2 -1
- {dkitchen-0.2.2 → dkitchen-0.3.1}/tests/test_codex_session.py +452 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/tests/test_kitchen.py +22 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/LICENSE +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/setup.cfg +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen/__init__.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen/orchestrate.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen/refs.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen/watch.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen.egg-info/SOURCES.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen.egg-info/dependency_links.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen.egg-info/entry_points.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen.egg-info/requires.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/src/dkitchen.egg-info/top_level.txt +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/tests/test_records.py +0 -0
- {dkitchen-0.2.2 → dkitchen-0.3.1}/tests/test_reference_scripts.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dkitchen
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
|
|
5
5
|
Author: 917Dhj
|
|
6
6
|
License-Expression: MIT
|
|
@@ -120,6 +120,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
|
|
|
120
120
|
`ding` passes messages between agent sessions and wakes a session when its long job ends.
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
|
+
ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
|
|
123
124
|
ding board # who is working on what, in this repo
|
|
124
125
|
ding send '#42' --from orchestrator --message-file note.md
|
|
125
126
|
ding launch train -- python train.py # run in tmux, wake me when it exits
|
|
@@ -88,6 +88,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
|
|
|
88
88
|
`ding` passes messages between agent sessions and wakes a session when its long job ends.
|
|
89
89
|
|
|
90
90
|
```bash
|
|
91
|
+
ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
|
|
91
92
|
ding board # who is working on what, in this repo
|
|
92
93
|
ding send '#42' --from orchestrator --message-file note.md
|
|
93
94
|
ding launch train -- python train.py # run in tmux, wake me when it exits
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "dkitchen"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -27,12 +27,16 @@ Every key is optional. The defaults match the English templates in kitchen/:
|
|
|
27
27
|
[log]
|
|
28
28
|
issue = 1 # the pinned orchestrator log ticket
|
|
29
29
|
pending = "Waiting for the user"
|
|
30
|
+
|
|
31
|
+
[sessions]
|
|
32
|
+
name = "【#{issue}】{name}" # `ding new --issue N --name NAME`; also {repo}
|
|
30
33
|
"""
|
|
31
34
|
from __future__ import annotations
|
|
32
35
|
|
|
33
36
|
from dataclasses import dataclass, fields as dataclass_fields, replace
|
|
34
37
|
from pathlib import Path
|
|
35
38
|
import re
|
|
39
|
+
import string
|
|
36
40
|
import subprocess
|
|
37
41
|
import sys
|
|
38
42
|
|
|
@@ -81,6 +85,38 @@ class Log:
|
|
|
81
85
|
pending: str = "Waiting for the user"
|
|
82
86
|
|
|
83
87
|
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Sessions:
|
|
90
|
+
name: str = "【#{issue}】{name}"
|
|
91
|
+
|
|
92
|
+
def session_name(self, *, issue: int, name: str, repo: str) -> str:
|
|
93
|
+
return self.name.format(issue=issue, name=name, repo=repo)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
SESSION_FIELDS = ("issue", "name", "repo")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def session_template(value: object) -> str:
|
|
100
|
+
"""A session name template using only SESSION_FIELDS, checked when the config is read."""
|
|
101
|
+
if not isinstance(value, str) or not value.strip():
|
|
102
|
+
raise ValueError(f"{FILE}: [sessions] name must be a nonempty string")
|
|
103
|
+
allowed = ", ".join(f"{{{field}}}" for field in SESSION_FIELDS)
|
|
104
|
+
try:
|
|
105
|
+
for _, field, spec, conversion in string.Formatter().parse(value):
|
|
106
|
+
if field is None:
|
|
107
|
+
continue
|
|
108
|
+
if field not in SESSION_FIELDS:
|
|
109
|
+
raise ValueError(f"{FILE}: [sessions] name uses {{{field}}}; use only {allowed}")
|
|
110
|
+
if conversion or "{" in spec:
|
|
111
|
+
raise ValueError(f"{FILE}: [sessions] name: write {{{field}}} without a conversion or nested field")
|
|
112
|
+
Sessions(value).session_name(issue=1, name="name", repo="owner/name")
|
|
113
|
+
except ValueError as exc:
|
|
114
|
+
if str(exc).startswith(FILE):
|
|
115
|
+
raise
|
|
116
|
+
raise ValueError(f"{FILE}: [sessions] name is not a valid template: {exc}") from exc
|
|
117
|
+
return value
|
|
118
|
+
|
|
119
|
+
|
|
84
120
|
@dataclass(frozen=True)
|
|
85
121
|
class Config:
|
|
86
122
|
root: Path
|
|
@@ -89,6 +125,7 @@ class Config:
|
|
|
89
125
|
status: Status = Status()
|
|
90
126
|
labels: Labels = Labels()
|
|
91
127
|
log: Log = Log()
|
|
128
|
+
sessions: Sessions = Sessions()
|
|
92
129
|
|
|
93
130
|
def repository(self) -> str:
|
|
94
131
|
"""The configured repository, or the one gh sees from the repository root."""
|
|
@@ -116,6 +153,8 @@ def section(cls, values: object, name: str):
|
|
|
116
153
|
if not isinstance(value, list) or not value or not all(isinstance(item, str) and item.strip() for item in value):
|
|
117
154
|
raise ValueError(f"{FILE}: [{name}] fields must be a nonempty list of field names")
|
|
118
155
|
value = tuple(item.strip() for item in value)
|
|
156
|
+
elif key == "name" and cls is Sessions:
|
|
157
|
+
value = session_template(value)
|
|
119
158
|
elif key == "issue":
|
|
120
159
|
if not isinstance(value, int) or isinstance(value, bool) or value <= 0:
|
|
121
160
|
raise ValueError(f"{FILE}: [{name}] issue must be a positive issue number")
|
|
@@ -141,7 +180,7 @@ def load(cwd: Path | None = None) -> Config:
|
|
|
141
180
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
142
181
|
except tomllib.TOMLDecodeError as exc:
|
|
143
182
|
raise ValueError(f"{path}: {exc}") from exc
|
|
144
|
-
sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log}
|
|
183
|
+
sections = {"stations": Stations, "status": Status, "labels": Labels, "log": Log, "sessions": Sessions}
|
|
145
184
|
unknown = sorted(set(data) - {"repo", *sections})
|
|
146
185
|
if unknown:
|
|
147
186
|
raise ValueError(f"{FILE}: unknown keys: {', '.join(unknown)}")
|
|
@@ -135,6 +135,16 @@ def read_status(call, thread_id: str) -> dict:
|
|
|
135
135
|
return status
|
|
136
136
|
|
|
137
137
|
|
|
138
|
+
def read_info(call, thread_id: str) -> dict:
|
|
139
|
+
"""A session's ID, name (None when unnamed), working directory, and status."""
|
|
140
|
+
result = call("thread/read", {"threadId": thread_id, "includeTurns": False})
|
|
141
|
+
thread = result.get("thread")
|
|
142
|
+
info = thread_metadata(thread)
|
|
143
|
+
if info["id"] != thread_id:
|
|
144
|
+
raise ValueError("App Server returned a different thread.")
|
|
145
|
+
return {**info, "name": thread.get("name") or None}
|
|
146
|
+
|
|
147
|
+
|
|
138
148
|
def read_reply(call, thread_id: str) -> str:
|
|
139
149
|
cursor = None
|
|
140
150
|
seen_cursors = set()
|
|
@@ -324,6 +334,11 @@ def github_repository() -> str:
|
|
|
324
334
|
return repo
|
|
325
335
|
|
|
326
336
|
|
|
337
|
+
def ownership_note(repo: str, number: int, thread_id: str) -> str:
|
|
338
|
+
return (f"You own ticket {repo}#{number}. Before anything else, post a comment on it "
|
|
339
|
+
f"with the line `Codex owner: {thread_id}`.\n\n")
|
|
340
|
+
|
|
341
|
+
|
|
327
342
|
def session_references(body: str | None, label: str = "session") -> list[str]:
|
|
328
343
|
if body is None:
|
|
329
344
|
return []
|
|
@@ -1162,6 +1177,46 @@ def message_text(message: str | None, message_file: str | None, sender: str | No
|
|
|
1162
1177
|
return message
|
|
1163
1178
|
|
|
1164
1179
|
|
|
1180
|
+
def new_task(args) -> int:
|
|
1181
|
+
thread_id = None
|
|
1182
|
+
stage = "validation"
|
|
1183
|
+
try:
|
|
1184
|
+
cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
|
|
1185
|
+
if cwd is None or not cwd.is_dir():
|
|
1186
|
+
raise ValueError("--cwd must name an existing directory.")
|
|
1187
|
+
message = message_text(args.message, args.message_file, None)
|
|
1188
|
+
if not message.strip():
|
|
1189
|
+
raise ValueError("The first message (--message or --message-file) must not be empty.")
|
|
1190
|
+
stage = "connection"
|
|
1191
|
+
with app_server(socket_path(args.socket)) as call:
|
|
1192
|
+
stage = "thread/start"
|
|
1193
|
+
thread = call(stage, {"cwd": str(cwd), "ephemeral": False}).get("thread")
|
|
1194
|
+
try:
|
|
1195
|
+
thread_id = str(UUID(thread["id"]))
|
|
1196
|
+
except (KeyError, ValueError, TypeError, AttributeError) as exc:
|
|
1197
|
+
raise ValueError("Malformed App Server thread ID.") from exc
|
|
1198
|
+
stage = "thread/name/set"
|
|
1199
|
+
call(stage, {"threadId": thread_id, "name": args.name})
|
|
1200
|
+
if args.issue is not None:
|
|
1201
|
+
message = ownership_note(args.repo, args.issue, thread_id) + message
|
|
1202
|
+
stage = "turn/start"
|
|
1203
|
+
turn = call(stage, {"threadId": thread_id,
|
|
1204
|
+
"input": [{"type": "text", "text": message}]}).get("turn")
|
|
1205
|
+
if (not isinstance(turn, dict) or not isinstance(turn.get("id"), str)
|
|
1206
|
+
or not turn["id"] or turn.get("status") not in ("inProgress", "completed")):
|
|
1207
|
+
raise ValueError("App Server did not return an accepted turn.")
|
|
1208
|
+
print(thread_id)
|
|
1209
|
+
return 0
|
|
1210
|
+
except (OSError, ValueError, RuntimeError, KeyboardInterrupt) as exc:
|
|
1211
|
+
recovery = ""
|
|
1212
|
+
if thread_id is not None:
|
|
1213
|
+
recovery = f"; session {thread_id}; inspect Desktop or ding board before retrying"
|
|
1214
|
+
elif stage == "thread/start":
|
|
1215
|
+
recovery = "; session creation is unconfirmed; inspect Desktop or ding board before retrying"
|
|
1216
|
+
print(f"ding: new {stage}: {str(exc) or type(exc).__name__}{recovery}", file=sys.stderr)
|
|
1217
|
+
return 130 if isinstance(exc, KeyboardInterrupt) else 1
|
|
1218
|
+
|
|
1219
|
+
|
|
1165
1220
|
def send_task(args) -> int:
|
|
1166
1221
|
state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
|
|
1167
1222
|
record = state / "ding/sent.jsonl"
|
|
@@ -1324,6 +1379,16 @@ def main(argv=None) -> int:
|
|
|
1324
1379
|
installed = "unknown"
|
|
1325
1380
|
parser.add_argument("--version", action="version", version=f"ding {installed}")
|
|
1326
1381
|
commands = parser.add_subparsers(dest="action", required=True)
|
|
1382
|
+
sub = commands.add_parser("new", help="Create a saved Codex session and start its first message")
|
|
1383
|
+
sub.add_argument("--cwd", required=True, help="Existing working directory for the session")
|
|
1384
|
+
sub.add_argument("--name", required=True, help="Session title; with --issue, filled into the "
|
|
1385
|
+
"[sessions] name template")
|
|
1386
|
+
sub.add_argument("--issue", type=int, help="Ticket this session owns: formats --name with the [sessions] "
|
|
1387
|
+
"name template and asks the session to post its Codex owner line")
|
|
1388
|
+
message = sub.add_mutually_exclusive_group(required=True)
|
|
1389
|
+
message.add_argument("--message", help="First message text, or '-' to read stdin")
|
|
1390
|
+
message.add_argument("--message-file", help="UTF-8 file holding the first message")
|
|
1391
|
+
sub.add_argument("--socket", help="Existing local App Server Unix socket path")
|
|
1327
1392
|
board = commands.add_parser("board", help="List sessions, run states, and local unwrapped tasks")
|
|
1328
1393
|
board.add_argument("--all", action="store_true", help="Include all non-archived sessions")
|
|
1329
1394
|
board.add_argument("--status", action="store_true", help="Show the latest executor comment status block")
|
|
@@ -1337,6 +1402,8 @@ def main(argv=None) -> int:
|
|
|
1337
1402
|
help="Minimum lifetime CPU percent per core outside tmux panes (default: 10)")
|
|
1338
1403
|
for action, summary in (("read", "Print a session's latest final reply"),
|
|
1339
1404
|
("status", "Print a session's App Server status as JSON"),
|
|
1405
|
+
("info", "Print a session's ID, name, working directory, and status as JSON"),
|
|
1406
|
+
("rename", "Change a session's name without starting a model turn"),
|
|
1340
1407
|
("send", "Send a message to a session"),
|
|
1341
1408
|
("run", "Run a command and message a session when it exits"),
|
|
1342
1409
|
("launch", "Run a command in a new tmux session and wake this Codex session when it exits")):
|
|
@@ -1350,6 +1417,8 @@ def main(argv=None) -> int:
|
|
|
1350
1417
|
sub.add_argument("--socket", help="Existing local App Server Unix socket path")
|
|
1351
1418
|
if action == "read":
|
|
1352
1419
|
sub.add_argument("--head", action="store_true", help="Print only the first paragraph of the final reply")
|
|
1420
|
+
if action == "rename":
|
|
1421
|
+
sub.add_argument("--name", required=True, help="New session name, preserved exactly as supplied")
|
|
1353
1422
|
if action in {"send", "run", "launch"}:
|
|
1354
1423
|
message = sub.add_mutually_exclusive_group(required=action != "launch")
|
|
1355
1424
|
message.add_argument("--message", help="Message text, or '-' to read stdin")
|
|
@@ -1402,11 +1471,22 @@ def main(argv=None) -> int:
|
|
|
1402
1471
|
command = argv[separator + 1:]
|
|
1403
1472
|
argv = argv[:separator]
|
|
1404
1473
|
args = parser.parse_args(argv)
|
|
1474
|
+
if args.action == "rename" and not args.name.strip():
|
|
1475
|
+
parser.error("--name must contain non-whitespace text")
|
|
1405
1476
|
if getattr(args, "issue", None) is not None and args.issue <= 0:
|
|
1406
1477
|
parser.error("--issue must be a positive issue number")
|
|
1407
1478
|
if args.action in {"run", "launch"} and not command:
|
|
1408
1479
|
parser.error(f"{args.action} requires a task command after --")
|
|
1409
1480
|
try:
|
|
1481
|
+
if args.action == "new":
|
|
1482
|
+
if not args.name.strip():
|
|
1483
|
+
parser.error("--name must contain non-whitespace text")
|
|
1484
|
+
if args.issue is not None and args.cwd and Path(args.cwd).expanduser().is_dir():
|
|
1485
|
+
from dkitchen import config
|
|
1486
|
+
settings = config.load(Path(args.cwd).expanduser().resolve())
|
|
1487
|
+
args.repo = settings.repository()
|
|
1488
|
+
args.name = settings.sessions.session_name(issue=args.issue, name=args.name, repo=args.repo)
|
|
1489
|
+
return new_task(args)
|
|
1410
1490
|
if args.action == "check-refs":
|
|
1411
1491
|
from dkitchen import refs
|
|
1412
1492
|
return refs.check_refs(args)
|
|
@@ -1442,6 +1522,15 @@ def main(argv=None) -> int:
|
|
|
1442
1522
|
if args.action == "run":
|
|
1443
1523
|
return run_task(args, command)
|
|
1444
1524
|
path = socket_path(args.socket)
|
|
1525
|
+
if args.action == "rename":
|
|
1526
|
+
with app_server(path) as call:
|
|
1527
|
+
call("thread/name/set", {"threadId": args.thread_id, "name": args.name})
|
|
1528
|
+
print(args.thread_id)
|
|
1529
|
+
return 0
|
|
1530
|
+
if args.action == "info":
|
|
1531
|
+
with app_server(path) as call:
|
|
1532
|
+
print(json.dumps(read_info(call, args.thread_id), ensure_ascii=False))
|
|
1533
|
+
return 0
|
|
1445
1534
|
result = query(args.thread_id, path, status_only=args.action == "status")
|
|
1446
1535
|
if args.action == "read" and args.head:
|
|
1447
1536
|
result = reply_head(result)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: dkitchen
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: A multi-agent workflow for research repos, with the ding CLI for messaging and waking agent sessions
|
|
5
5
|
Author: 917Dhj
|
|
6
6
|
License-Expression: MIT
|
|
@@ -120,6 +120,7 @@ A research repo has more resources than any single agent sees: GPUs on several s
|
|
|
120
120
|
`ding` passes messages between agent sessions and wakes a session when its long job ends.
|
|
121
121
|
|
|
122
122
|
```bash
|
|
123
|
+
ding new --cwd . --issue 42 --name 'Ablation' --message-file brief.md # start a Codex session for #42; prints its UUID
|
|
123
124
|
ding board # who is working on what, in this repo
|
|
124
125
|
ding send '#42' --from orchestrator --message-file note.md
|
|
125
126
|
ding launch train -- python train.py # run in tmux, wake me when it exits
|
|
@@ -252,6 +252,363 @@ def test_status_cli(capsys, monkeypatch):
|
|
|
252
252
|
assert json.loads(capsys.readouterr().out) == {"type": "active", "activeFlags": []}
|
|
253
253
|
|
|
254
254
|
|
|
255
|
+
@pytest.mark.parametrize("name, expected", [("【#57】消融实验", "【#57】消融实验"), (None, None), ("", None)])
|
|
256
|
+
def test_info_protocol_keeps_unnamed_sessions_unnamed(name, expected):
|
|
257
|
+
status = {"type": "idle", "activeFlags": []}
|
|
258
|
+
ws = Connection([{"result": {}}, {"result": {"thread": {
|
|
259
|
+
"id": THREAD_ID.upper(), "name": name, "cwd": "/work/worktree-57", "status": status, "turns": []}}}])
|
|
260
|
+
assert ding.read_info(ding.initialize(ws), THREAD_ID) == {
|
|
261
|
+
"id": THREAD_ID, "name": expected, "cwd": "/work/worktree-57", "status": status}
|
|
262
|
+
assert [r["method"] for r in ws.requests] == ["initialize", "initialized", "thread/read"]
|
|
263
|
+
assert ws.requests[-1]["params"] == {"threadId": THREAD_ID, "includeTurns": False}
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
@pytest.mark.parametrize("thread, error", [
|
|
267
|
+
(None, "Malformed App Server thread"),
|
|
268
|
+
({"id": "nope", "cwd": "/w", "status": {"type": "idle"}}, "thread ID"),
|
|
269
|
+
({"id": THREAD_ID, "name": 3, "cwd": "/w", "status": {"type": "idle"}}, "thread metadata"),
|
|
270
|
+
({"id": THREAD_ID, "cwd": "relative", "status": {"type": "idle"}}, "thread metadata"),
|
|
271
|
+
({"id": THREAD_ID, "cwd": "/w", "status": {}}, "thread metadata"),
|
|
272
|
+
({"id": OTHER_ID, "cwd": "/w", "status": {"type": "idle"}}, "different thread"),
|
|
273
|
+
])
|
|
274
|
+
def test_invalid_info(thread, error):
|
|
275
|
+
ws = Connection([{"result": {}}, {"result": {"thread": thread}}])
|
|
276
|
+
with pytest.raises(ValueError, match=error):
|
|
277
|
+
ding.read_info(ding.initialize(ws), THREAD_ID)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
@pytest.mark.parametrize("target", [["#42"], ["--issue", "42"], ["Build"]])
|
|
281
|
+
def test_info_cli_resolves_targets_and_prints_json(issue_data, monkeypatch, capsys, target):
|
|
282
|
+
calls = []
|
|
283
|
+
|
|
284
|
+
def call(method, params):
|
|
285
|
+
calls.append((method, params))
|
|
286
|
+
if method == "thread/list":
|
|
287
|
+
return {"data": [thread()]}
|
|
288
|
+
return {"thread": thread(name="【#42】构建", cwd="/work/repo")}
|
|
289
|
+
|
|
290
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
291
|
+
assert ding.main(["info", *target, "--socket", "/test.sock"]) == 0
|
|
292
|
+
assert calls[-1] == ("thread/read", {"threadId": THREAD_ID, "includeTurns": False})
|
|
293
|
+
assert all(method in {"thread/list", "thread/read"} for method, _ in calls)
|
|
294
|
+
captured = capsys.readouterr()
|
|
295
|
+
assert captured.out == '{"id": "%s", "name": "【#42】构建", "cwd": "/work/repo", ' \
|
|
296
|
+
'"status": {"type": "idle"}}\n' % THREAD_ID
|
|
297
|
+
assert captured.err == ""
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_info_cli_reports_failures(monkeypatch, capsys):
|
|
301
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(lambda *a: {"thread": None}))
|
|
302
|
+
assert ding.main(["info", THREAD_ID, "--socket", "/test.sock"]) == 1
|
|
303
|
+
captured = capsys.readouterr()
|
|
304
|
+
assert captured.out == ""
|
|
305
|
+
assert "Malformed App Server thread" in captured.err
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
@pytest.mark.parametrize("missing", ["--cwd", "--name", "--message"])
|
|
309
|
+
def test_new_requires_options(tmp_path, monkeypatch, capsys, missing):
|
|
310
|
+
monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
|
|
311
|
+
options = {"--cwd": str(tmp_path), "--name": "Build", "--message": "Start"}
|
|
312
|
+
argv = ["new"]
|
|
313
|
+
for flag, value in options.items():
|
|
314
|
+
if flag != missing:
|
|
315
|
+
argv.extend([flag, value])
|
|
316
|
+
with pytest.raises(SystemExit) as exc:
|
|
317
|
+
ding.main(argv)
|
|
318
|
+
assert exc.value.code == 2
|
|
319
|
+
assert missing in capsys.readouterr().err
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def test_new_rejects_message_and_message_file(tmp_path, monkeypatch, capsys):
|
|
323
|
+
monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
|
|
324
|
+
note = tmp_path / "note.md"
|
|
325
|
+
note.write_text("Start", encoding="utf-8")
|
|
326
|
+
with pytest.raises(SystemExit) as exc:
|
|
327
|
+
ding.main(["new", "--cwd", str(tmp_path), "--name", "Build",
|
|
328
|
+
"--message", "Start", "--message-file", str(note)])
|
|
329
|
+
assert exc.value.code == 2
|
|
330
|
+
assert "not allowed with argument" in capsys.readouterr().err
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
|
|
334
|
+
def test_new_rejects_blank_name_like_rename(tmp_path, monkeypatch, capsys, name):
|
|
335
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("blank name must not look up"))
|
|
336
|
+
with pytest.raises(SystemExit) as exc:
|
|
337
|
+
ding.main(["new", "--cwd", str(tmp_path), "--name", name, "--message", "Start"])
|
|
338
|
+
assert exc.value.code == 2
|
|
339
|
+
captured = capsys.readouterr()
|
|
340
|
+
assert captured.out == ""
|
|
341
|
+
assert "--name must contain non-whitespace text" in captured.err
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
@pytest.mark.parametrize("invalid", ["missing-dir", "file", "empty-cwd",
|
|
345
|
+
"blank-message", "empty-message", "blank-stdin", "empty-stdin",
|
|
346
|
+
"blank-file", "empty-file", "missing-file"])
|
|
347
|
+
def test_new_validates_before_socket_lookup(tmp_path, monkeypatch, capsys, invalid):
|
|
348
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must validate before socket lookup"))
|
|
349
|
+
monkeypatch.chdir(tmp_path)
|
|
350
|
+
cwd, message_option = str(tmp_path), ["--message", "Start"]
|
|
351
|
+
if invalid == "missing-dir":
|
|
352
|
+
cwd = str(tmp_path / "missing")
|
|
353
|
+
elif invalid == "file":
|
|
354
|
+
cwd = str(tmp_path / "file")
|
|
355
|
+
Path(cwd).touch()
|
|
356
|
+
elif invalid == "empty-cwd":
|
|
357
|
+
cwd = ""
|
|
358
|
+
else:
|
|
359
|
+
text = " \t\r\n" if invalid.startswith("blank") else ""
|
|
360
|
+
if invalid.endswith("stdin"):
|
|
361
|
+
monkeypatch.setattr(ding.sys, "stdin", io.StringIO(text))
|
|
362
|
+
message_option = ["--message", "-"]
|
|
363
|
+
elif invalid.endswith("file"):
|
|
364
|
+
note = tmp_path / "note.md"
|
|
365
|
+
if invalid != "missing-file":
|
|
366
|
+
note.write_text(text, encoding="utf-8")
|
|
367
|
+
message_option = ["--message-file", str(note)]
|
|
368
|
+
else:
|
|
369
|
+
message_option = ["--message", text]
|
|
370
|
+
assert ding.main(["new", "--cwd", cwd, "--name", "Build", *message_option]) == 1
|
|
371
|
+
captured = capsys.readouterr()
|
|
372
|
+
assert captured.out == ""
|
|
373
|
+
assert "new validation" in captured.err
|
|
374
|
+
assert ("--cwd" if invalid in {"missing-dir", "file", "empty-cwd"} else
|
|
375
|
+
"note.md" if invalid == "missing-file" else "--message") in captured.err
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def ticket_repo(tmp_path, template=None):
|
|
379
|
+
text = 'repo = "example/project"\n'
|
|
380
|
+
if template is not None:
|
|
381
|
+
text += f"[sessions]\nname = {json.dumps(template)}\n"
|
|
382
|
+
(tmp_path / ".dkitchen.toml").write_text(text, encoding="utf-8")
|
|
383
|
+
return tmp_path
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
@pytest.mark.parametrize("template, expected", [
|
|
387
|
+
(None, "【#57】Ablation {seed}"), ("{repo}#{issue:04d} {name}", "example/project#0057 Ablation {seed}"),
|
|
388
|
+
])
|
|
389
|
+
def test_new_issue_formats_name_and_asks_for_owner_line(tmp_path, monkeypatch, capsys, template, expected):
|
|
390
|
+
cwd = ticket_repo(tmp_path, template)
|
|
391
|
+
requests = []
|
|
392
|
+
|
|
393
|
+
def call(method, params):
|
|
394
|
+
requests.append((method, params))
|
|
395
|
+
return {"thread/start": {"thread": {"id": THREAD_ID}}, "thread/name/set": {},
|
|
396
|
+
"turn/start": {"turn": {"id": "turn-1", "status": "inProgress"}}}[method]
|
|
397
|
+
|
|
398
|
+
for function in ("gh_json", "gh_records", "github_repository"):
|
|
399
|
+
monkeypatch.setattr(ding, function, lambda *a: pytest.fail("must not read GitHub"))
|
|
400
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
401
|
+
assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Ablation {seed}",
|
|
402
|
+
"--message", "Brief", "--socket", "/test.sock"]) == 0
|
|
403
|
+
assert requests[1] == ("thread/name/set", {"threadId": THREAD_ID, "name": expected})
|
|
404
|
+
assert requests[2][1]["input"] == [{"type": "text", "text":
|
|
405
|
+
f"You own ticket example/project#57. Before anything else, post a comment on it "
|
|
406
|
+
f"with the line `Codex owner: {THREAD_ID}`.\n\nBrief"}]
|
|
407
|
+
assert capsys.readouterr().out == THREAD_ID + "\n"
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def test_new_issue_still_needs_a_name(tmp_path, monkeypatch, capsys):
|
|
411
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
|
|
412
|
+
with pytest.raises(SystemExit) as exc:
|
|
413
|
+
ding.main(["new", "--cwd", str(ticket_repo(tmp_path)), "--issue", "57", "--message", "Start"])
|
|
414
|
+
assert exc.value.code == 2
|
|
415
|
+
assert "--name" in capsys.readouterr().err
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@pytest.mark.parametrize("issue", ["0", "-3"])
|
|
419
|
+
def test_new_issue_must_be_positive(tmp_path, monkeypatch, capsys, issue):
|
|
420
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
|
|
421
|
+
with pytest.raises(SystemExit) as exc:
|
|
422
|
+
ding.main(["new", "--cwd", str(tmp_path), "--issue", issue, "--name", "Build", "--message", "Start"])
|
|
423
|
+
assert exc.value.code == 2
|
|
424
|
+
assert "positive issue number" in capsys.readouterr().err
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def test_new_issue_with_blank_name_is_refused_before_the_template(tmp_path, monkeypatch, capsys):
|
|
428
|
+
cwd = ticket_repo(tmp_path, "#{issue} {name}")
|
|
429
|
+
monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
|
|
430
|
+
with pytest.raises(SystemExit) as exc:
|
|
431
|
+
ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", " \t", "--message", "Start"])
|
|
432
|
+
assert exc.value.code == 2
|
|
433
|
+
assert "--name must contain non-whitespace text" in capsys.readouterr().err
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@pytest.mark.parametrize("failure", ["template", "repository"])
|
|
437
|
+
def test_new_issue_config_failure_creates_nothing(tmp_path, monkeypatch, capsys, failure):
|
|
438
|
+
if failure == "template":
|
|
439
|
+
cwd = ticket_repo(tmp_path, "{title}")
|
|
440
|
+
else:
|
|
441
|
+
cwd = tmp_path
|
|
442
|
+
real_run = subprocess.run
|
|
443
|
+
|
|
444
|
+
def gh_repo_view(argv, *args, **kwargs):
|
|
445
|
+
if argv[:3] == ["gh", "repo", "view"]:
|
|
446
|
+
return subprocess.CompletedProcess(argv, 1, "", "no git remotes found")
|
|
447
|
+
return real_run(argv, *args, **kwargs)
|
|
448
|
+
|
|
449
|
+
monkeypatch.setattr(subprocess, "run", gh_repo_view)
|
|
450
|
+
monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
|
|
451
|
+
assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Build", "--message", "Start"]) == 1
|
|
452
|
+
captured = capsys.readouterr()
|
|
453
|
+
assert captured.out == ""
|
|
454
|
+
assert ("{title}" if failure == "template" else "no git remotes found") in captured.err
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@pytest.mark.parametrize("source, status, explicit_socket", [
|
|
458
|
+
("argument", "inProgress", True), ("stdin", "completed", False), ("file", "inProgress", False),
|
|
459
|
+
])
|
|
460
|
+
def test_new_protocol_and_exact_text(tmp_path, monkeypatch, capsys, source, status, explicit_socket):
|
|
461
|
+
cwd = tmp_path / "project space" / "nested"
|
|
462
|
+
cwd.mkdir(parents=True)
|
|
463
|
+
(tmp_path / "work").symlink_to(cwd, target_is_directory=True)
|
|
464
|
+
monkeypatch.chdir(tmp_path)
|
|
465
|
+
monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
|
|
466
|
+
message = ' Review "quoted"; $(not-shell)\r\n世界\n'
|
|
467
|
+
name = " 检查\n配置 "
|
|
468
|
+
thread_id = "00000000-0000-4000-8000-0000000000ab"
|
|
469
|
+
requests, opened, closed = [], [], []
|
|
470
|
+
|
|
471
|
+
def call(method, params):
|
|
472
|
+
requests.append((method, params))
|
|
473
|
+
return {"thread/start": {"thread": {"id": "{" + thread_id.upper() + "}"}},
|
|
474
|
+
"thread/name/set": {}, "turn/start": {"turn": {"id": "turn-1", "status": status}}}[method]
|
|
475
|
+
|
|
476
|
+
@contextmanager
|
|
477
|
+
def server(path):
|
|
478
|
+
opened.append(path)
|
|
479
|
+
try:
|
|
480
|
+
yield call
|
|
481
|
+
finally:
|
|
482
|
+
closed.append(path)
|
|
483
|
+
|
|
484
|
+
def discover(directory):
|
|
485
|
+
assert not explicit_socket
|
|
486
|
+
assert directory == Path(f"/tmp/codex-daemon-{ding.os.getuid()}")
|
|
487
|
+
return "/test.sock"
|
|
488
|
+
|
|
489
|
+
monkeypatch.setattr(ding, "app_server", server)
|
|
490
|
+
monkeypatch.setattr(ding, "discover_socket", discover)
|
|
491
|
+
monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("new has no target"))
|
|
492
|
+
monkeypatch.setattr(ding, "deliver_message", lambda *a: pytest.fail("first message must not queue"))
|
|
493
|
+
monkeypatch.setattr(ding, "send_task", lambda *a: pytest.fail("first message must not use send"))
|
|
494
|
+
message_option = ["--message", message]
|
|
495
|
+
if source == "stdin":
|
|
496
|
+
monkeypatch.setattr(ding.sys, "stdin", io.StringIO(message))
|
|
497
|
+
message_option = ["--message", "-"]
|
|
498
|
+
elif source == "file":
|
|
499
|
+
(tmp_path / "note.md").write_bytes(message.encode("utf-8"))
|
|
500
|
+
message_option = ["--message-file", "note.md"]
|
|
501
|
+
options = ["--socket", "/test.sock"] if explicit_socket else []
|
|
502
|
+
assert ding.main(["new", "--cwd", "work", "--name", name, *message_option, *options]) == 0
|
|
503
|
+
assert requests == [
|
|
504
|
+
("thread/start", {"cwd": str(cwd), "ephemeral": False}),
|
|
505
|
+
("thread/name/set", {"threadId": thread_id, "name": name}),
|
|
506
|
+
("turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": message}]}),
|
|
507
|
+
]
|
|
508
|
+
assert opened == closed == ["/test.sock"]
|
|
509
|
+
captured = capsys.readouterr()
|
|
510
|
+
assert captured.out == thread_id + "\n"
|
|
511
|
+
assert captured.err == ""
|
|
512
|
+
assert not (tmp_path / "state/ding").exists()
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
@pytest.mark.parametrize("point", ["socket lookup", "connection"])
|
|
516
|
+
@pytest.mark.parametrize("error", [OSError("unavailable"), KeyboardInterrupt()])
|
|
517
|
+
def test_new_connection_failure_never_creates(tmp_path, monkeypatch, capsys, point, error):
|
|
518
|
+
def fail(*args):
|
|
519
|
+
raise error
|
|
520
|
+
|
|
521
|
+
monkeypatch.setattr(ding, "socket_path", fail if point == "socket lookup" else lambda *a: "/test.sock")
|
|
522
|
+
monkeypatch.setattr(ding, "app_server", fail if point == "connection" else
|
|
523
|
+
lambda *a: pytest.fail("socket lookup failed"))
|
|
524
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start"]) == (
|
|
525
|
+
130 if isinstance(error, KeyboardInterrupt) else 1)
|
|
526
|
+
captured = capsys.readouterr()
|
|
527
|
+
assert captured.out == ""
|
|
528
|
+
assert "new connection" in captured.err
|
|
529
|
+
assert "creation is unconfirmed" not in captured.err
|
|
530
|
+
assert THREAD_ID not in captured.err
|
|
531
|
+
|
|
532
|
+
|
|
533
|
+
@pytest.mark.parametrize("stage", ["thread/start", "thread/name/set", "turn/start"])
|
|
534
|
+
@pytest.mark.parametrize("error", [ding.AppServerError("request rejected"), TimeoutError("response lost"),
|
|
535
|
+
ding.AppServerUnavailable("connection closed"), KeyboardInterrupt()])
|
|
536
|
+
def test_new_failure_preserves_session_without_retry(tmp_path, monkeypatch, capsys, stage, error):
|
|
537
|
+
requests, connections = [], []
|
|
538
|
+
stages = ["thread/start", "thread/name/set", "turn/start"]
|
|
539
|
+
|
|
540
|
+
def call(method, params):
|
|
541
|
+
requests.append(method)
|
|
542
|
+
if method == stage:
|
|
543
|
+
raise error
|
|
544
|
+
return {"thread": {"id": THREAD_ID}}
|
|
545
|
+
|
|
546
|
+
def server(path):
|
|
547
|
+
connections.append(path)
|
|
548
|
+
return server_calls(call)
|
|
549
|
+
|
|
550
|
+
monkeypatch.setattr(ding, "app_server", server)
|
|
551
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
|
|
552
|
+
"--socket", "/test.sock"]) == (130 if isinstance(error, KeyboardInterrupt) else 1)
|
|
553
|
+
assert requests == stages[:stages.index(stage) + 1]
|
|
554
|
+
assert connections == ["/test.sock"]
|
|
555
|
+
captured = capsys.readouterr()
|
|
556
|
+
assert captured.out == ""
|
|
557
|
+
assert f"new {stage}" in captured.err
|
|
558
|
+
assert (str(error) or type(error).__name__) in captured.err
|
|
559
|
+
assert "inspect Desktop or ding board before retrying" in captured.err
|
|
560
|
+
if stage == "thread/start":
|
|
561
|
+
assert "session creation is unconfirmed" in captured.err
|
|
562
|
+
assert THREAD_ID not in captured.err
|
|
563
|
+
else:
|
|
564
|
+
assert f"session {THREAD_ID}" in captured.err
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@pytest.mark.parametrize("reply", [{}, {"thread": None}, {"thread": []}, {"thread": {}},
|
|
568
|
+
{"thread": {"id": None}}, {"thread": {"id": 7}},
|
|
569
|
+
{"thread": {"id": "invalid"}}])
|
|
570
|
+
def test_new_rejects_invalid_thread_before_naming(tmp_path, monkeypatch, capsys, reply):
|
|
571
|
+
requests = []
|
|
572
|
+
|
|
573
|
+
def call(method, params):
|
|
574
|
+
requests.append(method)
|
|
575
|
+
return reply
|
|
576
|
+
|
|
577
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
578
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
|
|
579
|
+
"--socket", "/test.sock"]) == 1
|
|
580
|
+
assert requests == ["thread/start"]
|
|
581
|
+
captured = capsys.readouterr()
|
|
582
|
+
assert captured.out == ""
|
|
583
|
+
assert "new thread/start: Malformed App Server thread ID" in captured.err
|
|
584
|
+
assert "session creation is unconfirmed" in captured.err
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
@pytest.mark.parametrize("turn", [None, [], {}, {"id": None, "status": "inProgress"},
|
|
588
|
+
{"id": "", "status": "inProgress"}, {"id": 7, "status": "inProgress"},
|
|
589
|
+
{"id": "turn-1"}, {"id": "turn-1", "status": "failed"},
|
|
590
|
+
{"id": "turn-1", "status": "interrupted"},
|
|
591
|
+
{"id": "turn-1", "status": "future-state"},
|
|
592
|
+
{"id": "turn-1", "status": {}}])
|
|
593
|
+
def test_new_rejects_unaccepted_turn_and_keeps_id(tmp_path, monkeypatch, capsys, turn):
|
|
594
|
+
requests = []
|
|
595
|
+
|
|
596
|
+
def call(method, params):
|
|
597
|
+
requests.append(method)
|
|
598
|
+
return {"thread/start": {"thread": {"id": THREAD_ID}},
|
|
599
|
+
"thread/name/set": {}, "turn/start": {"turn": turn}}[method]
|
|
600
|
+
|
|
601
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
602
|
+
assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
|
|
603
|
+
"--socket", "/test.sock"]) == 1
|
|
604
|
+
assert requests == ["thread/start", "thread/name/set", "turn/start"]
|
|
605
|
+
captured = capsys.readouterr()
|
|
606
|
+
assert captured.out == ""
|
|
607
|
+
assert "new turn/start: App Server did not return an accepted turn" in captured.err
|
|
608
|
+
assert f"session {THREAD_ID}" in captured.err
|
|
609
|
+
assert "inspect Desktop or ding board before retrying" in captured.err
|
|
610
|
+
|
|
611
|
+
|
|
255
612
|
def test_send_persists_intent_before_delivery(monkeypatch, tmp_path):
|
|
256
613
|
calls = []
|
|
257
614
|
message = 'text "quoted"; $(not-shell)\nnext'
|
|
@@ -2829,3 +3186,98 @@ def test_pane_representative_skips_empty_ancestor_command_and_breaks_ties_by_pid
|
|
|
2829
3186
|
panes = {10: {"pane_id": "%1", "session": "work", "window": "task"}}
|
|
2830
3187
|
assert [row["pid"] for row in ding.unwrapped_processes(
|
|
2831
3188
|
processes, panes, min_age=600, min_cpu=10, proc=tmp_path)] == [11]
|
|
3189
|
+
|
|
3190
|
+
|
|
3191
|
+
@pytest.mark.parametrize("arguments", [
|
|
3192
|
+
[], [THREAD_ID], ["--name", "New name"], [THREAD_ID, "--name"],
|
|
3193
|
+
[THREAD_ID, "--issue", "42", "--name", "New name"],
|
|
3194
|
+
])
|
|
3195
|
+
def test_rename_requires_one_target_and_name(monkeypatch, capsys, arguments):
|
|
3196
|
+
monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("invalid arguments must not resolve"))
|
|
3197
|
+
with pytest.raises(SystemExit) as exc:
|
|
3198
|
+
ding.main(["rename", *arguments])
|
|
3199
|
+
assert exc.value.code == 2
|
|
3200
|
+
assert capsys.readouterr().out == ""
|
|
3201
|
+
|
|
3202
|
+
|
|
3203
|
+
@pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
|
|
3204
|
+
@pytest.mark.parametrize("target", [["Build"], ["--issue", "42"]])
|
|
3205
|
+
def test_rename_rejects_blank_name_before_lookup(monkeypatch, capsys, name, target):
|
|
3206
|
+
for function in ("resolve_target", "socket_path", "app_server", "github_repository", "gh_json"):
|
|
3207
|
+
monkeypatch.setattr(ding, function, lambda *a: pytest.fail("blank name must not look up"))
|
|
3208
|
+
with pytest.raises(SystemExit) as exc:
|
|
3209
|
+
ding.main(["rename", *target, "--name", name])
|
|
3210
|
+
assert exc.value.code == 2
|
|
3211
|
+
captured = capsys.readouterr()
|
|
3212
|
+
assert captured.out == ""
|
|
3213
|
+
assert "--name must contain non-whitespace text" in captured.err
|
|
3214
|
+
|
|
3215
|
+
|
|
3216
|
+
@pytest.mark.parametrize("target,issue", [
|
|
3217
|
+
(THREAD_ID, None), ("Build", None), (None, 42), ("#42", None),
|
|
3218
|
+
("example/project#42", None), ("https://github.com/example/project/issues/42", None),
|
|
3219
|
+
])
|
|
3220
|
+
def test_rename_delegates_target_resolution(monkeypatch, capsys, target, issue):
|
|
3221
|
+
resolved = []
|
|
3222
|
+
calls = []
|
|
3223
|
+
|
|
3224
|
+
def resolve(value, number, path):
|
|
3225
|
+
resolved.append((value, number, path))
|
|
3226
|
+
return THREAD_ID
|
|
3227
|
+
|
|
3228
|
+
def call(method, params):
|
|
3229
|
+
calls.append((method, params))
|
|
3230
|
+
return {}
|
|
3231
|
+
|
|
3232
|
+
monkeypatch.setattr(ding, "resolve_target", resolve)
|
|
3233
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
3234
|
+
arguments = [target] if target is not None else ["--issue", str(issue)]
|
|
3235
|
+
assert ding.main(["rename", *arguments, "--name", "New name", "--socket", "/test.sock"]) == 0
|
|
3236
|
+
assert resolved == [(target, issue, "/test.sock")]
|
|
3237
|
+
assert calls == [("thread/name/set", {"threadId": THREAD_ID, "name": "New name"})]
|
|
3238
|
+
captured = capsys.readouterr()
|
|
3239
|
+
assert captured.out == THREAD_ID + "\n"
|
|
3240
|
+
assert captured.err == ""
|
|
3241
|
+
|
|
3242
|
+
|
|
3243
|
+
@pytest.mark.parametrize("reply,status,error", [
|
|
3244
|
+
({"result": {}}, 0, None),
|
|
3245
|
+
({"error": {"code": -32602, "message": "name refused"}}, 1, "name refused"),
|
|
3246
|
+
("", 1, "closed the connection"),
|
|
3247
|
+
({"result": None}, 1, "Malformed App Server result"),
|
|
3248
|
+
])
|
|
3249
|
+
def test_rename_rpc_acknowledgement_and_failure(tmp_path, monkeypatch, capsys, reply, status, error):
|
|
3250
|
+
websocket = pytest.importorskip("websocket")
|
|
3251
|
+
ws = Connection([{"result": {}}, reply])
|
|
3252
|
+
monkeypatch.setattr(websocket, "create_connection", lambda *a, **kw: ws)
|
|
3253
|
+
name = ' 评测 "quoted" $(not-shell)\nnext '
|
|
3254
|
+
path = str(tmp_path / "server")
|
|
3255
|
+
with socket.socket(socket.AF_UNIX) as server:
|
|
3256
|
+
server.bind(path)
|
|
3257
|
+
server.listen(1)
|
|
3258
|
+
assert ding.main(["rename", "{" + THREAD_ID + "}", "--name", name, "--socket", path]) == status
|
|
3259
|
+
captured = capsys.readouterr()
|
|
3260
|
+
assert captured.out == (THREAD_ID + "\n" if status == 0 else "")
|
|
3261
|
+
assert (error in captured.err) if error else captured.err == ""
|
|
3262
|
+
assert [request["method"] for request in ws.requests] == ["initialize", "initialized", "thread/name/set"]
|
|
3263
|
+
assert ws.requests[-1]["params"] == {"threadId": THREAD_ID, "name": name}
|
|
3264
|
+
assert ws.closed
|
|
3265
|
+
|
|
3266
|
+
|
|
3267
|
+
@pytest.mark.parametrize("target", ["Build", "#42"])
|
|
3268
|
+
def test_rename_refuses_ambiguous_targets(issue_data, monkeypatch, capsys, target):
|
|
3269
|
+
issue, comments = issue_data
|
|
3270
|
+
comments.append({"body": f"Codex session: {OTHER_ID}"})
|
|
3271
|
+
calls = []
|
|
3272
|
+
|
|
3273
|
+
def call(method, params):
|
|
3274
|
+
calls.append(method)
|
|
3275
|
+
assert method == "thread/list"
|
|
3276
|
+
return {"data": [thread(), thread(OTHER_ID, "Build")]}
|
|
3277
|
+
|
|
3278
|
+
monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
|
|
3279
|
+
assert ding.main(["rename", target, "--name", "New name", "--socket", "/test.sock"]) == 1
|
|
3280
|
+
captured = capsys.readouterr()
|
|
3281
|
+
assert captured.out == ""
|
|
3282
|
+
assert THREAD_ID in captured.err and OTHER_ID in captured.err
|
|
3283
|
+
assert calls == ["thread/list"]
|
|
@@ -70,6 +70,28 @@ def test_bad_config_is_refused_with_its_key(tmp_path, text, error):
|
|
|
70
70
|
settings(tmp_path, text)
|
|
71
71
|
|
|
72
72
|
|
|
73
|
+
def test_session_name_template_defaults_and_fills_names_verbatim(tmp_path):
|
|
74
|
+
assert settings(tmp_path).sessions.session_name(issue=57, name="Fix {x} 100%", repo="o/r") == \
|
|
75
|
+
"【#57】Fix {x} 100%"
|
|
76
|
+
loaded = settings(tmp_path, '[sessions]\nname = "{repo}#{issue:04d} {name}"')
|
|
77
|
+
assert loaded.sessions.session_name(issue=7, name="Plot", repo="o/r") == "o/r#0007 Plot"
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.parametrize("template, error", [
|
|
81
|
+
("", "nonempty string"),
|
|
82
|
+
("{issue} {title}", r"uses \{title\}; use only \{issue\}, \{name\}, \{repo\}"),
|
|
83
|
+
("{} {name}", r"uses \{\}"),
|
|
84
|
+
("{name.upper}", r"uses \{name.upper\}"),
|
|
85
|
+
("{name!r}", "without a conversion"),
|
|
86
|
+
("{name", "not a valid template"),
|
|
87
|
+
("{name:d}", "not a valid template"),
|
|
88
|
+
("{issue:{name}}", "nested field"),
|
|
89
|
+
])
|
|
90
|
+
def test_bad_session_name_template_is_refused_on_load(tmp_path, template, error):
|
|
91
|
+
with pytest.raises(ValueError, match=error):
|
|
92
|
+
settings(tmp_path, f"[sessions]\nname = {json.dumps(template)}")
|
|
93
|
+
|
|
94
|
+
|
|
73
95
|
def test_board_status_falls_back_to_the_config_fields(tmp_path, monkeypatch, capsys):
|
|
74
96
|
monkeypatch.chdir(tmp_path)
|
|
75
97
|
(tmp_path / config.FILE).write_text(CHINESE, encoding="utf-8")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|