diff --git a/tests/cron/test_cron_context_from.py b/tests/cron/test_cron_context_from.py index e3103f76fe..046d41f1e4 100644 --- a/tests/cron/test_cron_context_from.py +++ b/tests/cron/test_cron_context_from.py @@ -266,3 +266,125 @@ class TestBuildJobPromptContextFrom: # Should not crash and should not inject anything malicious assert "Process" in prompt assert "etc/passwd" not in prompt + + + +class TestUpdateContextFrom: + """Verify the cronjob tool's `update` action wires context_from through. + + Without this, the create-path stores the field but users can never modify + or clear it via the tool (schema promises "pass an empty array to clear"). + """ + + def test_update_adds_context_from_to_existing_job(self, cron_env): + from cron.jobs import create_job, get_job + from tools.cronjob_tools import cronjob + import json + + job_a = create_job(prompt="Find news", schedule="every 1h") + job_b = create_job(prompt="Summarize", schedule="every 2h") + assert job_b.get("context_from") is None + + result = json.loads(cronjob( + action="update", + job_id=job_b["id"], + context_from=job_a["id"], + )) + assert result["success"] is True + + reloaded = get_job(job_b["id"]) + assert reloaded["context_from"] == [job_a["id"]] + + def test_update_changes_context_from_reference(self, cron_env): + from cron.jobs import create_job, get_job + from tools.cronjob_tools import cronjob + import json + + job_a = create_job(prompt="Find news", schedule="every 1h") + job_a2 = create_job(prompt="Find weather", schedule="every 1h") + job_b = create_job( + prompt="Summarize", schedule="every 2h", context_from=job_a["id"], + ) + assert job_b["context_from"] == [job_a["id"]] + + result = json.loads(cronjob( + action="update", + job_id=job_b["id"], + context_from=[job_a2["id"]], + )) + assert result["success"] is True + assert get_job(job_b["id"])["context_from"] == [job_a2["id"]] + + def test_update_clears_context_from_with_empty_list(self, cron_env): + from cron.jobs import create_job, get_job + from tools.cronjob_tools import cronjob + import json + + job_a = create_job(prompt="Find news", schedule="every 1h") + job_b = create_job( + prompt="Summarize", schedule="every 2h", context_from=job_a["id"], + ) + assert get_job(job_b["id"])["context_from"] == [job_a["id"]] + + result = json.loads(cronjob( + action="update", + job_id=job_b["id"], + context_from=[], + )) + assert result["success"] is True + assert get_job(job_b["id"])["context_from"] is None + + def test_update_clears_context_from_with_empty_string(self, cron_env): + from cron.jobs import create_job, get_job + from tools.cronjob_tools import cronjob + import json + + job_a = create_job(prompt="Find news", schedule="every 1h") + job_b = create_job( + prompt="Summarize", schedule="every 2h", context_from=job_a["id"], + ) + + result = json.loads(cronjob( + action="update", + job_id=job_b["id"], + context_from="", + )) + assert result["success"] is True + assert get_job(job_b["id"])["context_from"] is None + + def test_update_rejects_unknown_job_reference(self, cron_env): + from cron.jobs import create_job + from tools.cronjob_tools import cronjob + import json + + job_b = create_job(prompt="Summarize", schedule="every 2h") + + result = json.loads(cronjob( + action="update", + job_id=job_b["id"], + context_from=["deadbeef0000"], + )) + assert result["success"] is False + assert "not found" in result["error"] + + def test_update_preserves_context_from_when_not_passed(self, cron_env): + """Updating other fields must not clobber context_from.""" + from cron.jobs import create_job, get_job + from tools.cronjob_tools import cronjob + import json + + job_a = create_job(prompt="Find news", schedule="every 1h") + job_b = create_job( + prompt="Summarize", schedule="every 2h", context_from=job_a["id"], + ) + + # Update an unrelated field + result = json.loads(cronjob( + action="update", + job_id=job_b["id"], + prompt="Summarize v2", + )) + assert result["success"] is True + reloaded = get_job(job_b["id"]) + assert reloaded["prompt"] == "Summarize v2" + assert reloaded["context_from"] == [job_a["id"]] diff --git a/tools/cronjob_tools.py b/tools/cronjob_tools.py index 2d9485f2b0..994c313623 100644 --- a/tools/cronjob_tools.py +++ b/tools/cronjob_tools.py @@ -382,6 +382,24 @@ def cronjob( if script_error: return tool_error(script_error, success=False) updates["script"] = _normalize_optional_job_value(script) if script else None + if context_from is not None: + # Empty string / empty list clears the field; otherwise validate + # each referenced job exists before storing. Normalized to a list + # (or None) to match the shape stored by create_job(). + if isinstance(context_from, str): + refs = [context_from.strip()] if context_from.strip() else [] + else: + refs = [str(j).strip() for j in context_from if str(j).strip()] + if refs: + from cron.jobs import get_job as _get_job + for ref_id in refs: + if not _get_job(ref_id): + return tool_error( + f"context_from job '{ref_id}' not found. " + "Use cronjob(action='list') to see available jobs.", + success=False, + ) + updates["context_from"] = refs or None if enabled_toolsets is not None: updates["enabled_toolsets"] = enabled_toolsets or None if workdir is not None: @@ -508,7 +526,6 @@ Important safety rule: cron-run sessions should not recursively schedule more cr "workdir": { "type": "string", "description": "Optional absolute path to run the job from. When set, AGENTS.md / CLAUDE.md / .cursorrules from that directory are injected into the system prompt, and the terminal/file/code_exec tools use it as their working directory — useful for running a job inside a specific project repo. Must be an absolute path that exists. When unset (default), preserves the original behaviour: no project context files, tools use the scheduler's cwd. On update, pass an empty string to clear. Jobs with workdir run sequentially (not parallel) to keep per-job directories isolated." - }, }, "required": ["action"]