aisc-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. aisc/VERSION +1 -0
  2. aisc/__init__.py +49 -0
  3. aisc/__main__.py +6 -0
  4. aisc/adapters/__init__.py +1 -0
  5. aisc/adapters/config_reader.py +215 -0
  6. aisc/adapters/container_registry.py +718 -0
  7. aisc/adapters/data_root_store.py +296 -0
  8. aisc/adapters/docker_.py +1183 -0
  9. aisc/adapters/docker_gateway.py +1244 -0
  10. aisc/adapters/docker_sdk_backed.py +274 -0
  11. aisc/adapters/maintenance_lock.py +97 -0
  12. aisc/adapters/state_file.py +184 -0
  13. aisc/adapters/system.py +101 -0
  14. aisc/adapters/windows_config_reader.py +249 -0
  15. aisc/adapters/workspace_lease_store.py +189 -0
  16. aisc/application/__init__.py +1 -0
  17. aisc/application/artifact.py +265 -0
  18. aisc/application/bundle_fetch.py +836 -0
  19. aisc/application/cc_switch_provider.py +245 -0
  20. aisc/application/cc_switch_resolver.py +363 -0
  21. aisc/application/config_service.py +452 -0
  22. aisc/application/conversation.py +516 -0
  23. aisc/application/data_migration.py +440 -0
  24. aisc/application/data_root.py +275 -0
  25. aisc/application/docker_lifecycle.py +659 -0
  26. aisc/application/doctor.py +821 -0
  27. aisc/application/legacy_scan.py +252 -0
  28. aisc/application/network_subscription.py +642 -0
  29. aisc/application/profile_service.py +105 -0
  30. aisc/application/provider.py +92 -0
  31. aisc/application/repair.py +387 -0
  32. aisc/application/resources.py +270 -0
  33. aisc/application/runtime.py +1826 -0
  34. aisc/application/session.py +320 -0
  35. aisc/application/tool_versions.py +210 -0
  36. aisc/application/toolchain.py +91 -0
  37. aisc/application/update.py +286 -0
  38. aisc/application/usage.py +305 -0
  39. aisc/application/version.py +133 -0
  40. aisc/application/web_gateway.py +459 -0
  41. aisc/application/workspace_reconcile.py +318 -0
  42. aisc/applog.py +103 -0
  43. aisc/cli/__init__.py +1 -0
  44. aisc/cli/build_progress_parser.py +217 -0
  45. aisc/cli/commands/__init__.py +1 -0
  46. aisc/cli/commands/agents.py +117 -0
  47. aisc/cli/commands/artifact.py +210 -0
  48. aisc/cli/commands/build.py +566 -0
  49. aisc/cli/commands/bundle.py +107 -0
  50. aisc/cli/commands/cc_switch.py +158 -0
  51. aisc/cli/commands/config.py +104 -0
  52. aisc/cli/commands/container.py +787 -0
  53. aisc/cli/commands/conversation.py +72 -0
  54. aisc/cli/commands/data_root.py +84 -0
  55. aisc/cli/commands/logs.py +89 -0
  56. aisc/cli/commands/network.py +132 -0
  57. aisc/cli/commands/profile.py +52 -0
  58. aisc/cli/commands/provider.py +49 -0
  59. aisc/cli/commands/run.py +494 -0
  60. aisc/cli/commands/runs.py +125 -0
  61. aisc/cli/commands/runtime.py +450 -0
  62. aisc/cli/commands/serve.py +550 -0
  63. aisc/cli/commands/serve_fs.py +369 -0
  64. aisc/cli/commands/session.py +167 -0
  65. aisc/cli/commands/update.py +145 -0
  66. aisc/cli/commands/usage.py +74 -0
  67. aisc/cli/commands/wizard.py +130 -0
  68. aisc/cli/commands/workspaces.py +162 -0
  69. aisc/cli/help_i18n.py +385 -0
  70. aisc/cli/main.py +2926 -0
  71. aisc/cli/output.py +239 -0
  72. aisc/cli/tutorial.py +109 -0
  73. aisc/domain/__init__.py +1 -0
  74. aisc/domain/artifacts.py +279 -0
  75. aisc/domain/cc_switch_release.py +303 -0
  76. aisc/domain/config.py +64 -0
  77. aisc/domain/data_migration.py +259 -0
  78. aisc/domain/data_root.py +124 -0
  79. aisc/domain/docker_ownership.py +148 -0
  80. aisc/domain/gateway.py +168 -0
  81. aisc/domain/models.py +674 -0
  82. aisc/domain/toolchain.py +72 -0
  83. aisc/domain/web_services.py +355 -0
  84. aisc/domain/workspace_lease.py +139 -0
  85. aisc/schemas/__init__.py +1 -0
  86. aisc/schemas/config_schema.py +145 -0
  87. aisc_cli-0.1.0.dist-info/METADATA +420 -0
  88. aisc_cli-0.1.0.dist-info/RECORD +92 -0
  89. aisc_cli-0.1.0.dist-info/WHEEL +5 -0
  90. aisc_cli-0.1.0.dist-info/entry_points.txt +2 -0
  91. aisc_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
  92. aisc_cli-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,516 @@
1
+ """Agent conversation discovery (v2.1.8 T3).
2
+
3
+ Read-only JSONL scanning per docs/plans/2.1.8-dev-plans/01-design.md
4
+ §1a-1e. Thin read over provider-native session files — no new storage
5
+ (方案 A): Claude ``<ws>/claude/projects/**/*.jsonl`` (filename stem =
6
+ conversation id), Codex ``<ws>/codex/sessions/**/*.jsonl`` (rollout
7
+ filename carries the id).
8
+
9
+ Oversized files (>10MB) are never fully parsed: a streaming head scan of
10
+ the first 200 parseable lines yields title/time, ``message_count`` stays
11
+ ``null`` and the entry is annotated ``file_too_large``. Malformed JSON
12
+ lines are skipped; a file with at least one parseable user message but
13
+ some corrupt lines is annotated ``malformed`` and kept, ``resumable:
14
+ false``. Entries with zero parseable user messages are excluded.
15
+
16
+ D-5 (decisions.md): conversation ids are provider-native — Codex uses
17
+ time-ordered UUIDv7 in the wild (see the frozen T0 codex fixtures), so id
18
+ validation accepts any RFC-4122 version, not only v4. Non-UUID garbage is
19
+ still rejected (the check's stated intent).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import re
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any, Dict, List, Optional, Tuple
30
+
31
+ from aisc.application.data_root import DataRootResolver
32
+ from aisc.domain.models import CliError
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Frozen constants (design §1a/1d/1e)
36
+ # ---------------------------------------------------------------------------
37
+
38
+ MAX_JSONL_BYTES = 10 * 1024 * 1024
39
+ HEAD_SCAN_PARSEABLE_LINES = 200
40
+ TITLE_MAX_CHARS = 80
41
+ UNREADABLE_TITLE = "(无法读取)"
42
+
43
+ ERROR_INVALID_ID = "AISC_ERR_CONVERSATION_INVALID_ID"
44
+ ERROR_INVALID_AGENT = "AISC_ERR_CONVERSATION_INVALID_AGENT"
45
+ ERROR_UNRESUMABLE = "AISC_ERR_CONVERSATION_UNRESUMABLE"
46
+
47
+ # D-5: any RFC-4122 version (provider-native ids; Codex ships v7). The
48
+ # design's v4-only pattern predated the T0 probe fixtures and would have
49
+ # rejected every real Codex session.
50
+ _UUID_RE = re.compile(
51
+ r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
52
+ r"-[0-9a-fA-F]{12}$"
53
+ )
54
+ _CODEX_FILENAME_RE = re.compile(
55
+ r"^rollout-\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-"
56
+ r"([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}"
57
+ r"-[0-9a-fA-F]{12})\.jsonl$"
58
+ )
59
+
60
+ _AGENTS = ("claude", "codex")
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Title sanitization (design §1d)
65
+ # ---------------------------------------------------------------------------
66
+
67
+ _ANSI_RE = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)")
68
+ _REDACT_PATTERNS = (
69
+ re.compile(r"sk-[a-zA-Z0-9]{20,}"),
70
+ re.compile(r"Bearer\s+\S+"),
71
+ re.compile(r"api[_-]?key.*['\"]\s*\w{16,}"),
72
+ )
73
+ _CONTROL_CHARS_RE = re.compile(r"[\x00-\x1f\x7f]")
74
+
75
+ # Injected-context user messages (手测反馈 #1: providers record setup context
76
+ # — AGENTS.md/skills/permissions blocks, command caveats — as user role, so
77
+ # the FIRST user line is often not what the user typed). Title selection
78
+ # prefers the first message that does not look like injected context and
79
+ # falls back to the first message when every candidate does.
80
+ _CONTEXT_TITLE_RE = re.compile(
81
+ r"^(?:"
82
+ r"<" # XML-ish context blocks (<INSTRUCTIONS>,
83
+ # <skills_instructions>, <local-command-
84
+ # caveat>, <command-name>, <environment…)
85
+ r"|# AGENTS\.md instructions" # Codex AGENTS.md injection preamble
86
+ r"|Caveat:" # Claude command caveat body
87
+ r")"
88
+ )
89
+
90
+
91
+ def sanitize_title(text: str) -> str:
92
+ """Sanitize a raw user message into a display title.
93
+
94
+ Order: strip ANSI escapes → apply redaction patterns → drop control
95
+ chars (newlines become spaces) → collapse whitespace → truncate to 80
96
+ Unicode scalar values. Empty results degrade to ``UNREADABLE_TITLE``.
97
+ """
98
+ # 2.1.10 (field report #5): a non-string payload (nested content object
99
+ # from a foreign session schema) must degrade, never render as
100
+ # [object Object] upstream.
101
+ if not isinstance(text, str):
102
+ return UNREADABLE_TITLE
103
+ out = _ANSI_RE.sub("", text)
104
+ for pat in _REDACT_PATTERNS:
105
+ out = pat.sub("[REDACTED]", out)
106
+ out = _CONTROL_CHARS_RE.sub(" ", out)
107
+ out = " ".join(out.split())
108
+ out = out[:TITLE_MAX_CHARS]
109
+ return out or UNREADABLE_TITLE
110
+
111
+
112
+ # ---------------------------------------------------------------------------
113
+ # Per-provider user-message extraction (design §1a)
114
+ # ---------------------------------------------------------------------------
115
+
116
+ def _extract_claude_user(obj: Dict[str, Any]) -> Tuple[bool, Optional[str], bool]:
117
+ """Return (is_user_line, extracted_text, title_eligible) for a Claude
118
+ line. ``title_eligible`` excludes SDK-internal prompts ("init" and
119
+ friends — 手测反馈 #1) from title selection."""
120
+ if obj.get("type") != "user":
121
+ return False, None, False
122
+ msg = obj.get("message")
123
+ if not isinstance(msg, dict) or msg.get("role") != "user":
124
+ return False, None, False
125
+ sdk = msg.get("promptSource") == "sdk"
126
+ return True, _text_from_content(msg.get("content"), "text"), not sdk
127
+
128
+
129
+ def _extract_codex_user(obj: Dict[str, Any]) -> Tuple[bool, Optional[str], bool]:
130
+ """Return (is_user_line, extracted_text, title_eligible) for a Codex
131
+ line. Title eligibility is decided later by the context-text heuristic."""
132
+ if obj.get("type") != "response_item":
133
+ return False, None, False
134
+ payload = obj.get("payload")
135
+ if not isinstance(payload, dict) or payload.get("type") != "message":
136
+ return False, None, False
137
+ if payload.get("role") != "user":
138
+ return False, None, False
139
+ return True, _text_from_content(payload.get("content"), "input_text"), True
140
+
141
+
142
+ def _text_from_content(content: Any, block_type: str) -> Optional[str]:
143
+ """Content is a string or a list of typed blocks; extract the first
144
+ text block's text (design §1d extraction). Returns None when the line
145
+ is a user line without extractable text (e.g. tool results)."""
146
+ if isinstance(content, str):
147
+ return content
148
+ if isinstance(content, list):
149
+ for block in content:
150
+ if isinstance(block, dict) and block.get("type") == block_type:
151
+ text = block.get("text")
152
+ if isinstance(text, str):
153
+ return text
154
+ return None
155
+
156
+
157
+ _EXTRACTORS = {
158
+ "claude": _extract_claude_user,
159
+ "codex": _extract_codex_user,
160
+ }
161
+
162
+
163
+ def is_conversation_uuid(value: str) -> bool:
164
+ """True for a well-formed conversation id — any RFC-4122 version (D-5:
165
+ Codex ships UUIDv7). Non-UUID garbage is rejected."""
166
+ return bool(value) and bool(_UUID_RE.match(value))
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # File scanning
171
+ # ---------------------------------------------------------------------------
172
+
173
+ @dataclass
174
+ class _FileScan:
175
+ parseable: int = 0
176
+ user_messages: int = 0
177
+ first_ts: Optional[str] = None
178
+ last_ts: Optional[str] = None
179
+ title: Optional[str] = None
180
+ malformed_seen: bool = False
181
+ _first_raw: Optional[str] = None
182
+ _good_raw: Optional[str] = None
183
+
184
+
185
+ def _scan_file(path: Path, agent: str, head_only: bool) -> _FileScan:
186
+ """Stream a JSONL session file. ``head_only`` (oversized files) stops
187
+ after HEAD_SCAN_PARSEABLE_LINES parseable lines — the file is never
188
+ fully parsed (design §1a max_file_size)."""
189
+ state = _FileScan()
190
+ extract = _EXTRACTORS[agent]
191
+ # 2.1.10 (field report #4): one unreadable session file (permissions,
192
+ # owner mismatch on a data-root restored from elsewhere) must not kill
193
+ # the whole conversation list — skip it like a malformed file.
194
+ try:
195
+ fh = path.open("r", encoding="utf-8", errors="replace")
196
+ except OSError:
197
+ state.malformed_seen = True
198
+ return state
199
+ with fh:
200
+ for line in fh:
201
+ line = line.strip()
202
+ if not line:
203
+ continue
204
+ try:
205
+ obj = json.loads(line)
206
+ except (ValueError, TypeError):
207
+ state.malformed_seen = True
208
+ continue
209
+ if not isinstance(obj, dict):
210
+ continue
211
+ state.parseable += 1
212
+ ts = obj.get("timestamp")
213
+ if isinstance(ts, str) and ts:
214
+ if state.first_ts is None:
215
+ state.first_ts = ts
216
+ state.last_ts = ts
217
+ is_user, text, title_eligible = extract(obj)
218
+ if is_user:
219
+ state.user_messages += 1
220
+ if text:
221
+ # 手测反馈 #1: prefer the first non-context user message
222
+ # for the title; fall back to the first message when
223
+ # every candidate is injected context.
224
+ if state._first_raw is None:
225
+ state._first_raw = text
226
+ if state._good_raw is None and title_eligible \
227
+ and not _CONTEXT_TITLE_RE.match(text.lstrip()):
228
+ state._good_raw = text
229
+ if head_only and state.parseable >= HEAD_SCAN_PARSEABLE_LINES:
230
+ break
231
+ chosen = state._good_raw if state._good_raw else state._first_raw
232
+ state.title = sanitize_title(chosen) if chosen else None
233
+ return state
234
+
235
+
236
+ def _conversation_id_for(agent: str, filename: str) -> Optional[str]:
237
+ """Derive the conversation id from the filename (design §1a id_source).
238
+
239
+ Claude: filename stem IS the id. Codex: ``rollout-<ts>-<uuid>.jsonl``.
240
+ Returns None for files that do not carry a well-formed id — they are
241
+ not sessions and are skipped.
242
+ """
243
+ if agent == "claude":
244
+ stem = filename[:-len(".jsonl")] if filename.endswith(".jsonl") else filename
245
+ return stem if _UUID_RE.match(stem) else None
246
+ m = _CODEX_FILENAME_RE.match(filename)
247
+ return m.group(1) if m else None
248
+
249
+
250
+ def _summarize(agent: str, conv_id: str, file_size: int,
251
+ scan: _FileScan) -> Optional[Dict[str, Any]]:
252
+ """Apply §1e filtering/annotation. Returns None when the entry is
253
+ excluded from the list (zero parseable user messages)."""
254
+ if scan.user_messages == 0:
255
+ return None
256
+ resumable = True
257
+ reason: Optional[str] = None
258
+ message_count: Optional[int] = scan.user_messages
259
+ if file_size > MAX_JSONL_BYTES:
260
+ resumable = False
261
+ reason = "file_too_large"
262
+ message_count = None # head scan only — a full count was not taken
263
+ elif scan.malformed_seen:
264
+ resumable = False
265
+ reason = "malformed"
266
+ entry: Dict[str, Any] = {
267
+ "conversation_id": conv_id,
268
+ "agent": agent,
269
+ "title": scan.title if scan.title else UNREADABLE_TITLE,
270
+ "started_at": scan.first_ts,
271
+ "last_at": scan.last_ts,
272
+ "message_count": message_count,
273
+ "file_size": file_size,
274
+ "resumable": resumable,
275
+ }
276
+ if not resumable:
277
+ entry["unavailable_reason"] = reason
278
+ return entry
279
+
280
+
281
+ def _scan_agent_dir(base: Path, agent: str) -> List[Dict[str, Any]]:
282
+ if not base.is_dir():
283
+ return []
284
+ out: List[Dict[str, Any]] = []
285
+ for path in sorted(base.rglob("*.jsonl")):
286
+ if not path.is_file():
287
+ continue
288
+ conv_id = _conversation_id_for(agent, path.name)
289
+ if conv_id is None:
290
+ continue
291
+ try:
292
+ size = path.stat().st_size
293
+ except OSError:
294
+ continue
295
+ scan = _scan_file(path, agent, head_only=(size > MAX_JSONL_BYTES))
296
+ entry = _summarize(agent, conv_id, size, scan)
297
+ if entry is not None:
298
+ out.append(entry)
299
+ return out
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Public entry points
304
+ # ---------------------------------------------------------------------------
305
+
306
+ def _workspace_dir(workspace: str) -> Path:
307
+ """The workspace's data-root dir (read-only resolve — never creates)."""
308
+ ws = Path(workspace).resolve() if workspace else Path.cwd()
309
+ return DataRootResolver().resolve(ws).workspace_dir
310
+
311
+
312
+ # ---------------------------------------------------------------------------
313
+ # Title overrides (v2.1.8 T4 手测反馈 #2: 右键重命名). Providers own session
314
+ # titles (first user message); the Workbench label lives in a small override
315
+ # map under the workspace runtime dir (设计 3a: that dir is the workspace
316
+ # runtime persistence home). <agent>:<id-lowercase> → display title.
317
+ # ---------------------------------------------------------------------------
318
+
319
+ _TITLES_SCHEMA = "aisc.conversation-titles/v1"
320
+
321
+
322
+ def _titles_path(ws_dir: Path) -> Path:
323
+ return ws_dir / "runtime" / "conversation_titles.json"
324
+
325
+
326
+ def _load_titles(ws_dir: Path) -> Dict[str, str]:
327
+ try:
328
+ data = json.loads(_titles_path(ws_dir).read_text(encoding="utf-8"))
329
+ except (FileNotFoundError, ValueError, OSError):
330
+ return {}
331
+ if not isinstance(data, dict) or not isinstance(data.get("titles"), dict):
332
+ return {}
333
+ return {
334
+ str(k): str(v)
335
+ for k, v in data["titles"].items()
336
+ if isinstance(v, str) and v
337
+ }
338
+
339
+
340
+ def _save_titles(ws_dir: Path, titles: Dict[str, str]) -> None:
341
+ path = _titles_path(ws_dir)
342
+ path.parent.mkdir(parents=True, exist_ok=True)
343
+ payload = json.dumps(
344
+ {"schema": _TITLES_SCHEMA, "titles": titles},
345
+ ensure_ascii=False, indent=2, sort_keys=True,
346
+ ) + "\n"
347
+ tmp = path.with_name(f".{path.name}.tmp.{os.getpid()}")
348
+ tmp.write_text(payload, encoding="utf-8")
349
+ os.replace(tmp, path) # atomic — readers never see a partial file
350
+
351
+
352
+ def list_conversations(workspace: str) -> Dict[str, Any]:
353
+ """List agent history conversations for a workspace (design §1b).
354
+
355
+ Read-only, no Docker. Sorted most-recent-first for the picker UI
356
+ (``last_at`` descending; entries without timestamps sort last).
357
+ """
358
+ ws_dir = _workspace_dir(workspace)
359
+ entries: List[Dict[str, Any]] = []
360
+ entries.extend(_scan_agent_dir(ws_dir / "claude" / "projects", "claude"))
361
+ entries.extend(_scan_agent_dir(ws_dir / "codex" / "sessions", "codex"))
362
+ entries.sort(
363
+ key=lambda e: (e["last_at"] or "", e["conversation_id"]),
364
+ reverse=True,
365
+ )
366
+ # Title overrides (rename) win over the extracted first-user-message.
367
+ overrides = _load_titles(ws_dir)
368
+ if overrides:
369
+ for entry in entries:
370
+ key = f"{entry['agent']}:{entry['conversation_id'].lower()}"
371
+ if key in overrides:
372
+ entry["title"] = overrides[key]
373
+ return {"schema_version": 1, "conversations": entries}
374
+
375
+
376
+ def _find_conversation_file(ws_dir: Path, agent: str,
377
+ conversation_id: str) -> Optional[Path]:
378
+ """Exact provider-specific file match for a conversation id (design §2).
379
+ Claude: filename stem == id. Codex: rollout filename's trailing id,
380
+ case-insensitive. Returns None when absent — never a glob substring."""
381
+ if agent == "claude":
382
+ base = ws_dir / "claude" / "projects"
383
+ if not base.is_dir():
384
+ return None
385
+ for p in base.rglob("*.jsonl"):
386
+ if p.is_file() and p.stem == conversation_id:
387
+ return p
388
+ return None
389
+ base = ws_dir / "codex" / "sessions"
390
+ if not base.is_dir():
391
+ return None
392
+ for p in base.rglob("*.jsonl"):
393
+ if not p.is_file():
394
+ continue
395
+ m = _CODEX_FILENAME_RE.match(p.name)
396
+ if m is not None and m.group(1).lower() == conversation_id.lower():
397
+ return p
398
+ return None
399
+
400
+
401
+ def preflight_conversation(workspace: str, conversation_id: str,
402
+ agent: str) -> Dict[str, Any]:
403
+ """Validate a conversation is resumable BEFORE any PTY spawn (design
404
+ §2: sidecar captured preflight). Exact provider-specific file match —
405
+ never glob substring matching."""
406
+ if not is_conversation_uuid(conversation_id):
407
+ raise CliError(
408
+ message=f"Invalid conversation ID: {conversation_id}",
409
+ exit_code=2,
410
+ error_code=ERROR_INVALID_ID,
411
+ )
412
+ if agent not in _AGENTS:
413
+ raise CliError(
414
+ message=f"Unsupported agent for resume: {agent}",
415
+ exit_code=2,
416
+ error_code=ERROR_INVALID_AGENT,
417
+ )
418
+
419
+ ws_dir = _workspace_dir(workspace)
420
+ if _find_conversation_file(ws_dir, agent, conversation_id) is None:
421
+ raise CliError(
422
+ message=f"Conversation {conversation_id} not found for agent {agent}",
423
+ exit_code=3,
424
+ error_code=ERROR_UNRESUMABLE,
425
+ )
426
+ return {
427
+ "preflight_ok": True,
428
+ "conversation_id": conversation_id,
429
+ "agent": agent,
430
+ }
431
+
432
+
433
+ def delete_conversation(workspace: str, conversation_id: str,
434
+ agent: str) -> Dict[str, Any]:
435
+ """Delete a conversation's session file (v2.1.8 T4 手测反馈 #4: 变更页
436
+ 右键删除). Exact match only; the id must be well-formed. Idempotent
437
+ failure mode: an absent conversation raises the same UNRESUMABLE error
438
+ as preflight — the caller has just shown it in a list, so absence is
439
+ an anomaly worth reporting, not a silent success."""
440
+ if not is_conversation_uuid(conversation_id):
441
+ raise CliError(
442
+ message=f"Invalid conversation ID: {conversation_id}",
443
+ exit_code=2,
444
+ error_code=ERROR_INVALID_ID,
445
+ )
446
+ if agent not in _AGENTS:
447
+ raise CliError(
448
+ message=f"Unsupported agent: {agent}",
449
+ exit_code=2,
450
+ error_code=ERROR_INVALID_AGENT,
451
+ )
452
+ ws_dir = _workspace_dir(workspace)
453
+ path = _find_conversation_file(ws_dir, agent, conversation_id)
454
+ if path is None:
455
+ raise CliError(
456
+ message=f"Conversation {conversation_id} not found for agent {agent}",
457
+ exit_code=3,
458
+ error_code=ERROR_UNRESUMABLE,
459
+ )
460
+ try:
461
+ path.unlink()
462
+ except OSError as exc:
463
+ raise CliError(
464
+ message=f"Failed to delete conversation {conversation_id}: {exc}",
465
+ exit_code=1,
466
+ error_code="AISC_ERR_GENERAL",
467
+ ) from exc
468
+ # Drop the rename override with the conversation (best-effort — a stale
469
+ # entry is inert but must not accumulate).
470
+ titles = _load_titles(ws_dir)
471
+ key = f"{agent}:{conversation_id.lower()}"
472
+ if key in titles:
473
+ del titles[key]
474
+ _save_titles(ws_dir, titles)
475
+ return {"deleted": True, "conversation_id": conversation_id, "agent": agent}
476
+
477
+
478
+ def rename_conversation(workspace: str, conversation_id: str, agent: str,
479
+ title: str) -> Dict[str, Any]:
480
+ """Set a Workbench display title for a conversation (手测反馈 #2). The
481
+ provider's own title is untouched; the override wins in ``list``."""
482
+ if not is_conversation_uuid(conversation_id):
483
+ raise CliError(
484
+ message=f"Invalid conversation ID: {conversation_id}",
485
+ exit_code=2,
486
+ error_code=ERROR_INVALID_ID,
487
+ )
488
+ if agent not in _AGENTS:
489
+ raise CliError(
490
+ message=f"Unsupported agent: {agent}",
491
+ exit_code=2,
492
+ error_code=ERROR_INVALID_AGENT,
493
+ )
494
+ if not isinstance(title, str) or not title.strip():
495
+ raise CliError(
496
+ message="Title must be a non-empty string",
497
+ exit_code=2,
498
+ error_code="AISC_ERR_USAGE",
499
+ )
500
+ ws_dir = _workspace_dir(workspace)
501
+ if _find_conversation_file(ws_dir, agent, conversation_id) is None:
502
+ raise CliError(
503
+ message=f"Conversation {conversation_id} not found for agent {agent}",
504
+ exit_code=3,
505
+ error_code=ERROR_UNRESUMABLE,
506
+ )
507
+ clean = sanitize_title(title.strip())
508
+ titles = _load_titles(ws_dir)
509
+ titles[f"{agent}:{conversation_id.lower()}"] = clean
510
+ _save_titles(ws_dir, titles)
511
+ return {
512
+ "renamed": True,
513
+ "conversation_id": conversation_id,
514
+ "agent": agent,
515
+ "title": clean,
516
+ }