diff --git a/README.md b/README.md index 7482bb8..4dc1d31 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Codex DeepSeek Worker +# Codex DeepSeek Run DeepSeek V4 Flash as a native text-only worker inside Codex while keeping your existing Codex model as the orchestrator. @@ -6,7 +6,7 @@ This project is for developers who want more parallel coding capacity, a low-cos ## Why a worker instead of a model switch? -Codex remains responsible for task decomposition, visual inputs, integration decisions, and final verification. `DeepSeekWorker` receives bounded text tasks such as: +Codex remains responsible for task decomposition, visual inputs, integration decisions, and final verification. `DeepSeek` receives bounded text tasks such as: - exploring a large repository and returning evidence; - implementing one isolated change; @@ -14,11 +14,11 @@ Codex remains responsible for task decomposition, visual inputs, integration dec - reviewing a diff with a second model; - drafting technical documentation from source files. -The worker is deliberately not the final decision-maker. Its agent contract requires a compact `WORKER_REPORT` with changed files, verification evidence, risks, and follow-ups. +The subagent is deliberately not the final decision-maker. Its agent contract requires a compact `WORKER_REPORT` with changed files, verification evidence, risks, and follow-ups. ## What V1 installs -- Native Codex role: `DeepSeekWorker` +- Native Codex role: `DeepSeek` - Model: `deepseek-v4-flash` - Provider: official DeepSeek API - Reasoning effort: `high` @@ -54,26 +54,30 @@ The Skill checks the current state before writing anything. If a credential is m After setup returns `ready`, restart Codex and open a new task so the native role is loaded. +Existing installations that used the former `DeepSeekWorker` role are migrated by +`repair`: the manager backs up the old agent file, installs `DeepSeek`, removes only +the old file it owns, and repeats direct plus native-routing verification. + ## Use the worker Ask the parent Codex agent to delegate a bounded task: ```text -Use DeepSeekWorker to inspect the authentication module, identify the failure path, +Use DeepSeek to inspect the authentication module, identify the failure path, and return an evidence-based fix recommendation. Do not edit files. ``` For implementation: ```text -Use DeepSeekWorker to implement the approved parser change and run the focused parser tests. +Use DeepSeek to implement the approved parser change and run the focused parser tests. Return the diff summary, verification, risks, and follow-ups to the parent agent. ``` Codex delegates through the native agent mechanism: ```text -spawn_agent(agent_type="DeepSeekWorker", fork_turns="none", ...) +spawn_agent(agent_type="DeepSeek", fork_turns="none", ...) ``` Daily work does not run the setup Skill again. @@ -117,7 +121,7 @@ A successful direct API call is not enough. Native verification must confirm bot model_provider = deepseek model = deepseek-v4-flash reasoning_effort = high -agent_role = DeepSeekWorker +agent_role = DeepSeek ``` Only then does the manager return `status: ready`. @@ -146,7 +150,7 @@ python3 /path/to/skill-creator/scripts/quick_validate.py codex-deepseek-worker ## Project ownership -Codex DeepSeek Worker is an open-source developer tool maintained by [BeatAPI](https://beatapi.io). It does not require a BeatAPI account or API key and is not affiliated with or endorsed by OpenAI or DeepSeek. +Codex DeepSeek is an open-source developer tool maintained by [BeatAPI](https://beatapi.io). It does not require a BeatAPI account or API key and is not affiliated with or endorsed by OpenAI or DeepSeek. ## License diff --git a/codex-deepseek-worker/SKILL.md b/codex-deepseek-worker/SKILL.md index 90cc490..89a643f 100644 --- a/codex-deepseek-worker/SKILL.md +++ b/codex-deepseek-worker/SKILL.md @@ -3,23 +3,23 @@ name: codex-deepseek-worker description: Configure, inspect, test, repair, disable, or uninstall DeepSeek V4 Flash as a native text-only Codex worker. Use when a user asks to add a DeepSeek worker or native DeepSeek subagent to Codex, verify its actual provider routing, repair it after a parent-model change, or remove its managed configuration. Do not trigger for general DeepSeek API questions or ordinary coding tasks after the worker is configured. --- -# Codex DeepSeek Worker +# Codex DeepSeek -Manage the native `DeepSeekWorker` configuration only. Do not use this Skill as a substitute execution path for daily coding work. Delegate deterministic configuration, catalog, credential, backup, and verification operations to `scripts/deepseek_worker.py`; do not hand-edit TOML, JSON, agent files, or credential stores. +Manage the native `DeepSeek` configuration only. Do not use this Skill as a substitute execution path for daily coding work. Delegate deterministic configuration, catalog, credential, backup, and verification operations to `scripts/deepseek_worker.py`; do not hand-edit TOML, JSON, agent files, or credential stores. ## Preserve these contracts - Use the Codex desktop app's bundled runtime. Treat its version as diagnostic information and require a real native-routing test. - Read the current parent model from Codex configuration. Do not hardcode or replace the user's primary model or login. - Run `repair` after the parent model changes, then verify again. -- Treat `DeepSeekWorker` as text-only. Convert relevant visual evidence to a textual task package before delegation. +- Treat `DeepSeek` as text-only. Convert relevant visual evidence to a textual task package before delegation. - For daily work, have the parent Codex agent call: ```text - spawn_agent(agent_type="DeepSeekWorker", fork_turns="none", ...) + spawn_agent(agent_type="DeepSeek", fork_turns="none", ...) ``` -- If the active tool schema does not expose `DeepSeekWorker`, ask the user to restart Codex and open a new task. Do not use the manager script or `codex exec` to perform the user's coding task. +- If the active tool schema does not expose `DeepSeek`, ask the user to restart Codex and open a new task. Do not use the manager script or `codex exec` to perform the user's coding task. - Read [references/worker-routing.md](references/worker-routing.md) when deciding what to delegate. Read [references/compatibility.md](references/compatibility.md) for configuration, routing, and rollback details. ## Follow this workflow @@ -41,7 +41,7 @@ python3 /scripts/deepseek_worker.py --json - `status`: Inspect runtime, configuration, catalog, credential, role, and manifest without changing them. - `setup`: Install the managed provider/catalog/role and perform live verification. -- `test`: Run a direct provider test followed by native `DeepSeekWorker` routing verification. +- `test`: Run a direct provider test followed by native `DeepSeek` routing verification. - `repair`: Reapply the managed configuration for the current parent model and verify it. - `disable`: Disable the managed worker while preserving provider data and credentials. - `uninstall`: Remove managed configuration. Pass `--remove-credential` only when explicitly requested. diff --git a/codex-deepseek-worker/agents/openai.yaml b/codex-deepseek-worker/agents/openai.yaml index 27a93c3..e2676c3 100644 --- a/codex-deepseek-worker/agents/openai.yaml +++ b/codex-deepseek-worker/agents/openai.yaml @@ -1,4 +1,4 @@ interface: - display_name: "Codex DeepSeek Worker" - short_description: "Install and verify a native DeepSeek coding worker" - default_prompt: "Use $codex-deepseek-worker to configure and verify DeepSeek as a native Codex worker." + display_name: "Codex DeepSeek" + short_description: "Install and verify a native DeepSeek coding subagent" + default_prompt: "Use $codex-deepseek-worker to configure and verify DeepSeek as a native Codex subagent." diff --git a/codex-deepseek-worker/evals/evals.json b/codex-deepseek-worker/evals/evals.json index cee9d29..857aa2f 100644 --- a/codex-deepseek-worker/evals/evals.json +++ b/codex-deepseek-worker/evals/evals.json @@ -4,7 +4,7 @@ { "id": 1, "prompt": "Configure DeepSeek as a native Codex worker. Here is the API key: sk-test-placeholder.", - "expected_output": "Run status first. Pass the key only through standard input, preserve the primary Codex model, install DeepSeekWorker, and require direct plus native routing verification before returning ready.", + "expected_output": "Run status first. Pass the key only through standard input, preserve the primary Codex model, install DeepSeek, and require direct plus native routing verification before returning ready.", "files": [] }, { @@ -27,20 +27,20 @@ }, { "id": 5, - "prompt": "DeepSeekWorker is not in spawn_agent yet. Use the manager script to finish my code change anyway.", + "prompt": "DeepSeek is not in spawn_agent yet. Use the manager script to finish my code change anyway.", "expected_output": "Do not use the manager or codex exec as a substitute coding path. Ask the user to restart Codex and open a new task.", "files": [] }, { "id": 6, - "prompt": "Have DeepSeekWorker inspect this screenshot and reproduce the UI.", + "prompt": "Have DeepSeek inspect this screenshot and reproduce the UI.", "expected_output": "Explain that V1 is text-only. The parent agent must inspect the visual and provide a bounded textual task package.", "files": [] }, { "id": 7, "prompt": "After setup, how should I use the worker for a repository review?", - "expected_output": "The parent Codex agent delegates a bounded text task with spawn_agent(agent_type=DeepSeekWorker, fork_turns=none). The worker returns a WORKER_REPORT and the parent verifies the evidence.", + "expected_output": "The parent Codex agent delegates a bounded text task with spawn_agent(agent_type=DeepSeek, fork_turns=none). The subagent returns a WORKER_REPORT and the parent verifies the evidence.", "files": [] }, { diff --git a/codex-deepseek-worker/references/compatibility.md b/codex-deepseek-worker/references/compatibility.md index d74c400..b6213c5 100644 --- a/codex-deepseek-worker/references/compatibility.md +++ b/codex-deepseek-worker/references/compatibility.md @@ -18,12 +18,20 @@ The default `CODEX_HOME` is `~/.codex`: - Codex config: `$CODEX_HOME/config.toml` - Merged model catalog: `$CODEX_HOME/models-with-deepseek.json` -- Worker role: `$CODEX_HOME/agents/DeepSeekWorker.toml` +- Subagent role: `$CODEX_HOME/agents/DeepSeek.toml` - Manifest and backups: `$CODEX_HOME/codex-deepseek-worker/` - Credential target: `codex-deepseek-worker-api-key` The manager does not change the top-level `model` or `model_provider`. +## Role-name migration + +Version 1 originally installed `$CODEX_HOME/agents/DeepSeekWorker.toml`. A current +`repair` recognizes that file only when its content hash matches the manager's +manifest, includes it in the transaction backup, replaces it with +`$CODEX_HOME/agents/DeepSeek.toml`, and verifies the new `DeepSeek` role. An +unrecognized or user-modified legacy file is reported as a conflict and preserved. + ## Native routing On macOS, the manager discovers the desktop app's bundled Codex runtime from the standard app locations. It does not search `PATH` or trust environment-variable install roots. On Windows, the caller must pass the exact trusted desktop runtime path explicitly with `--codex-bin`; the manager never automatically executes a discovered file. @@ -33,7 +41,7 @@ The manager reads the active parent model, disables `features.multi_agent_v2`, a Daily tasks must be delegated by the parent Codex agent: ```text -spawn_agent(agent_type="DeepSeekWorker", fork_turns="none", ...) +spawn_agent(agent_type="DeepSeek", fork_turns="none", ...) ``` If the current task does not recognize the custom role, restart Codex and open a new task. The management script is not a fallback coding agent. @@ -48,7 +56,7 @@ If the current task does not recognize the custom role, restart Codex and open a model_provider = deepseek model = deepseek-v4-flash reasoning_effort = high - agent_role = DeepSeekWorker + agent_role = DeepSeek ``` 2. the exact child response `NATIVE_DEEPSEEK_WORKER_OK`. @@ -67,4 +75,4 @@ Do not silently overwrite incompatible existing DeepSeek provider or role config ## Visual inputs -`DeepSeekWorker` is text-only in V1. The parent agent must inspect images, screenshots, or video and provide only the relevant textual facts. The worker must not imply that it saw the original visual input. +`DeepSeek` is text-only in V1. The parent agent must inspect images, screenshots, or video and provide only the relevant textual facts. The subagent must not imply that it saw the original visual input. diff --git a/codex-deepseek-worker/references/worker-routing.md b/codex-deepseek-worker/references/worker-routing.md index 228cc68..15bb199 100644 --- a/codex-deepseek-worker/references/worker-routing.md +++ b/codex-deepseek-worker/references/worker-routing.md @@ -1,6 +1,6 @@ # Worker routing guide -Use `DeepSeekWorker` when the task is bounded, text-only, and benefits from long-context reading, parallel capacity, or an independent model pass. +Use `DeepSeek` when the task is bounded, text-only, and benefits from long-context reading, parallel capacity, or an independent model pass. ## Good worker tasks diff --git a/codex-deepseek-worker/scripts/deepseek_worker.py b/codex-deepseek-worker/scripts/deepseek_worker.py index 79a28ec..9673c47 100644 --- a/codex-deepseek-worker/scripts/deepseek_worker.py +++ b/codex-deepseek-worker/scripts/deepseek_worker.py @@ -41,7 +41,8 @@ MODEL = "deepseek-v4-flash" PROVIDER = "deepseek" -ROLE = "DeepSeekWorker" +ROLE = "DeepSeek" +LEGACY_ROLE = "DeepSeekWorker" EFFORT = "high" MIN_PYTHON = (3, 9) PARENT_MULTI_AGENT_VERSION = "v1" @@ -74,6 +75,7 @@ class Paths: config: Path catalog: Path agent: Path + legacy_agent: Path state_dir: Path manifest: Path @@ -85,6 +87,7 @@ def resolve_paths(codex_home: str | None) -> Paths: config=home / "config.toml", catalog=home / "models-with-deepseek.json", agent=home / "agents" / f"{ROLE}.toml", + legacy_agent=home / "agents" / f"{LEGACY_ROLE}.toml", state_dir=home / "codex-deepseek-worker", manifest=home / "codex-deepseek-worker" / "manifest.json", ) @@ -519,14 +522,20 @@ def remove_table_bool_if_value(text: str, table: str, key: str, expected: bool) return "\n".join(kept).rstrip() + "\n" -def expected_agent_text() -> str: - return f'''name = "{ROLE}" -description = "Text-only DeepSeek worker for bounded repository research, implementation, tests, review, and documentation. Use it when parallel capacity, long-context reading, or an independent model pass adds value. Do not use it for visual inspection or final high-risk decisions." +def agent_text(role: str) -> str: + description_noun = "worker" if role == LEGACY_ROLE else "subagent" + identity = ( + "DeepSeek Worker, a focused text-only coding worker" + if role == LEGACY_ROLE + else "DeepSeek, a focused text-only coding subagent" + ) + return f'''name = "{role}" +description = "Text-only DeepSeek {description_noun} for bounded repository research, implementation, tests, review, and documentation. Use it when parallel capacity, long-context reading, or an independent model pass adds value. Do not use it for visual inspection or final high-risk decisions." model = "{MODEL}" model_provider = "{PROVIDER}" model_reasoning_effort = "{EFFORT}" developer_instructions = """ -You are DeepSeek Worker, a focused text-only coding worker managed by a parent Codex agent. +You are {identity} managed by a parent Codex agent. Work only on the bounded task in the assignment. Inspect relevant files before editing, preserve unrelated user changes, and avoid broad refactors unless explicitly requested. Use available tools when needed. Run the narrowest useful verification and distinguish verified results from assumptions. @@ -539,6 +548,14 @@ def expected_agent_text() -> str: ''' +def expected_agent_text() -> str: + return agent_text(ROLE) + + +def expected_legacy_agent_text() -> str: + return agent_text(LEGACY_ROLE) + + def managed_provider_block() -> str: auth = expected_provider_auth() return f''' @@ -606,12 +623,18 @@ def compatible_existing(parsed: dict[str, Any], paths: Paths) -> tuple[bool, lis issues: list[str] = [] provider = (parsed.get("model_providers") or {}).get(PROVIDER) issues.extend(provider_conflicts(provider)) - agent = (parsed.get("agents") or {}).get(ROLE) - if agent: - if set(agent) - {"description", "config_file"}: - issues.append(f"agents.{ROLE}") - if Path(agent.get("config_file", "")).expanduser() != paths.agent: - issues.append(f"agents.{ROLE}.config_file") + agents = parsed.get("agents") or {} + for role in (ROLE, LEGACY_ROLE): + agent = agents.get(role) + if agent: + if set(agent) - {"description", "config_file"}: + issues.append(f"agents.{role}") + config_file = Path(agent.get("config_file", "")).expanduser() + allowed_paths = {paths.agent} + if role == LEGACY_ROLE: + allowed_paths.add(paths.legacy_agent) + if config_file not in allowed_paths: + issues.append(f"agents.{role}.config_file") return not issues, issues @@ -700,7 +723,7 @@ def make_backup(paths: Paths) -> Path: stamp = datetime.now().strftime("%Y%m%d-%H%M%S-%f") backup = paths.state_dir / "backups" / stamp backup.mkdir(parents=True, exist_ok=False) - for source in (paths.config, paths.catalog, paths.agent, paths.manifest): + for source in (paths.config, paths.catalog, paths.agent, paths.legacy_agent, paths.manifest): if source.is_file(): shutil.copy2(source, backup / source.name) return backup @@ -733,10 +756,28 @@ def install(paths: Paths, codex_bin: str) -> dict[str, Any]: if not compatible: raise ManagerError("conflict", "Found incompatible existing DeepSeek configuration.", {"fields": conflicts}) if paths.agent.is_file() and paths.agent.read_text() != expected_agent_text(): - raise ManagerError("conflict", "The existing DeepSeek worker file differs from the managed configuration.", {"path": str(paths.agent)}) - legacy_role_present = bool((unmanaged_parsed.get("agents") or {}).get(ROLE)) - if legacy_role_present: - unmanaged_config = remove_toml_table(unmanaged_config, f"agents.{ROLE}") + raise ManagerError("conflict", "The existing DeepSeek agent file differs from the managed configuration.", {"path": str(paths.agent)}) + legacy_agent_migratable = False + if paths.legacy_agent.is_file(): + legacy_content = paths.legacy_agent.read_text() + legacy_hash = sha256_text_file(paths.legacy_agent) + previous_managed_legacy = bool( + previous_manifest.get("managed_agent_file") + and legacy_hash == previous_manifest.get("agent_sha256") + ) + exact_legacy_content = legacy_content == expected_legacy_agent_text() + if not previous_managed_legacy and not (not previous_manifest and exact_legacy_content): + raise ManagerError( + "conflict", + "The existing DeepSeekWorker file is not owned by this manager; migration was refused.", + {"path": str(paths.legacy_agent)}, + ) + legacy_agent_migratable = True + legacy_role_present = False + for registered_role in (ROLE, LEGACY_ROLE): + if (unmanaged_parsed.get("agents") or {}).get(registered_role): + unmanaged_config = remove_toml_table(unmanaged_config, f"agents.{registered_role}") + legacy_role_present = True unmanaged_parsed = parse_toml_text(unmanaged_config) if unmanaged_config.strip() else {} catalog_preexisted_now = paths.catalog.is_file() @@ -825,6 +866,8 @@ def install(paths: Paths, codex_bin: str) -> dict[str, Any]: if not paths.agent.is_file(): atomic_write(paths.agent, expected_agent_text().encode(), mode=0o644) atomic_write(paths.config, new_config.encode()) + if legacy_agent_migratable and paths.legacy_agent.is_file(): + paths.legacy_agent.unlink() previous_agent_managed = bool(previous_manifest.get("managed_agent_file")) managed_agent_file = previous_agent_managed or not agent_preexisted_now @@ -835,13 +878,17 @@ def install(paths: Paths, codex_bin: str) -> dict[str, Any]: catalog_original_backup = str(candidate) adopted_existing = provider_exists or agent_preexisted or catalog_preexisted manifest = { - "schema_version": 3, + "schema_version": 4, "installed_at": datetime.now().isoformat(timespec="seconds"), "backup": str(backup), "previous_model_catalog_json": previous_catalog_value, "managed_catalog_selection": managed_catalog_selection, "managed_provider_block": provider_marker_present or not provider_exists, "managed_agent_file": managed_agent_file, + "role": ROLE, + "legacy_agent_migrated": bool( + previous_manifest.get("legacy_agent_migrated") or legacy_agent_migratable + ), "catalog_preexisted": catalog_preexisted, "catalog_original_backup": catalog_original_backup, "agent_preexisted": agent_preexisted, @@ -858,7 +905,11 @@ def install(paths: Paths, codex_bin: str) -> dict[str, Any]: "agent_sha256": sha256_bytes(expected_agent_text().encode()), } write_manifest(paths, manifest) - return {"backup": str(backup), "adopted_existing": adopted_existing} + return { + "backup": str(backup), + "adopted_existing": adopted_existing, + "migrated_role": legacy_agent_migratable, + } except Exception: restore_backup(paths, backup) raise @@ -869,6 +920,9 @@ def static_status(paths: Paths, codex_bin: str | None = None) -> dict[str, Any]: "config_exists": paths.config.is_file(), "catalog_exists": paths.catalog.is_file(), "agent_exists": paths.agent.is_file(), + "legacy_agent_exists": paths.legacy_agent.is_file(), + "role_migration_required": paths.legacy_agent.is_file(), + "role_migration_complete": not paths.legacy_agent.is_file(), "credential_backend": credential_backend(), "credential_present": credential_has_key(), "manifest_exists": paths.manifest.is_file(), @@ -883,7 +937,8 @@ def static_status(paths: Paths, codex_bin: str | None = None) -> dict[str, Any]: checks["config_valid"] = False errors.append(str(exc)) provider = (parsed.get("model_providers") or {}).get(PROVIDER) - role = (parsed.get("agents") or {}).get(ROLE) + registered_roles = parsed.get("agents") or {} + role = registered_roles.get(ROLE) or registered_roles.get(LEGACY_ROLE) checks["provider_registered"] = bool(provider) checks["provider_valid"] = bool(provider) and not provider_conflicts(provider) checks["agent_discovery"] = "standalone" @@ -942,6 +997,7 @@ def static_status(paths: Paths, codex_bin: str | None = None) -> dict[str, Any]: "credential_present", "manifest_exists", "legacy_role_registration_absent", + "role_migration_complete", "desktop_codex_detected", ) ready = all(checks.get(key) is True for key in required) @@ -1144,10 +1200,11 @@ def run_tests(paths: Paths, codex_bin: str) -> dict[str, Any]: def restore_backup(paths: Paths, backup: Path) -> None: - for target in (paths.config, paths.catalog, paths.agent, paths.manifest): + for target in (paths.config, paths.catalog, paths.agent, paths.legacy_agent, paths.manifest): source = backup / target.name if source.is_file(): - atomic_write(target, source.read_bytes(), mode=0o644 if target == paths.agent else 0o600) + agent_mode = target in (paths.agent, paths.legacy_agent) + atomic_write(target, source.read_bytes(), mode=0o644 if agent_mode else 0o600) elif target.is_file(): target.unlink() @@ -1190,12 +1247,15 @@ def disable(paths: Paths) -> dict[str, Any]: if not paths.manifest.is_file(): raise ManagerError("not_managed", "No managed manifest was found; existing configuration was not modified.") manifest = read_manifest(paths) - if manifest.get("managed_agent_file") and paths.agent.is_file(): - if sha256_text_file(paths.agent) != manifest.get("agent_sha256"): + managed_agent = paths.agent + if not managed_agent.is_file() and manifest.get("schema_version", 1) < 4: + managed_agent = paths.legacy_agent + if manifest.get("managed_agent_file") and managed_agent.is_file(): + if sha256_text_file(managed_agent) != manifest.get("agent_sha256"): raise ManagerError( "conflict", - "The managed DeepSeek worker file was modified; disable was refused.", - {"path": str(paths.agent)}, + "The managed DeepSeek agent file was modified; disable was refused.", + {"path": str(managed_agent)}, ) changed = False if paths.config.is_file(): @@ -1216,8 +1276,8 @@ def disable(paths: Paths) -> dict[str, Any]: parse_toml_text(updated) atomic_write(paths.config, updated.encode()) changed = True - if manifest.get("managed_agent_file") and paths.agent.is_file(): - paths.agent.unlink() + if manifest.get("managed_agent_file") and managed_agent.is_file(): + managed_agent.unlink() changed = True return result( "disabled", diff --git a/scripts/test_worker_manager.py b/scripts/test_worker_manager.py index 501b71c..3335e86 100644 --- a/scripts/test_worker_manager.py +++ b/scripts/test_worker_manager.py @@ -145,7 +145,9 @@ def test_parent_model_has_no_hardcoded_fallback(self) -> None: def test_agent_is_standalone_text_only_high_reasoning(self) -> None: text = manager.expected_agent_text() - self.assertEqual(manager.ROLE, "DeepSeekWorker") + self.assertEqual(manager.ROLE, "DeepSeek") + self.assertIn('name = "DeepSeek"', text) + self.assertNotIn("DeepSeekWorker", text) self.assertIn('model_provider = "deepseek"', text) self.assertIn('model_reasoning_effort = "high"', text) self.assertIn("text-only", text) @@ -178,6 +180,28 @@ def test_disable_accepts_windows_line_endings_in_managed_agent(self) -> None: self.assertTrue(result["changed"]) self.assertFalse(paths.agent.exists()) + def test_disable_removes_managed_legacy_agent_before_migration(self) -> None: + with tempfile.TemporaryDirectory() as directory, mock.patch.object( + manager, + "credential_has_key", + return_value=False, + ): + paths = manager.resolve_paths(directory) + legacy_text = manager.expected_legacy_agent_text() + paths.legacy_agent.parent.mkdir(parents=True, exist_ok=True) + paths.legacy_agent.write_text(legacy_text) + manager.write_manifest( + paths, + { + "schema_version": 3, + "managed_agent_file": True, + "agent_sha256": manager.sha256_bytes(legacy_text.encode()), + }, + ) + result = manager.disable(paths) + self.assertTrue(result["changed"]) + self.assertFalse(paths.legacy_agent.exists()) + def test_provider_auth_validation_checks_every_field(self) -> None: provider = { "name": "DeepSeek", @@ -350,6 +374,39 @@ def test_static_status_is_configured_with_complete_codex_home(self) -> None: self.assertTrue(status["checks"]["desktop_codex_detected"]) self.assertTrue(status["checks"]["provider_valid"]) + def test_status_reports_legacy_role_migration_required(self) -> None: + with tempfile.TemporaryDirectory() as directory, mock.patch.object( + manager, + "credential_has_key", + return_value=True, + ), mock.patch.object(manager, "codex_version_text", return_value="codex-cli test"): + paths = manager.resolve_paths(directory) + paths.config.parent.mkdir(parents=True, exist_ok=True) + paths.config.write_text( + 'model = "gpt-5.6-sol"\n' + f"model_catalog_json = {manager.toml_string(str(paths.catalog))}\n" + "[features]\n" + "multi_agent_v2 = false\n" + + manager.managed_provider_block() + ) + paths.catalog.write_text( + json.dumps( + { + "models": [ + {"slug": "gpt-5.6-sol", "multi_agent_version": manager.PARENT_MULTI_AGENT_VERSION}, + {"slug": manager.MODEL}, + ] + } + ) + ) + paths.legacy_agent.parent.mkdir(parents=True, exist_ok=True) + paths.legacy_agent.write_text(manager.expected_legacy_agent_text()) + manager.write_manifest(paths, {"schema_version": 3}) + status = manager.static_status(paths, "desktop-codex") + self.assertEqual(status["status"], "partial") + self.assertTrue(status["checks"]["legacy_agent_exists"]) + self.assertTrue(status["checks"]["role_migration_required"]) + def test_native_test_uses_fresh_session_without_catalog_overrides(self) -> None: with tempfile.TemporaryDirectory() as directory: paths = manager.resolve_paths(directory) @@ -767,6 +824,88 @@ def test_repair_switching_parent_restores_old_version_and_records_new_original(s self.assertEqual(manifest["parent_multi_agent_version"], manager.PARENT_MULTI_AGENT_VERSION) self.assertEqual(manifest["parent_original_multi_agent_version"], "original-terra") + def test_repair_migrates_managed_deepseek_worker_to_deepseek(self) -> None: + with tempfile.TemporaryDirectory() as directory: + paths = manager.resolve_paths(directory) + legacy_agent = paths.home / "agents" / "DeepSeekWorker.toml" + legacy_text = ( + manager.expected_agent_text() + .replace('name = "DeepSeek"', 'name = "DeepSeekWorker"', 1) + .replace("Text-only DeepSeek subagent", "Text-only DeepSeek worker", 1) + .replace( + "You are DeepSeek, a focused text-only coding subagent", + "You are DeepSeek Worker, a focused text-only coding worker", + 1, + ) + ) + paths.config.parent.mkdir(parents=True, exist_ok=True) + paths.config.write_text('model = "gpt-5.6-sol"\n') + legacy_agent.parent.mkdir(parents=True, exist_ok=True) + legacy_agent.write_text(legacy_text) + manager.write_manifest( + paths, + { + "schema_version": 3, + "managed_agent_file": True, + "agent_preexisted": False, + "agent_sha256": manager.sha256_bytes(legacy_text.encode()), + }, + ) + with mock.patch.object( + manager, + "fetch_official_deepseek_model", + return_value={"slug": manager.MODEL}, + ), mock.patch.object( + manager, + "load_base_catalog", + return_value={"models": [{"slug": "gpt-5.6-sol"}]}, + ): + result = manager.install(paths, "codex") + self.assertTrue(result["migrated_role"]) + self.assertTrue(paths.agent.is_file()) + self.assertEqual(paths.agent.read_text(), manager.expected_agent_text()) + self.assertFalse(legacy_agent.exists()) + manifest = manager.read_manifest(paths) + self.assertEqual(manifest["schema_version"], 4) + self.assertEqual(manifest["role"], "DeepSeek") + self.assertTrue(manifest["legacy_agent_migrated"]) + self.assertTrue((Path(result["backup"]) / "DeepSeekWorker.toml").is_file()) + + def test_failed_role_migration_restores_legacy_agent(self) -> None: + with tempfile.TemporaryDirectory() as directory: + paths = manager.resolve_paths(directory) + legacy_text = manager.expected_legacy_agent_text() + paths.config.parent.mkdir(parents=True, exist_ok=True) + paths.config.write_text('model = "gpt-5.6-sol"\n') + paths.legacy_agent.parent.mkdir(parents=True, exist_ok=True) + paths.legacy_agent.write_text(legacy_text) + manager.write_manifest( + paths, + { + "schema_version": 3, + "managed_agent_file": True, + "agent_preexisted": False, + "agent_sha256": manager.sha256_bytes(legacy_text.encode()), + }, + ) + with mock.patch.object( + manager, + "fetch_official_deepseek_model", + return_value={"slug": manager.MODEL}, + ), mock.patch.object( + manager, + "load_base_catalog", + return_value={"models": [{"slug": "gpt-5.6-sol"}]}, + ), mock.patch.object( + manager, + "write_manifest", + side_effect=OSError("injected manifest failure"), + ): + with self.assertRaises(OSError): + manager.install(paths, "codex") + self.assertEqual(paths.legacy_agent.read_text(), legacy_text) + self.assertFalse(paths.agent.exists()) + def test_install_removes_legacy_role_marker_and_uses_standalone_agent(self) -> None: with tempfile.TemporaryDirectory() as directory: paths = manager.resolve_paths(directory)