agents: prefix conversation title with "done " after cleanup (#4449) Completed conversations remain indistinguishable from active ones in the conversation list after `conversation-done` reclaims their worktree, branches, and Bazel output bases. Update `.agents/skills/conversation-done/scripts/cleanup.py` and `SKILL.md` to prepend `"done "` to the active conversation title (up to 70 characters, without duplicating an existing prefix) via the local Language Server API once cleanup finishes without errors.
diff --git a/.agents/skills/conversation-done/SKILL.md b/.agents/skills/conversation-done/SKILL.md index f11c78b..21b6285 100644 --- a/.agents/skills/conversation-done/SKILL.md +++ b/.agents/skills/conversation-done/SKILL.md
@@ -22,9 +22,13 @@ 3. **Local Git Branch**: The feature branch associated with the worktree. 4. **Remote Git Branch**: The feature branch pushed to the user's fork (e.g., `origin`). +5. **Conversation Name**: Once cleanup completes without errors, the active + conversation title is prefixed with `"done "` (without duplicating an + existing `"done "` prefix, capped at 70 characters). -This skill safely shuts down active build processes and reclaims these -resources without removing the conversation transcript or chat history. +This skill safely shuts down active build processes, reclaims these external +resources, and marks the conversation title as `"done "` without removing the +conversation transcript or chat history. ## Script Location @@ -103,6 +107,8 @@ * **Git Worktree**: Removed worktree directory path. * **Local Git Branch**: Deleted local branch name. * **Remote Git Branch**: Deleted remote branch (`<remote>/<branch>`). +* **Conversation Name**: Updated conversation title (prefixed with `"done "` + after successful cleanup). If any resources were skipped, left intact via `--leave-*` flags, or not found, note that briefly in the summary.
diff --git a/.agents/skills/conversation-done/scripts/cleanup.py b/.agents/skills/conversation-done/scripts/cleanup.py index 1496b1f..e68f3d8 100755 --- a/.agents/skills/conversation-done/scripts/cleanup.py +++ b/.agents/skills/conversation-done/scripts/cleanup.py
@@ -32,15 +32,29 @@ import asyncio import dataclasses import glob +import json import os import shutil import signal import stat import subprocess import sys +import urllib.error +import urllib.request PROTECTED_BRANCHES = frozenset({"main", "master", "HEAD"}) PROTECTED_REMOTES = frozenset({"upstream"}) +# Maximum conversation title length displayed by the UI. +_CONVERSATION_TITLE_MAX_LENGTH = 70 +_DONE_TITLE_PREFIX = "done " +# Local loopback Language Server RPC timeout in seconds. +_LS_RPC_TIMEOUT_SECONDS = 5 +_GET_TRAJECTORIES_METHOD = ( + "exa.language_server_pb.LanguageServerService/GetAllCascadeTrajectories" +) +_UPDATE_ANNOTATIONS_METHOD = ( + "exa.language_server_pb.LanguageServerService/UpdateConversationAnnotations" +) @dataclasses.dataclass @@ -519,16 +533,17 @@ leave_branch: bool = False, leave_remote: bool = False, dry_run: bool = False, -) -> list[str]: - """Executes cleanup on a single target and returns deleted resource descriptions.""" +) -> tuple[list[str], bool]: + """Executes cleanup on a single target and returns deleted resources and success.""" log(f"\nCleaning up target resources for: {target.worktree_path or target.branch}") deleted: list[str] = [] + succeeded = True if target.is_main_worktree: log_error( "Target is the main repository! Refusing to clean up main repository." ) - return deleted + return deleted, False # 1. Clean up Bazel output bases if not leave_bazel_output_base and target.bazel_output_bases: @@ -538,6 +553,8 @@ log(f" - Output base: {ob} ({size_str})") if await shutdown_and_remove_bazel_output_base(ob, dry_run=dry_run): deleted.append(f"Bazel output base: {ob} ({size_str})") + else: + succeeded = False elif leave_bazel_output_base: log("1. Leaving Bazel output base intact.") else: @@ -548,6 +565,8 @@ log("2. Cleaning up Git worktree:") if await remove_git_worktree(main_repo, target.worktree_path, dry_run=dry_run): deleted.append(f"Git worktree: {target.worktree_path}") + else: + succeeded = False elif leave_worktree: log("2. Leaving Git worktree directory on disk.") else: @@ -564,6 +583,7 @@ deleted.append(f"Local branch: {target.branch}") else: log_warn(f" {msg}") + succeeded = False elif leave_branch: log("3. Leaving local branch intact.") else: @@ -585,12 +605,89 @@ deleted.append(f"Remote branch: {target.remote}/{target.branch}") else: log_warn(f" {msg}") + succeeded = False elif leave_remote: log("4. Leaving remote branch intact.") elif target.branch and target.remote: log(f"4. Remote branch '{target.branch}' not present on '{target.remote}'.") - return deleted + return deleted, succeeded + + +def format_done_title(current_title: str) -> str: + """Prepends 'done ' to a conversation title and truncates to max length.""" + cleaned = " ".join(current_title.splitlines()).strip() + if not cleaned: + return _DONE_TITLE_PREFIX.strip() + + if cleaned.lower().startswith(_DONE_TITLE_PREFIX): + prefixed = _DONE_TITLE_PREFIX + cleaned[len(_DONE_TITLE_PREFIX) :].lstrip() + else: + prefixed = f"{_DONE_TITLE_PREFIX}{cleaned}" + + return prefixed[:_CONVERSATION_TITLE_MAX_LENGTH].rstrip() + + +def prefix_conversation_title( + conversation_id: str, + ls_address: str, + csrf_header_value: str, + dry_run: bool, +) -> None: + """Prepends 'done ' to the active conversation title via the Language Server.""" + headers = { + "Content-Type": "application/json", + "x-codeium-csrf-token": csrf_header_value, + } + list_url = f"http://{ls_address}/{_GET_TRAJECTORIES_METHOD}" + list_req = urllib.request.Request( + list_url, + data=b"{}", + headers=headers, + ) + try: + with urllib.request.urlopen(list_req, timeout=_LS_RPC_TIMEOUT_SECONDS) as resp: + payload = json.loads(resp.read().decode("utf-8")) + except (urllib.error.URLError, json.JSONDecodeError, OSError) as exc: + log_warn(f"Failed to query conversation title: {exc}") + return + + summaries = payload.get("trajectorySummaries") or {} + entry = summaries.get(conversation_id) or {} + annotations = entry.get("annotations") or {} + current_title = annotations.get("title") or entry.get("summary") or "" + if not current_title: + return + + new_title = format_done_title(current_title) + if new_title == current_title: + return + + if dry_run: + log(f" [Dry Run] Would rename conversation to: {new_title}") + return + + update_url = f"http://{ls_address}/{_UPDATE_ANNOTATIONS_METHOD}" + update_body = json.dumps( + { + "cascadeId": conversation_id, + "annotations": {"title": new_title}, + "mergeAnnotations": True, + } + ).encode("utf-8") + update_req = urllib.request.Request( + update_url, + data=update_body, + headers=headers, + ) + try: + with urllib.request.urlopen( + update_req, timeout=_LS_RPC_TIMEOUT_SECONDS + ) as resp: + resp.read() + log(f"Renamed conversation to: {new_title}") + except (urllib.error.URLError, OSError) as exc: + log_warn(f"Failed to rename conversation: {exc}") async def list_resources(main_repo: str) -> None: @@ -767,8 +864,9 @@ return deleted_resources: list[str] = [] + all_succeeded = True for target in targets: - deleted = await execute_cleanup_target( + deleted, succeeded = await execute_cleanup_target( main_repo, target, leave_bazel_output_base=args.leave_bazel_output_base, @@ -778,6 +876,8 @@ dry_run=args.dry_run, ) deleted_resources.extend(deleted) + if not succeeded: + all_succeeded = False summary_header = ( "\nResources that would be deleted (dry run):" @@ -790,6 +890,19 @@ log(f" - {resource}") else: log(" None") + + if all_succeeded: + conversation_id = os.environ.get("ANTIGRAVITY_CONVERSATION_ID", "").strip() + ls_address = os.environ.get("ANTIGRAVITY_LS_ADDRESS", "").strip() + csrf_header_value = os.environ.get("ANTIGRAVITY_CSRF_TOKEN", "").strip() + if conversation_id and ls_address and csrf_header_value: + prefix_conversation_title( + conversation_id=conversation_id, + ls_address=ls_address, + csrf_header_value=csrf_header_value, + dry_run=args.dry_run, + ) + log("\nCleanup completed.")
diff --git a/.agents/skills/conversation-done/scripts/cleanup_test.py b/.agents/skills/conversation-done/scripts/cleanup_test.py old mode 100644 new mode 100755 index 79abfa9..52285ee --- a/.agents/skills/conversation-done/scripts/cleanup_test.py +++ b/.agents/skills/conversation-done/scripts/cleanup_test.py
@@ -107,7 +107,9 @@ with mock.patch("cleanup.remove_git_worktree") as mock_rm_wt, mock.patch( "cleanup.log_error" ) as mock_log_err: - await cleanup.execute_cleanup_target(main_repo, target) + deleted, succeeded = await cleanup.execute_cleanup_target(main_repo, target) + self.assertEqual(deleted, []) + self.assertFalse(succeeded) mock_rm_wt.assert_not_called() mock_log_err.assert_called_with( "Target is the main repository! Refusing to clean up main repository." @@ -172,8 +174,11 @@ "Deleted remote branch 'feat1' from 'origin'.", ), ): - deleted = await cleanup.execute_cleanup_target("/repo/main", target) + deleted, succeeded = await cleanup.execute_cleanup_target( + "/repo/main", target + ) + self.assertTrue(succeeded) self.assertEqual( deleted, [ @@ -184,6 +189,26 @@ ], ) + def test_format_done_title(self): + """Verifies prepending 'done ' without duplication and 70-char truncation.""" + self.assertEqual( + cleanup.format_done_title("Fix flaky upload test"), + "done Fix flaky upload test", + ) + self.assertEqual( + cleanup.format_done_title("done Fix flaky upload test"), + "done Fix flaky upload test", + ) + self.assertEqual( + cleanup.format_done_title("DONE Fix flaky upload test"), + "done Fix flaky upload test", + ) + self.assertEqual(cleanup.format_done_title(" "), "done") + long_title = "a" * 80 + formatted = cleanup.format_done_title(long_title) + self.assertEqual(len(formatted), 70) + self.assertTrue(formatted.startswith("done ")) + if __name__ == "__main__": unittest.main()