dkitchen 0.2.2__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dkitchen
3
- Version: 0.2.2
3
+ Version: 0.3.0
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.0"
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)}")
@@ -324,6 +324,11 @@ def github_repository() -> str:
324
324
  return repo
325
325
 
326
326
 
327
+ def ownership_note(repo: str, number: int, thread_id: str) -> str:
328
+ return (f"You own ticket {repo}#{number}. Before anything else, post a comment on it "
329
+ f"with the line `Codex owner: {thread_id}`.\n\n")
330
+
331
+
327
332
  def session_references(body: str | None, label: str = "session") -> list[str]:
328
333
  if body is None:
329
334
  return []
@@ -1162,6 +1167,46 @@ def message_text(message: str | None, message_file: str | None, sender: str | No
1162
1167
  return message
1163
1168
 
1164
1169
 
1170
+ def new_task(args) -> int:
1171
+ thread_id = None
1172
+ stage = "validation"
1173
+ try:
1174
+ cwd = Path(args.cwd).expanduser().resolve() if args.cwd else None
1175
+ if cwd is None or not cwd.is_dir():
1176
+ raise ValueError("--cwd must name an existing directory.")
1177
+ message = message_text(args.message, args.message_file, None)
1178
+ if not message.strip():
1179
+ raise ValueError("The first message (--message or --message-file) must not be empty.")
1180
+ stage = "connection"
1181
+ with app_server(socket_path(args.socket)) as call:
1182
+ stage = "thread/start"
1183
+ thread = call(stage, {"cwd": str(cwd), "ephemeral": False}).get("thread")
1184
+ try:
1185
+ thread_id = str(UUID(thread["id"]))
1186
+ except (KeyError, ValueError, TypeError, AttributeError) as exc:
1187
+ raise ValueError("Malformed App Server thread ID.") from exc
1188
+ stage = "thread/name/set"
1189
+ call(stage, {"threadId": thread_id, "name": args.name})
1190
+ if args.issue is not None:
1191
+ message = ownership_note(args.repo, args.issue, thread_id) + message
1192
+ stage = "turn/start"
1193
+ turn = call(stage, {"threadId": thread_id,
1194
+ "input": [{"type": "text", "text": message}]}).get("turn")
1195
+ if (not isinstance(turn, dict) or not isinstance(turn.get("id"), str)
1196
+ or not turn["id"] or turn.get("status") not in ("inProgress", "completed")):
1197
+ raise ValueError("App Server did not return an accepted turn.")
1198
+ print(thread_id)
1199
+ return 0
1200
+ except (OSError, ValueError, RuntimeError, KeyboardInterrupt) as exc:
1201
+ recovery = ""
1202
+ if thread_id is not None:
1203
+ recovery = f"; session {thread_id}; inspect Desktop or ding board before retrying"
1204
+ elif stage == "thread/start":
1205
+ recovery = "; session creation is unconfirmed; inspect Desktop or ding board before retrying"
1206
+ print(f"ding: new {stage}: {str(exc) or type(exc).__name__}{recovery}", file=sys.stderr)
1207
+ return 130 if isinstance(exc, KeyboardInterrupt) else 1
1208
+
1209
+
1165
1210
  def send_task(args) -> int:
1166
1211
  state = Path(os.environ.get("XDG_STATE_HOME") or Path.home() / ".local/state")
1167
1212
  record = state / "ding/sent.jsonl"
@@ -1324,6 +1369,16 @@ def main(argv=None) -> int:
1324
1369
  installed = "unknown"
1325
1370
  parser.add_argument("--version", action="version", version=f"ding {installed}")
1326
1371
  commands = parser.add_subparsers(dest="action", required=True)
1372
+ sub = commands.add_parser("new", help="Create a saved Codex session and start its first message")
1373
+ sub.add_argument("--cwd", required=True, help="Existing working directory for the session")
1374
+ sub.add_argument("--name", required=True, help="Session title; with --issue, filled into the "
1375
+ "[sessions] name template")
1376
+ sub.add_argument("--issue", type=int, help="Ticket this session owns: formats --name with the [sessions] "
1377
+ "name template and asks the session to post its Codex owner line")
1378
+ message = sub.add_mutually_exclusive_group(required=True)
1379
+ message.add_argument("--message", help="First message text, or '-' to read stdin")
1380
+ message.add_argument("--message-file", help="UTF-8 file holding the first message")
1381
+ sub.add_argument("--socket", help="Existing local App Server Unix socket path")
1327
1382
  board = commands.add_parser("board", help="List sessions, run states, and local unwrapped tasks")
1328
1383
  board.add_argument("--all", action="store_true", help="Include all non-archived sessions")
1329
1384
  board.add_argument("--status", action="store_true", help="Show the latest executor comment status block")
@@ -1337,6 +1392,7 @@ def main(argv=None) -> int:
1337
1392
  help="Minimum lifetime CPU percent per core outside tmux panes (default: 10)")
1338
1393
  for action, summary in (("read", "Print a session's latest final reply"),
1339
1394
  ("status", "Print a session's App Server status as JSON"),
1395
+ ("rename", "Change a session's name without starting a model turn"),
1340
1396
  ("send", "Send a message to a session"),
1341
1397
  ("run", "Run a command and message a session when it exits"),
1342
1398
  ("launch", "Run a command in a new tmux session and wake this Codex session when it exits")):
@@ -1350,6 +1406,8 @@ def main(argv=None) -> int:
1350
1406
  sub.add_argument("--socket", help="Existing local App Server Unix socket path")
1351
1407
  if action == "read":
1352
1408
  sub.add_argument("--head", action="store_true", help="Print only the first paragraph of the final reply")
1409
+ if action == "rename":
1410
+ sub.add_argument("--name", required=True, help="New session name, preserved exactly as supplied")
1353
1411
  if action in {"send", "run", "launch"}:
1354
1412
  message = sub.add_mutually_exclusive_group(required=action != "launch")
1355
1413
  message.add_argument("--message", help="Message text, or '-' to read stdin")
@@ -1402,11 +1460,22 @@ def main(argv=None) -> int:
1402
1460
  command = argv[separator + 1:]
1403
1461
  argv = argv[:separator]
1404
1462
  args = parser.parse_args(argv)
1463
+ if args.action == "rename" and not args.name.strip():
1464
+ parser.error("--name must contain non-whitespace text")
1405
1465
  if getattr(args, "issue", None) is not None and args.issue <= 0:
1406
1466
  parser.error("--issue must be a positive issue number")
1407
1467
  if args.action in {"run", "launch"} and not command:
1408
1468
  parser.error(f"{args.action} requires a task command after --")
1409
1469
  try:
1470
+ if args.action == "new":
1471
+ if not args.name.strip():
1472
+ parser.error("--name must contain non-whitespace text")
1473
+ if args.issue is not None and args.cwd and Path(args.cwd).expanduser().is_dir():
1474
+ from dkitchen import config
1475
+ settings = config.load(Path(args.cwd).expanduser().resolve())
1476
+ args.repo = settings.repository()
1477
+ args.name = settings.sessions.session_name(issue=args.issue, name=args.name, repo=args.repo)
1478
+ return new_task(args)
1410
1479
  if args.action == "check-refs":
1411
1480
  from dkitchen import refs
1412
1481
  return refs.check_refs(args)
@@ -1442,6 +1511,11 @@ def main(argv=None) -> int:
1442
1511
  if args.action == "run":
1443
1512
  return run_task(args, command)
1444
1513
  path = socket_path(args.socket)
1514
+ if args.action == "rename":
1515
+ with app_server(path) as call:
1516
+ call("thread/name/set", {"threadId": args.thread_id, "name": args.name})
1517
+ print(args.thread_id)
1518
+ return 0
1445
1519
  result = query(args.thread_id, path, status_only=args.action == "status")
1446
1520
  if args.action == "read" and args.head:
1447
1521
  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.0
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,310 @@ 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("missing", ["--cwd", "--name", "--message"])
256
+ def test_new_requires_options(tmp_path, monkeypatch, capsys, missing):
257
+ monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
258
+ options = {"--cwd": str(tmp_path), "--name": "Build", "--message": "Start"}
259
+ argv = ["new"]
260
+ for flag, value in options.items():
261
+ if flag != missing:
262
+ argv.extend([flag, value])
263
+ with pytest.raises(SystemExit) as exc:
264
+ ding.main(argv)
265
+ assert exc.value.code == 2
266
+ assert missing in capsys.readouterr().err
267
+
268
+
269
+ def test_new_rejects_message_and_message_file(tmp_path, monkeypatch, capsys):
270
+ monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
271
+ note = tmp_path / "note.md"
272
+ note.write_text("Start", encoding="utf-8")
273
+ with pytest.raises(SystemExit) as exc:
274
+ ding.main(["new", "--cwd", str(tmp_path), "--name", "Build",
275
+ "--message", "Start", "--message-file", str(note)])
276
+ assert exc.value.code == 2
277
+ assert "not allowed with argument" in capsys.readouterr().err
278
+
279
+
280
+ @pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
281
+ def test_new_rejects_blank_name_like_rename(tmp_path, monkeypatch, capsys, name):
282
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("blank name must not look up"))
283
+ with pytest.raises(SystemExit) as exc:
284
+ ding.main(["new", "--cwd", str(tmp_path), "--name", name, "--message", "Start"])
285
+ assert exc.value.code == 2
286
+ captured = capsys.readouterr()
287
+ assert captured.out == ""
288
+ assert "--name must contain non-whitespace text" in captured.err
289
+
290
+
291
+ @pytest.mark.parametrize("invalid", ["missing-dir", "file", "empty-cwd",
292
+ "blank-message", "empty-message", "blank-stdin", "empty-stdin",
293
+ "blank-file", "empty-file", "missing-file"])
294
+ def test_new_validates_before_socket_lookup(tmp_path, monkeypatch, capsys, invalid):
295
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must validate before socket lookup"))
296
+ monkeypatch.chdir(tmp_path)
297
+ cwd, message_option = str(tmp_path), ["--message", "Start"]
298
+ if invalid == "missing-dir":
299
+ cwd = str(tmp_path / "missing")
300
+ elif invalid == "file":
301
+ cwd = str(tmp_path / "file")
302
+ Path(cwd).touch()
303
+ elif invalid == "empty-cwd":
304
+ cwd = ""
305
+ else:
306
+ text = " \t\r\n" if invalid.startswith("blank") else ""
307
+ if invalid.endswith("stdin"):
308
+ monkeypatch.setattr(ding.sys, "stdin", io.StringIO(text))
309
+ message_option = ["--message", "-"]
310
+ elif invalid.endswith("file"):
311
+ note = tmp_path / "note.md"
312
+ if invalid != "missing-file":
313
+ note.write_text(text, encoding="utf-8")
314
+ message_option = ["--message-file", str(note)]
315
+ else:
316
+ message_option = ["--message", text]
317
+ assert ding.main(["new", "--cwd", cwd, "--name", "Build", *message_option]) == 1
318
+ captured = capsys.readouterr()
319
+ assert captured.out == ""
320
+ assert "new validation" in captured.err
321
+ assert ("--cwd" if invalid in {"missing-dir", "file", "empty-cwd"} else
322
+ "note.md" if invalid == "missing-file" else "--message") in captured.err
323
+
324
+
325
+ def ticket_repo(tmp_path, template=None):
326
+ text = 'repo = "example/project"\n'
327
+ if template is not None:
328
+ text += f"[sessions]\nname = {json.dumps(template)}\n"
329
+ (tmp_path / ".dkitchen.toml").write_text(text, encoding="utf-8")
330
+ return tmp_path
331
+
332
+
333
+ @pytest.mark.parametrize("template, expected", [
334
+ (None, "【#57】Ablation {seed}"), ("{repo}#{issue:04d} {name}", "example/project#0057 Ablation {seed}"),
335
+ ])
336
+ def test_new_issue_formats_name_and_asks_for_owner_line(tmp_path, monkeypatch, capsys, template, expected):
337
+ cwd = ticket_repo(tmp_path, template)
338
+ requests = []
339
+
340
+ def call(method, params):
341
+ requests.append((method, params))
342
+ return {"thread/start": {"thread": {"id": THREAD_ID}}, "thread/name/set": {},
343
+ "turn/start": {"turn": {"id": "turn-1", "status": "inProgress"}}}[method]
344
+
345
+ for function in ("gh_json", "gh_records", "github_repository"):
346
+ monkeypatch.setattr(ding, function, lambda *a: pytest.fail("must not read GitHub"))
347
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
348
+ assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Ablation {seed}",
349
+ "--message", "Brief", "--socket", "/test.sock"]) == 0
350
+ assert requests[1] == ("thread/name/set", {"threadId": THREAD_ID, "name": expected})
351
+ assert requests[2][1]["input"] == [{"type": "text", "text":
352
+ f"You own ticket example/project#57. Before anything else, post a comment on it "
353
+ f"with the line `Codex owner: {THREAD_ID}`.\n\nBrief"}]
354
+ assert capsys.readouterr().out == THREAD_ID + "\n"
355
+
356
+
357
+ def test_new_issue_still_needs_a_name(tmp_path, monkeypatch, capsys):
358
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
359
+ with pytest.raises(SystemExit) as exc:
360
+ ding.main(["new", "--cwd", str(ticket_repo(tmp_path)), "--issue", "57", "--message", "Start"])
361
+ assert exc.value.code == 2
362
+ assert "--name" in capsys.readouterr().err
363
+
364
+
365
+ @pytest.mark.parametrize("issue", ["0", "-3"])
366
+ def test_new_issue_must_be_positive(tmp_path, monkeypatch, capsys, issue):
367
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
368
+ with pytest.raises(SystemExit) as exc:
369
+ ding.main(["new", "--cwd", str(tmp_path), "--issue", issue, "--name", "Build", "--message", "Start"])
370
+ assert exc.value.code == 2
371
+ assert "positive issue number" in capsys.readouterr().err
372
+
373
+
374
+ def test_new_issue_with_blank_name_is_refused_before_the_template(tmp_path, monkeypatch, capsys):
375
+ cwd = ticket_repo(tmp_path, "#{issue} {name}")
376
+ monkeypatch.setattr(ding, "socket_path", lambda *a: pytest.fail("must not look up a socket"))
377
+ with pytest.raises(SystemExit) as exc:
378
+ ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", " \t", "--message", "Start"])
379
+ assert exc.value.code == 2
380
+ assert "--name must contain non-whitespace text" in capsys.readouterr().err
381
+
382
+
383
+ @pytest.mark.parametrize("failure", ["template", "repository"])
384
+ def test_new_issue_config_failure_creates_nothing(tmp_path, monkeypatch, capsys, failure):
385
+ if failure == "template":
386
+ cwd = ticket_repo(tmp_path, "{title}")
387
+ else:
388
+ cwd = tmp_path
389
+ real_run = subprocess.run
390
+
391
+ def gh_repo_view(argv, *args, **kwargs):
392
+ if argv[:3] == ["gh", "repo", "view"]:
393
+ return subprocess.CompletedProcess(argv, 1, "", "no git remotes found")
394
+ return real_run(argv, *args, **kwargs)
395
+
396
+ monkeypatch.setattr(subprocess, "run", gh_repo_view)
397
+ monkeypatch.setattr(ding, "app_server", lambda *a: pytest.fail("must not connect"))
398
+ assert ding.main(["new", "--cwd", str(cwd), "--issue", "57", "--name", "Build", "--message", "Start"]) == 1
399
+ captured = capsys.readouterr()
400
+ assert captured.out == ""
401
+ assert ("{title}" if failure == "template" else "no git remotes found") in captured.err
402
+
403
+
404
+ @pytest.mark.parametrize("source, status, explicit_socket", [
405
+ ("argument", "inProgress", True), ("stdin", "completed", False), ("file", "inProgress", False),
406
+ ])
407
+ def test_new_protocol_and_exact_text(tmp_path, monkeypatch, capsys, source, status, explicit_socket):
408
+ cwd = tmp_path / "project space" / "nested"
409
+ cwd.mkdir(parents=True)
410
+ (tmp_path / "work").symlink_to(cwd, target_is_directory=True)
411
+ monkeypatch.chdir(tmp_path)
412
+ monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state"))
413
+ message = ' Review "quoted"; $(not-shell)\r\n世界\n'
414
+ name = " 检查\n配置 "
415
+ thread_id = "00000000-0000-4000-8000-0000000000ab"
416
+ requests, opened, closed = [], [], []
417
+
418
+ def call(method, params):
419
+ requests.append((method, params))
420
+ return {"thread/start": {"thread": {"id": "{" + thread_id.upper() + "}"}},
421
+ "thread/name/set": {}, "turn/start": {"turn": {"id": "turn-1", "status": status}}}[method]
422
+
423
+ @contextmanager
424
+ def server(path):
425
+ opened.append(path)
426
+ try:
427
+ yield call
428
+ finally:
429
+ closed.append(path)
430
+
431
+ def discover(directory):
432
+ assert not explicit_socket
433
+ assert directory == Path(f"/tmp/codex-daemon-{ding.os.getuid()}")
434
+ return "/test.sock"
435
+
436
+ monkeypatch.setattr(ding, "app_server", server)
437
+ monkeypatch.setattr(ding, "discover_socket", discover)
438
+ monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("new has no target"))
439
+ monkeypatch.setattr(ding, "deliver_message", lambda *a: pytest.fail("first message must not queue"))
440
+ monkeypatch.setattr(ding, "send_task", lambda *a: pytest.fail("first message must not use send"))
441
+ message_option = ["--message", message]
442
+ if source == "stdin":
443
+ monkeypatch.setattr(ding.sys, "stdin", io.StringIO(message))
444
+ message_option = ["--message", "-"]
445
+ elif source == "file":
446
+ (tmp_path / "note.md").write_bytes(message.encode("utf-8"))
447
+ message_option = ["--message-file", "note.md"]
448
+ options = ["--socket", "/test.sock"] if explicit_socket else []
449
+ assert ding.main(["new", "--cwd", "work", "--name", name, *message_option, *options]) == 0
450
+ assert requests == [
451
+ ("thread/start", {"cwd": str(cwd), "ephemeral": False}),
452
+ ("thread/name/set", {"threadId": thread_id, "name": name}),
453
+ ("turn/start", {"threadId": thread_id, "input": [{"type": "text", "text": message}]}),
454
+ ]
455
+ assert opened == closed == ["/test.sock"]
456
+ captured = capsys.readouterr()
457
+ assert captured.out == thread_id + "\n"
458
+ assert captured.err == ""
459
+ assert not (tmp_path / "state/ding").exists()
460
+
461
+
462
+ @pytest.mark.parametrize("point", ["socket lookup", "connection"])
463
+ @pytest.mark.parametrize("error", [OSError("unavailable"), KeyboardInterrupt()])
464
+ def test_new_connection_failure_never_creates(tmp_path, monkeypatch, capsys, point, error):
465
+ def fail(*args):
466
+ raise error
467
+
468
+ monkeypatch.setattr(ding, "socket_path", fail if point == "socket lookup" else lambda *a: "/test.sock")
469
+ monkeypatch.setattr(ding, "app_server", fail if point == "connection" else
470
+ lambda *a: pytest.fail("socket lookup failed"))
471
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start"]) == (
472
+ 130 if isinstance(error, KeyboardInterrupt) else 1)
473
+ captured = capsys.readouterr()
474
+ assert captured.out == ""
475
+ assert "new connection" in captured.err
476
+ assert "creation is unconfirmed" not in captured.err
477
+ assert THREAD_ID not in captured.err
478
+
479
+
480
+ @pytest.mark.parametrize("stage", ["thread/start", "thread/name/set", "turn/start"])
481
+ @pytest.mark.parametrize("error", [ding.AppServerError("request rejected"), TimeoutError("response lost"),
482
+ ding.AppServerUnavailable("connection closed"), KeyboardInterrupt()])
483
+ def test_new_failure_preserves_session_without_retry(tmp_path, monkeypatch, capsys, stage, error):
484
+ requests, connections = [], []
485
+ stages = ["thread/start", "thread/name/set", "turn/start"]
486
+
487
+ def call(method, params):
488
+ requests.append(method)
489
+ if method == stage:
490
+ raise error
491
+ return {"thread": {"id": THREAD_ID}}
492
+
493
+ def server(path):
494
+ connections.append(path)
495
+ return server_calls(call)
496
+
497
+ monkeypatch.setattr(ding, "app_server", server)
498
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
499
+ "--socket", "/test.sock"]) == (130 if isinstance(error, KeyboardInterrupt) else 1)
500
+ assert requests == stages[:stages.index(stage) + 1]
501
+ assert connections == ["/test.sock"]
502
+ captured = capsys.readouterr()
503
+ assert captured.out == ""
504
+ assert f"new {stage}" in captured.err
505
+ assert (str(error) or type(error).__name__) in captured.err
506
+ assert "inspect Desktop or ding board before retrying" in captured.err
507
+ if stage == "thread/start":
508
+ assert "session creation is unconfirmed" in captured.err
509
+ assert THREAD_ID not in captured.err
510
+ else:
511
+ assert f"session {THREAD_ID}" in captured.err
512
+
513
+
514
+ @pytest.mark.parametrize("reply", [{}, {"thread": None}, {"thread": []}, {"thread": {}},
515
+ {"thread": {"id": None}}, {"thread": {"id": 7}},
516
+ {"thread": {"id": "invalid"}}])
517
+ def test_new_rejects_invalid_thread_before_naming(tmp_path, monkeypatch, capsys, reply):
518
+ requests = []
519
+
520
+ def call(method, params):
521
+ requests.append(method)
522
+ return reply
523
+
524
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
525
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
526
+ "--socket", "/test.sock"]) == 1
527
+ assert requests == ["thread/start"]
528
+ captured = capsys.readouterr()
529
+ assert captured.out == ""
530
+ assert "new thread/start: Malformed App Server thread ID" in captured.err
531
+ assert "session creation is unconfirmed" in captured.err
532
+
533
+
534
+ @pytest.mark.parametrize("turn", [None, [], {}, {"id": None, "status": "inProgress"},
535
+ {"id": "", "status": "inProgress"}, {"id": 7, "status": "inProgress"},
536
+ {"id": "turn-1"}, {"id": "turn-1", "status": "failed"},
537
+ {"id": "turn-1", "status": "interrupted"},
538
+ {"id": "turn-1", "status": "future-state"},
539
+ {"id": "turn-1", "status": {}}])
540
+ def test_new_rejects_unaccepted_turn_and_keeps_id(tmp_path, monkeypatch, capsys, turn):
541
+ requests = []
542
+
543
+ def call(method, params):
544
+ requests.append(method)
545
+ return {"thread/start": {"thread": {"id": THREAD_ID}},
546
+ "thread/name/set": {}, "turn/start": {"turn": turn}}[method]
547
+
548
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
549
+ assert ding.main(["new", "--cwd", str(tmp_path), "--name", "Build", "--message", "Start",
550
+ "--socket", "/test.sock"]) == 1
551
+ assert requests == ["thread/start", "thread/name/set", "turn/start"]
552
+ captured = capsys.readouterr()
553
+ assert captured.out == ""
554
+ assert "new turn/start: App Server did not return an accepted turn" in captured.err
555
+ assert f"session {THREAD_ID}" in captured.err
556
+ assert "inspect Desktop or ding board before retrying" in captured.err
557
+
558
+
255
559
  def test_send_persists_intent_before_delivery(monkeypatch, tmp_path):
256
560
  calls = []
257
561
  message = 'text "quoted"; $(not-shell)\nnext'
@@ -2829,3 +3133,98 @@ def test_pane_representative_skips_empty_ancestor_command_and_breaks_ties_by_pid
2829
3133
  panes = {10: {"pane_id": "%1", "session": "work", "window": "task"}}
2830
3134
  assert [row["pid"] for row in ding.unwrapped_processes(
2831
3135
  processes, panes, min_age=600, min_cpu=10, proc=tmp_path)] == [11]
3136
+
3137
+
3138
+ @pytest.mark.parametrize("arguments", [
3139
+ [], [THREAD_ID], ["--name", "New name"], [THREAD_ID, "--name"],
3140
+ [THREAD_ID, "--issue", "42", "--name", "New name"],
3141
+ ])
3142
+ def test_rename_requires_one_target_and_name(monkeypatch, capsys, arguments):
3143
+ monkeypatch.setattr(ding, "resolve_target", lambda *a: pytest.fail("invalid arguments must not resolve"))
3144
+ with pytest.raises(SystemExit) as exc:
3145
+ ding.main(["rename", *arguments])
3146
+ assert exc.value.code == 2
3147
+ assert capsys.readouterr().out == ""
3148
+
3149
+
3150
+ @pytest.mark.parametrize("name", ["", " \t\n", "\u2003"])
3151
+ @pytest.mark.parametrize("target", [["Build"], ["--issue", "42"]])
3152
+ def test_rename_rejects_blank_name_before_lookup(monkeypatch, capsys, name, target):
3153
+ for function in ("resolve_target", "socket_path", "app_server", "github_repository", "gh_json"):
3154
+ monkeypatch.setattr(ding, function, lambda *a: pytest.fail("blank name must not look up"))
3155
+ with pytest.raises(SystemExit) as exc:
3156
+ ding.main(["rename", *target, "--name", name])
3157
+ assert exc.value.code == 2
3158
+ captured = capsys.readouterr()
3159
+ assert captured.out == ""
3160
+ assert "--name must contain non-whitespace text" in captured.err
3161
+
3162
+
3163
+ @pytest.mark.parametrize("target,issue", [
3164
+ (THREAD_ID, None), ("Build", None), (None, 42), ("#42", None),
3165
+ ("example/project#42", None), ("https://github.com/example/project/issues/42", None),
3166
+ ])
3167
+ def test_rename_delegates_target_resolution(monkeypatch, capsys, target, issue):
3168
+ resolved = []
3169
+ calls = []
3170
+
3171
+ def resolve(value, number, path):
3172
+ resolved.append((value, number, path))
3173
+ return THREAD_ID
3174
+
3175
+ def call(method, params):
3176
+ calls.append((method, params))
3177
+ return {}
3178
+
3179
+ monkeypatch.setattr(ding, "resolve_target", resolve)
3180
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
3181
+ arguments = [target] if target is not None else ["--issue", str(issue)]
3182
+ assert ding.main(["rename", *arguments, "--name", "New name", "--socket", "/test.sock"]) == 0
3183
+ assert resolved == [(target, issue, "/test.sock")]
3184
+ assert calls == [("thread/name/set", {"threadId": THREAD_ID, "name": "New name"})]
3185
+ captured = capsys.readouterr()
3186
+ assert captured.out == THREAD_ID + "\n"
3187
+ assert captured.err == ""
3188
+
3189
+
3190
+ @pytest.mark.parametrize("reply,status,error", [
3191
+ ({"result": {}}, 0, None),
3192
+ ({"error": {"code": -32602, "message": "name refused"}}, 1, "name refused"),
3193
+ ("", 1, "closed the connection"),
3194
+ ({"result": None}, 1, "Malformed App Server result"),
3195
+ ])
3196
+ def test_rename_rpc_acknowledgement_and_failure(tmp_path, monkeypatch, capsys, reply, status, error):
3197
+ websocket = pytest.importorskip("websocket")
3198
+ ws = Connection([{"result": {}}, reply])
3199
+ monkeypatch.setattr(websocket, "create_connection", lambda *a, **kw: ws)
3200
+ name = ' 评测 "quoted" $(not-shell)\nnext '
3201
+ path = str(tmp_path / "server")
3202
+ with socket.socket(socket.AF_UNIX) as server:
3203
+ server.bind(path)
3204
+ server.listen(1)
3205
+ assert ding.main(["rename", "{" + THREAD_ID + "}", "--name", name, "--socket", path]) == status
3206
+ captured = capsys.readouterr()
3207
+ assert captured.out == (THREAD_ID + "\n" if status == 0 else "")
3208
+ assert (error in captured.err) if error else captured.err == ""
3209
+ assert [request["method"] for request in ws.requests] == ["initialize", "initialized", "thread/name/set"]
3210
+ assert ws.requests[-1]["params"] == {"threadId": THREAD_ID, "name": name}
3211
+ assert ws.closed
3212
+
3213
+
3214
+ @pytest.mark.parametrize("target", ["Build", "#42"])
3215
+ def test_rename_refuses_ambiguous_targets(issue_data, monkeypatch, capsys, target):
3216
+ issue, comments = issue_data
3217
+ comments.append({"body": f"Codex session: {OTHER_ID}"})
3218
+ calls = []
3219
+
3220
+ def call(method, params):
3221
+ calls.append(method)
3222
+ assert method == "thread/list"
3223
+ return {"data": [thread(), thread(OTHER_ID, "Build")]}
3224
+
3225
+ monkeypatch.setattr(ding, "app_server", lambda path: server_calls(call))
3226
+ assert ding.main(["rename", target, "--name", "New name", "--socket", "/test.sock"]) == 1
3227
+ captured = capsys.readouterr()
3228
+ assert captured.out == ""
3229
+ assert THREAD_ID in captured.err and OTHER_ID in captured.err
3230
+ 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