diff --git a/docs/engineering/codex_dgx_worker.md b/docs/engineering/codex_dgx_worker.md index c738649fe..6ca2ce5d8 100644 --- a/docs/engineering/codex_dgx_worker.md +++ b/docs/engineering/codex_dgx_worker.md @@ -17,6 +17,22 @@ transcript separately. The current Tasks form's assignee picker offers only Unassigned and Von; it does not yet offer this worker. The pilot acceptance used labelled fixtures through canonical services, not that picker. +Native coding tasks can specify `requested_model` (an exact Codex model ID, +for example `gpt-6-astra`) and `requested_reasoning_effort` (for example `high`). +Ask Von to include those fields when creating the task or set them through +`task_update_fields`. Each omitted or cleared field independently inherits the +worker configuration. The worker's built-in defaults are Astra/medium; an +operator can configure a different permitted default. These are execution +preferences on the canonical task, not settings inferred from historical +conversation text. Changes apply at the next launch, not midway through a run. + +The launch passes both resolved values explicitly to Codex and retains their +values and sources in `execution_settings` in the run context/checkpoint and +result message. Unsupported selections fail visibly through Codex; the worker +does not substitute another model or reasoning level. The standing Sol block +also applies to task overrides. Reply-only inbox runs use operator defaults +because task selection happens within that reply. + The worker checks at the operator-configured interval, starts at most one coding run per check, and sends a pickup message followed by a result or question through Von direct messages. An empty check makes no model request. Each task has its own Git worktree. Each @@ -148,7 +164,8 @@ A JSON config has these fields (paths are operator-selected): "state_root": "/home/mjw/.codex-von-worker/worker-state", "source_repo": "/home/mjw/von-codex-runtime", "codex_command": "/home/mjw/.local/bin/von-codex", - "model": "gpt-5.6-terra", + "model": "gpt-6-astra", + "model_reasoning_effort": "medium", "permission_profile": "von-coding", "github_command": "/home/mjw/.local/bin/gh", "git_author_name": "Your configured commit author", diff --git a/scripts/codex_von_inbox.py b/scripts/codex_von_inbox.py index 714130d4e..40771a5fe 100644 --- a/scripts/codex_von_inbox.py +++ b/scripts/codex_von_inbox.py @@ -9,9 +9,19 @@ from pathlib import Path try: - from .codex_von_worker import authorised_task, fingerprint, write_json + from .codex_von_worker import ( + authorised_task, + fingerprint, + resolve_execution_settings, + write_json, + ) except ImportError: - from codex_von_worker import authorised_task, fingerprint, write_json + from codex_von_worker import ( + authorised_task, + fingerprint, + resolve_execution_settings, + write_json, + ) SCHEMA = { "type": "object", @@ -154,12 +164,15 @@ def launch(config, state, lock_fd): write_json(run / "context.json", state["context"]) prompt = Path(__file__).with_name("codex_von_inbox_prompt.md").read_text() prompt += "\nContext for this reply:\n" + json.dumps(state["context"], default=str) + settings = resolve_execution_settings(config, {}) args = [ config["codex_command"], "exec", "--strict-config", "--model", - config["model"], + settings["model"], + "-c", + "model_reasoning_effort=" + json.dumps(settings["reasoning_effort"]), "--sandbox", "read-only", "--cd", diff --git a/scripts/codex_von_worker.py b/scripts/codex_von_worker.py index f02e39aab..adcc2f2af 100755 --- a/scripts/codex_von_worker.py +++ b/scripts/codex_von_worker.py @@ -53,6 +53,33 @@ def fingerprint(value): ).hexdigest() +def resolve_execution_settings(config, inputs): + """Resolve task preferences without substituting another requested model.""" + resolved = {} + for name, default in (("model", "gpt-6-astra"), ("reasoning_effort", "medium")): + requested = inputs.get("requested_" + name) + configured = config.get( + "model" if name == "model" else "model_reasoning_effort" + ) + value = requested if requested is not None else configured + value = default if value is None else value + if ( + not isinstance(value, str) + or not value.strip() + or any(c.isspace() for c in value.strip()) + ): + raise ValueError( + f"Invalid {name}: specify one exact model or reasoning level" + ) + resolved[name] = value.strip() + resolved[name + "_source"] = ( + "task" if requested is not None else "worker_default" + ) + if "sol" in resolved["model"].lower(): + raise ValueError("Sol-family models are disabled for this worker") + return resolved + + def authorised_task(task, config): return ( task.get("assignee_concept_id") == config["agent_id"] @@ -171,6 +198,8 @@ def inputs(self, task): return { "title": task["title"], "description": task.get("description"), + "requested_model": task.get("requested_model"), + "requested_reasoning_effort": task.get("requested_reasoning_effort"), "comments": comments, "replies": [ message for message in replies if not self.inbox_answered(message) @@ -320,6 +349,13 @@ def finish(self, state): f"DGX worktree: {state.get('worktree', 'not started')}\n" f"Run: {state['attempt']}" ).strip() + if state.get("execution_settings"): + settings = state["execution_settings"] + content += ( + f"\nExecution model: {settings['model']} ({settings['model_source']}); " + f"reasoning: {settings['reasoning_effort']} " + f"({settings['reasoning_effort_source']})." + ) # Keep the delivery intent stable across a crash after a task transition. # A changed/cancelled task is left untouched; the run evidence still goes # to the original authorised delegator. @@ -400,6 +436,12 @@ def launch(config, state, inputs, conversation, state_path, lock_fd): state.pop("report_task", None) state.pop("report_content", None) state.pop("deployment_final", None) + state.pop("execution_settings", None) + write_json(state_path, state) + settings = resolve_execution_settings(config, inputs) + state["execution_settings"] = settings + context["execution_settings"] = settings + write_json(context_path, context) write_json(state_path, state) if not state.get("checkout_prepared"): worktree.parent.mkdir(parents=True, exist_ok=True) @@ -471,7 +513,9 @@ def launch(config, state, inputs, conversation, state_path, lock_fd): "exec", "--strict-config", "--model", - config["model"], + settings["model"], + "-c", + "model_reasoning_effort=" + json.dumps(settings["reasoning_effort"]), "--cd", str(worktree), "--json", @@ -670,7 +714,7 @@ def tick(config, api, lock_fd): ) try: launch(config, state, inputs, conversation, path, lock_fd) - except (OSError, subprocess.CalledProcessError) as exc: + except (OSError, subprocess.CalledProcessError, ValueError) as exc: if "run_dir" not in state: raise write_json( @@ -678,6 +722,13 @@ def tick(config, api, lock_fd): {"error_type": type(exc).__name__}, ) recover_result(state) + if isinstance(exc, ValueError): + state["result"]["summary"] = ( + "Coding execution settings rejected: " + str(exc) + ) + state["result"][ + "question" + ] = "Correct this task's model or reasoning setting, then requeue it." write_json(path, state) apply_deployment(config, api, state, path, lock_fd) api.finish(state) @@ -702,8 +753,7 @@ def main(): options = parser.parse_args() os.umask(0o077) config = json.loads(Path(options.config).read_text()) - if "sol" in config["model"].lower(): - raise ValueError("Sol-family models are disabled for this worker") + resolve_execution_settings(config, {}) state_root = Path(config["state_root"]) state_root.mkdir(parents=True, exist_ok=True) with (state_root / "worker.lock").open("a") as lock: diff --git a/src/backend/integrations/internal_mcp/catalogue.py b/src/backend/integrations/internal_mcp/catalogue.py index 8a48a202f..fccdf38e9 100644 --- a/src/backend/integrations/internal_mcp/catalogue.py +++ b/src/backend/integrations/internal_mcp/catalogue.py @@ -35235,6 +35235,11 @@ def _task_create(**kwargs): evidence = kwargs.get("evidence") notes = kwargs.get("notes") reference_code = kwargs.get("reference_code") + requested_settings = { + key: (value.strip() or None) if isinstance(value, str) else value + for key in ("requested_model", "requested_reasoning_effort") + if (value := kwargs.get(key)) is not None + } request_id = str(kwargs.get("request_id") or "").strip() idempotency_key = str(kwargs.get("idempotency_key") or "").strip() @@ -35315,6 +35320,7 @@ def _task_create(**kwargs): "evidence": evidence, "notes": notes, "reference_code": reference_code, + **requested_settings, } creation_fingerprint = hashlib.sha256( json.dumps( @@ -35330,6 +35336,7 @@ def _task_create(**kwargs): "description": description.strip(), "status": "pending", "priority": priority, + **requested_settings, } def _canonical_read_back_mismatches(task: dict[str, Any]) -> dict[str, Any]: @@ -35456,6 +35463,7 @@ def _reconcile_after_create_failure() -> dict[str, Any] | None: evidence=evidence, notes=notes, reference_code=reference_code, + **requested_settings, project_concept_id=kwargs.get("project_concept_id"), collection_concept_ids=kwargs.get("collection_concept_ids"), agent_creation_fingerprint=creation_fingerprint, @@ -36952,6 +36960,8 @@ def _task_update_fields(**kwargs): "priority", "task_type_ids", "task_role", + "requested_model", + "requested_reasoning_effort", "next_checkpoint", "progress_signal", "evidence", @@ -44886,6 +44896,8 @@ def _build_default_catalogue_task_and_workflow_definitions() -> List[MethodDefin "report_to_concept_id": (str, type(None)), "reports_to_concept_id": (str, type(None)), "task_role": (str, type(None)), + "requested_model": (str, type(None)), + "requested_reasoning_effort": (str, type(None)), "next_checkpoint": (str, type(None)), "progress_signal": (str, type(None)), "evidence": (str, type(None)), @@ -44934,7 +44946,11 @@ def _build_default_catalogue_task_and_workflow_definitions() -> List[MethodDefin "assignee_concept_id when reporting responsibility. " "Priority: low, medium, high, critical. Tasks start in 'pending' status and can include " "planning metadata (components, fix versions, sprint values, backlog rank), canonical " - "task categories, source semantics, and richer task-detail fields." + "task categories, source semantics, and richer task-detail fields. " + "For coding-agent work, preserve a user-specified model and reasoning level in " + "requested_model (exact model ID, e.g. gpt-6-astra) and " + "requested_reasoning_effort (e.g. medium or high). Omit either to inherit " + "the coding worker default. These fields do not execute the task." ), ), MethodDefinition( @@ -45132,13 +45148,15 @@ def _build_default_catalogue_task_and_workflow_definitions() -> List[MethodDefin "Update multiple task fields in one operation. " "Supported fields: status, assignee_concept_id, created_by_concept_id, " "title, description, priority, task_type_ids, task_source_id, " - "report_to_concept_id, task_role, next_checkpoint, progress_signal, " + "report_to_concept_id, task_role, requested_model, requested_reasoning_effort, " + "next_checkpoint, progress_signal, " "evidence, notes, reference_code, start_date, due_date, labels, " "components, fix_versions, sprint_values, backlog_rank, " "reporter_concept_id, watcher_concept_ids, parent_task_concept_id, " "epic_task_concept_id, current_work_product_concept_id. " "For ordinary continuation use title, description, status, priority, " - "task_type_ids, task_role, next_checkpoint, progress_signal, evidence, " + "task_type_ids, task_role, requested_model, requested_reasoning_effort, " + "next_checkpoint, progress_signal, evidence, " "notes, start_date, due_date, current_work_product_concept_id, " "assignee_concept_id (self or #V#von_system, creator only), and " "report_to_concept_id (self)." diff --git a/src/backend/mcp_server/mcp_stdio_server.py b/src/backend/mcp_server/mcp_stdio_server.py index a94ec8e5d..4c4abd6ef 100644 --- a/src/backend/mcp_server/mcp_stdio_server.py +++ b/src/backend/mcp_server/mcp_stdio_server.py @@ -4722,6 +4722,8 @@ async def _handle_create_task(arguments: dict[str, Any]) -> list[TextContent]: evidence=arguments.get("evidence"), notes=arguments.get("notes"), reference_code=arguments.get("reference_code"), + requested_model=arguments.get("requested_model"), + requested_reasoning_effort=arguments.get("requested_reasoning_effort"), project_concept_id=arguments.get("project_concept_id"), collection_concept_ids=arguments.get("collection_concept_ids"), ) diff --git a/src/backend/mcp_server/vontology_mcp.json b/src/backend/mcp_server/vontology_mcp.json index 92e6c0efb..b22c328f0 100644 --- a/src/backend/mcp_server/vontology_mcp.json +++ b/src/backend/mcp_server/vontology_mcp.json @@ -1505,6 +1505,14 @@ }, "description": "Collections associated with the selected project." }, + "requested_model": { + "type": ["string", "null"], + "description": "Exact coding model requested by the user, e.g. gpt-6-astra. Omit or clear to inherit the worker default." + }, + "requested_reasoning_effort": { + "type": ["string", "null"], + "description": "Coding reasoning level requested by the user, e.g. medium or high. Omit or clear to inherit the worker default." + }, "title": { "type": "string", "description": "Brief task title (required)" diff --git a/src/backend/server/routes/task_routes.py b/src/backend/server/routes/task_routes.py index 4b8eae2d4..5ad8c15c5 100644 --- a/src/backend/server/routes/task_routes.py +++ b/src/backend/server/routes/task_routes.py @@ -370,6 +370,8 @@ def create_task_route() -> ResponseReturnValue: evidence=data.get("evidence"), notes=data.get("notes"), reference_code=data.get("reference_code"), + requested_model=data.get("requested_model"), + requested_reasoning_effort=data.get("requested_reasoning_effort"), project_concept_id=data.get("project_concept_id"), collection_concept_ids=data.get("collection_concept_ids"), ) diff --git a/src/backend/services/task_management_service.py b/src/backend/services/task_management_service.py index 94650b9ef..3bba807ce 100644 --- a/src/backend/services/task_management_service.py +++ b/src/backend/services/task_management_service.py @@ -1299,6 +1299,8 @@ def create_task( evidence: str | None = None, notes: str | None = None, reference_code: str | None = None, + requested_model: str | None = None, + requested_reasoning_effort: str | None = None, agent_creation_fingerprint: str | None = None, agent_creation_request_id: str | None = None, project_concept_id: str | None = None, @@ -1396,6 +1398,12 @@ def create_task( reference_code, field_name="reference_code", ) + requested_model = _normalise_optional_text( + requested_model, field_name="requested_model" + ) + requested_reasoning_effort = _normalise_optional_text( + requested_reasoning_effort, field_name="requested_reasoning_effort" + ) agent_creation_fingerprint = _normalise_optional_text( agent_creation_fingerprint, field_name="agent_creation_fingerprint", @@ -1537,6 +1545,8 @@ def create_task( TASK_METADATA_KEY_BULK_TASK_COLLECTIONS: [], TASK_METADATA_KEY_CREATION_FINGERPRINT: agent_creation_fingerprint, TASK_METADATA_KEY_CREATION_REQUEST_ID: agent_creation_request_id, + "requested_model": requested_model, + "requested_reasoning_effort": requested_reasoning_effort, "project_concept_id": project_concept_id, "collection_concept_ids": collection_concept_ids or [], }, @@ -1657,6 +1667,8 @@ def create_task( "evidence": evidence, "notes": notes, "reference_code": reference_code, + "requested_model": requested_model, + "requested_reasoning_effort": requested_reasoning_effort, "created_at": now.isoformat(), "required_text_persistence_failures": required_text_persistence_failures, } @@ -2163,6 +2175,8 @@ def _build_task_response( "evidence": evidence, "notes": notes, "reference_code": reference_code, + "requested_model": metadata.get("requested_model"), + "requested_reasoning_effort": metadata.get("requested_reasoning_effort"), "parent_task_concept_id": parent_task_id, "epic_task_concept_id": epic_task_id, "subtask_concept_ids": subtask_ids, @@ -4744,6 +4758,12 @@ def update_task_fields( raise InvalidTaskDataError("fields must be a non-empty dict") existing_task = _build_task_response(task_doc) + # Validate these settings before changing any of the requested task fields. + execution_settings = { + key: _normalise_optional_text(fields[key], field_name=key) + for key in ("requested_model", "requested_reasoning_effort") + if key in fields + } membership = None if "project_concept_id" in fields or "collection_concept_ids" in fields: from .task_project_service import validate_task_membership @@ -4860,6 +4880,14 @@ def update_task_fields( if next_start is not None and next_due is not None and next_start > next_due: raise InvalidTaskDataError("start_date must be before or equal to due_date") + for key, value in execution_settings.items(): + if existing_task.get(key) != value: + ConceptsRepository.update_one( + {"concept_id": task_concept_id}, + {"$set": {f"metadata.{key}": value}}, + ) + changed_fields.append(key) + if product_field_present: _replace_single_relationship_target( task_concept_id=task_concept_id, @@ -5360,6 +5388,8 @@ def update_task_fields( "evidence", "notes", "reference_code", + "requested_model", + "requested_reasoning_effort", "start_date", "due_date", "labels", diff --git a/tests/backend/test_codex_von_inbox.py b/tests/backend/test_codex_von_inbox.py index 2aa033109..78216488a 100644 --- a/tests/backend/test_codex_von_inbox.py +++ b/tests/backend/test_codex_von_inbox.py @@ -280,6 +280,7 @@ def test_inbox_launch_is_read_only_and_inherits_the_single_worker_lock( def run(args, **kwargs): assert args[args.index("--sandbox") + 1] == "read-only" assert kwargs["pass_fds"] == (17,) + assert 'model_reasoning_effort="medium"' in args assert not set(kwargs["env"]) & {"OPENAI_API_KEY", "MONGO_URI"} output = inbox.Path(args[args.index("--output-last-message") + 1]) inbox.write_json( diff --git a/tests/backend/test_codex_von_worker.py b/tests/backend/test_codex_von_worker.py index b75c63ca1..989bbf0e3 100644 --- a/tests/backend/test_codex_von_worker.py +++ b/tests/backend/test_codex_von_worker.py @@ -62,6 +62,50 @@ def test_empty_poll_has_no_model_or_message(config, monkeypatch): worker.tick(config, SimpleNamespace(pending=list), 0) +@pytest.mark.parametrize( + "requested,expected", + [ + ({}, ("gpt-6-astra", "medium")), + ({"requested_model": "gpt-5.6-terra"}, ("gpt-5.6-terra", "medium")), + ({"requested_reasoning_effort": "high"}, ("gpt-6-astra", "high")), + ( + {"requested_model": None, "requested_reasoning_effort": None}, + ("gpt-6-astra", "medium"), + ), + ], +) +def test_task_model_settings_inherit_independently(requested, expected): + resolved = worker.resolve_execution_settings({}, requested) + assert (resolved["model"], resolved["reasoning_effort"]) == expected + for key in ("model", "reasoning_effort"): + assert resolved[key + "_source"] == ( + "task" + if requested.get("requested_" + key) is not None + else "worker_default" + ) + + +def test_forbidden_task_model_is_reported_without_running_codex(config, monkeypatch): + monkeypatch.setattr( + worker.subprocess, "run", lambda *a, **kw: pytest.fail("model invoked") + ) + reports = [] + api = SimpleNamespace( + pending=lambda: [task(config)], + inputs=lambda _: {"requested_model": "gpt-5.6-sol"}, + conversation=lambda _: {"available": False}, + send=lambda *a: "#V#started", + tasks=SimpleNamespace(update_task_status=lambda *a, **kw: None), + finish=lambda state: ( + reports.append(state["result"].copy()), + state.update(phase="waiting", message_id="#V#result"), + ), + ) + worker.tick(config, api, 0) + assert reports[0]["status"] == "blocked" + assert "Sol-family" in reports[0]["summary"] + + @pytest.mark.parametrize( "request_deployment,changed,interrupted,expected", [ @@ -352,6 +396,8 @@ def test_interrupted_clone_recovers_with_independent_metadata_and_no_service_sec assert 'MONGO_URI' not in os.environ assert '--sandbox' not in sys.argv assert 'default_permissions="test-profile"' in sys.argv +assert sys.argv[sys.argv.index('--model') + 1] == 'gpt-6-astra' +assert 'model_reasoning_effort="high"' in sys.argv result = Path(sys.argv[sys.argv.index('--output-last-message') + 1]) result.write_text(json.dumps({'status':'completed','summary':'Fixture ran','evidence':'Checked','question':''})) """) @@ -369,6 +415,7 @@ def test_interrupted_clone_recovers_with_independent_metadata_and_no_service_sec ) state = {"task_id": "#V#task"} state_path = tmp_path / "state.json" + inputs = {"requested_model": "gpt-6-astra", "requested_reasoning_effort": "high"} def fail_fetch(args, **kwargs): if "fetch" in args: @@ -378,13 +425,17 @@ def fail_fetch(args, **kwargs): with (tmp_path / "lock").open("w") as lock: monkeypatch.setattr(worker, "command", fail_fetch) with pytest.raises(OSError): - worker.launch(config, state, {}, {}, state_path, lock.fileno()) + worker.launch(config, state, inputs, {}, state_path, lock.fileno()) assert (Path(state["worktree"]) / ".git").is_dir() assert not state.get("checkout_prepared") monkeypatch.setattr(worker, "command", run) - worker.launch(config, state, {}, {}, state_path, lock.fileno()) + worker.launch(config, state, inputs, {}, state_path, lock.fileno()) checkout = Path(state["worktree"]) assert state["result"]["status"] == "completed" + assert state["execution_settings"]["model"] == "gpt-6-astra" + assert state["execution_settings"]["reasoning_effort"] == "high" + context = json.loads((Path(state["run_dir"]) / "context.json").read_text()) + assert context["execution_settings"] == state["execution_settings"] assert state["checkout_prepared"] assert (checkout / "AGENTS.md").read_text() == (source / "AGENTS.md").read_text() assert run(["git", "-C", str(checkout), "remote", "get-url", "origin"]) == str( diff --git a/tests/backend/test_internal_mcp_direct_message_tools.py b/tests/backend/test_internal_mcp_direct_message_tools.py index 87aa1bc50..a28edb0af 100644 --- a/tests/backend/test_internal_mcp_direct_message_tools.py +++ b/tests/backend/test_internal_mcp_direct_message_tools.py @@ -453,7 +453,11 @@ def fake_find(**kwargs): def fake_create(**kwargs): create_calls["count"] += 1 - task = _owned_task() + task = { + **_owned_task(), + "requested_model": kwargs.get("requested_model"), + "requested_reasoning_effort": kwargs.get("requested_reasoning_effort"), + } persisted.update( { "task": task, @@ -475,6 +479,8 @@ def fake_create(**kwargs): ) arguments = { "title": "Agent task", + "requested_model": "gpt-6-astra", + "requested_reasoning_effort": "high", "description": "Test", "assignee_id": "#V#user_alice", "created_by_concept_id": "#V#user_alice", @@ -492,6 +498,8 @@ def fake_create(**kwargs): assert second["changed"] is False assert second["idempotent_replay"] is True assert create_calls["count"] == 1 + assert first["requested_model"] == "gpt-6-astra" + assert second["requested_reasoning_effort"] == "high" def test_task_create_actor_scoped_idempotency_key_reuses_task_without_turn( diff --git a/tests/backend/test_task_continuation_mcp.py b/tests/backend/test_task_continuation_mcp.py index bacbdb85d..40db940f0 100644 --- a/tests/backend/test_task_continuation_mcp.py +++ b/tests/backend/test_task_continuation_mcp.py @@ -84,6 +84,8 @@ def test_ordinary_continuation_links_product_and_delegates_without_duplicate_wri assignee_concept_id="#V#von_system", report_to_concept_id="#V#alice", next_checkpoint="Continue from the saved backlog checkpoint", + requested_model="gpt-6-astra", + requested_reasoning_effort="high", ) assert payload["actor_bound_continuation"] is True assert payload["actor_concept_id"] == "#V#alice" @@ -91,6 +93,8 @@ def test_ordinary_continuation_links_product_and_delegates_without_duplicate_wri first = handlers._task_update_fields(**payload) repeat = handlers._task_update_fields(**payload) assert first["effect_status"] == "succeeded" + assert task["requested_model"] == "gpt-6-astra" + assert task["requested_reasoning_effort"] == "high" assert ( first["canonical_read_back"]["task"]["current_work_product"]["status"] == "ready" diff --git a/tests/backend/test_task_management_service.py b/tests/backend/test_task_management_service.py index a4890bbf3..2090d8825 100644 --- a/tests/backend/test_task_management_service.py +++ b/tests/backend/test_task_management_service.py @@ -325,6 +325,8 @@ def test_create_task_with_all_params( evidence="Printed document", notes="Needs a coloured copy", reference_code="TASK-001", + requested_model="gpt-6-astra", + requested_reasoning_effort="high", ) assert result["title"] == "Full Task" @@ -340,6 +342,11 @@ def test_create_task_with_all_params( assert result["evidence"] == "Printed document" assert result["notes"] == "Needs a coloured copy" assert result["reference_code"] == "TASK-001" + assert result["requested_model"] == "gpt-6-astra" + assert result["requested_reasoning_effort"] == "high" + metadata = mock_repo.insert_one.call_args.args[0]["metadata"] + assert metadata["requested_model"] == "gpt-6-astra" + assert metadata["requested_reasoning_effort"] == "high" stored_predicates = { call.kwargs.get("predicate") for call in mock_upsert.call_args_list } @@ -2332,3 +2339,62 @@ def test_comment_attachment_and_worklog_preserve_source_metadata( == "2026-04-01T09:00:00+00:00" for entry in pushed_entries ) + + +def test_execution_preferences_round_trip_update_clear_and_preserve_other_metadata( + monkeypatch, +): + from src.backend.services import task_management_service as tasks + + doc = { + "concept_id": "#V#task_model_settings", + "relationships": {"is_an_instance_of": [TASK_SPECIFICATION_TYPE_ID]}, + "metadata": { + "requested_model": "gpt-6-astra", + "requested_reasoning_effort": "high", + "labels": ["keep"], + }, + } + monkeypatch.setattr(tasks, "_get_task_doc", lambda _: (doc["concept_id"], doc)) + monkeypatch.setattr(tasks, "get_texts_for_concept", lambda *a, **kw: []) + monkeypatch.setattr(tasks, "_append_task_history_event", lambda **kw: None) + writes = [] + + def update(query, operation): + assert query == {"concept_id": doc["concept_id"]} + writes.append(operation) + for key, value in operation.get("$set", {}).items(): + if key.startswith("metadata."): + doc["metadata"][key.removeprefix("metadata.")] = value + else: + doc[key] = value + + monkeypatch.setattr(tasks.ConceptsRepository, "update_one", update) + assert tasks.get_task(doc["concept_id"])["requested_reasoning_effort"] == "high" + result = tasks.update_task_fields( + doc["concept_id"], fields={"requested_reasoning_effort": " medium "} + ) + assert result["changed_fields"] == ["requested_reasoning_effort"] + assert result["task"]["requested_model"] == "gpt-6-astra" + assert result["task"]["requested_reasoning_effort"] == "medium" + assert not result["warnings"] + cleared = tasks.update_task_fields( + doc["concept_id"], + fields={"requested_model": None, "requested_reasoning_effort": ""}, + ) + assert cleared["task"]["requested_model"] is None + assert cleared["task"]["requested_reasoning_effort"] is None + assert doc["metadata"]["labels"] == ["keep"] + count = len(writes) + assert not tasks.update_task_fields( + doc["concept_id"], fields={"requested_model": None} + )["changed_fields"] + assert len(writes) == count + with pytest.raises( + InvalidTaskDataError, match="requested_reasoning_effort must be a string" + ): + tasks.update_task_fields( + doc["concept_id"], + fields={"status": "completed", "requested_reasoning_effort": 3}, + ) + assert len(writes) == count diff --git a/tests/backend/test_task_routes_parity_fields.py b/tests/backend/test_task_routes_parity_fields.py index 348b33d1b..a8273bb8b 100644 --- a/tests/backend/test_task_routes_parity_fields.py +++ b/tests/backend/test_task_routes_parity_fields.py @@ -82,6 +82,8 @@ def _fake_create_task(**kwargs): "evidence": "Printed document", "notes": "Needs a coloured copy", "reference_code": "TASK-001", + "requested_model": "gpt-6-astra", + "requested_reasoning_effort": "high", }, ) @@ -97,6 +99,8 @@ def _fake_create_task(**kwargs): assert captured["task_source_id"] == "#V#jira_imported_task_source" assert captured["report_to_concept_id"] == "#V#user_manager" assert captured["task_role"] == "Communicator" + assert captured["requested_model"] == "gpt-6-astra" + assert captured["requested_reasoning_effort"] == "high" assert captured["next_checkpoint"] == "Tomorrow morning" assert captured["progress_signal"] == "Confirmed by chat" assert captured["evidence"] == "Printed document"