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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dkitchen
3
- Version: 0.2.2
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.2.2"
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.2.2
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