From d046c4e40875faa80795d867fdd0eaa6b0ad4025 Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Fri, 18 Sep 2026 23:39:50 -0700 Subject: [PATCH] test(codex_app_server): invariants for the Hermes prompt handoff; docs Session: thread/start carries developerInstructions + personality "none", and omits developerInstructions when the prompt is blank (folds #72106's assertion into the rewritten cwd-only test). Runtime: _ensure_codex_session composes _cached_system_prompt + ephemeral_system_prompt exactly like turn_context and sends it once per thread across turns; no field when the agent has no prompt. Docs state that SOUL.md / system prompt / channel overrides now reach the model as developer instructions and that conversation history is still not projected into the codex thread. Contributor mapping for @Momentum96 (#27998 port). --- contributors/emails/wjsrjsdn12@gmail.com | 1 + .../test_codex_runtime_prompt_handoff.py | 55 +++++++++++++++++++ .../test_codex_app_server_session.py | 22 +++++--- .../features/codex-app-server-runtime.md | 3 + 4 files changed, 73 insertions(+), 8 deletions(-) create mode 100644 contributors/emails/wjsrjsdn12@gmail.com create mode 100644 tests/agent/test_codex_runtime_prompt_handoff.py diff --git a/contributors/emails/wjsrjsdn12@gmail.com b/contributors/emails/wjsrjsdn12@gmail.com new file mode 100644 index 0000000000..cfcfe2a8bd --- /dev/null +++ b/contributors/emails/wjsrjsdn12@gmail.com @@ -0,0 +1 @@ +Momentum96 diff --git a/tests/agent/test_codex_runtime_prompt_handoff.py b/tests/agent/test_codex_runtime_prompt_handoff.py new file mode 100644 index 0000000000..c9fda680a3 --- /dev/null +++ b/tests/agent/test_codex_runtime_prompt_handoff.py @@ -0,0 +1,55 @@ +"""The codex_app_server runtime hands Hermes' composed system prompt to the codex thread (#74712, #26035). + +The standard loop sends ``_cached_system_prompt + ephemeral_system_prompt`` as its system message; the +codex early-return used to send only cwd + raw user text, so SOUL.md / memory / channel_overrides were +composed and then silently dropped. +""" + +from types import SimpleNamespace + +from agent import codex_runtime +from agent.transports import codex_app_server_session as sess_mod + + +class _FakeClient: + def __init__(self, **_kw): + self.requests = [] + + def initialize(self, **_kw): + return {} + + def request(self, method, params=None, timeout=None): + self.requests.append((method, params)) + return {"thread": {"id": "t1"}} + + +def _agent(**overrides): + base = dict(_codex_session=None, session_cwd="/tmp", tool_progress_callback=None, + _cached_system_prompt="SOUL: you are Hermes", ephemeral_system_prompt="Always start with ZZZ") + base.update(overrides) + return SimpleNamespace(**base) + + +def test_runtime_sends_composed_prompt_once_per_thread(monkeypatch): + """Composition mirrors turn_context (prompt + blank line + ephemeral); sent on thread/start + exactly once even though _ensure_codex_session runs on every turn.""" + client = _FakeClient() + monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client) + agent = _agent() + for _ in range(3): # three turns reuse one session + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + starts = [p for (m, p) in client.requests if m == "thread/start"] + assert len(starts) == 1 + assert starts[0]["developerInstructions"] == "SOUL: you are Hermes\n\nAlways start with ZZZ" + + +def test_runtime_omits_prompt_when_agent_has_none(monkeypatch): + """No cached prompt and no ephemeral additions → no developerInstructions field at all.""" + client = _FakeClient() + monkeypatch.setattr(sess_mod, "CodexAppServerClient", lambda **kw: client) + agent = _agent(_cached_system_prompt=None, ephemeral_system_prompt=None) + codex_runtime._ensure_codex_session(agent) + agent._codex_session.ensure_started() + (_, params), = [(m, p) for (m, p) in client.requests if m == "thread/start"] + assert "developerInstructions" not in params diff --git a/tests/agent/transports/test_codex_app_server_session.py b/tests/agent/transports/test_codex_app_server_session.py index 65b2d1fe19..a3a494a848 100644 --- a/tests/agent/transports/test_codex_app_server_session.py +++ b/tests/agent/transports/test_codex_app_server_session.py @@ -164,17 +164,23 @@ class TestLifecycle: method_calls = [m for (m, _) in client.requests if m == "thread/start"] assert len(method_calls) == 1 - def test_thread_start_passes_cwd_only(self): - """thread/start carries cwd. We intentionally do NOT pass `permissions` - on this codex version (experimentalApi-gated + requires matching - config.toml [permissions] table). Letting codex use its default - (read-only unless user configures otherwise) is the documented path.""" + def test_thread_start_carries_hermes_prompt_and_disables_codex_personality(self): + """thread/start carries cwd, Hermes' composed prompt as developerInstructions and + personality "none" (#74712, #72104, #26035). We intentionally do NOT pass `permissions` + (experimentalApi-gated + requires a matching config.toml [permissions] table).""" client = FakeClient() - s = make_session(client, permission_profile="workspace-write") + s = make_session(client, permission_profile="workspace-write", developer_instructions="SOUL: be terse") s.ensure_started() method, params = next(r for r in client.requests if r[0] == "thread/start") - assert params["cwd"] == "/tmp" - assert "permissions" not in params # see session.ensure_started() comment + assert params == {"cwd": "/tmp", "developerInstructions": "SOUL: be terse", "personality": "none"} + + def test_thread_start_omits_developer_instructions_when_prompt_empty(self): + """No prompt (or a blank one) never sends an empty developerInstructions field.""" + client = FakeClient() + make_session(client, developer_instructions=" ").ensure_started() + method, params = next(r for r in client.requests if r[0] == "thread/start") + assert "developerInstructions" not in params + assert params["personality"] == "none" def test_close_idempotent(self): client = FakeClient() diff --git a/website/docs/user-guide/features/codex-app-server-runtime.md b/website/docs/user-guide/features/codex-app-server-runtime.md index 9c85c01891..f0b433919a 100644 --- a/website/docs/user-guide/features/codex-app-server-runtime.md +++ b/website/docs/user-guide/features/codex-app-server-runtime.md @@ -20,6 +20,7 @@ Not using OpenAI Codex? `hermes setup --portal` configures a non-Codex backend w - **Native Codex plugins** — Linear, GitHub, Gmail, Calendar, Canva, etc. — installed via `codex plugin` are auto-migrated and active in your Hermes session. - **Hermes' richer tools come along** — web_search, web_extract, browser automation, vision, image generation, skills, and TTS work via an MCP callback. Codex calls back into Hermes for tools it doesn't have built in. - **Memory and skill nudges keep working** — Codex's events are projected into Hermes' message shape so the self-improvement loop sees a normal-looking transcript. +- **Your Hermes persona rides along** — the composed system prompt (SOUL.md, MEMORY.md/USER.md, per-channel `system_prompt` overrides) is sent to the codex thread once as developer instructions when the thread starts, and Codex's built-in personality is disabled so it cannot compete with yours. ## What tools the model actually has @@ -125,6 +126,7 @@ The kanban tools are gated by `HERMES_KANBAN_TASK` env var the dispatcher sets | Native Codex plugins (Linear, GitHub, etc.) | — | yes (auto-migrated) | | User MCP servers | yes | yes (auto-migrated to codex) | | Memory + skill review (background) | yes | yes (via item projection) | +| System prompt / SOUL.md / channel `system_prompt` overrides | yes | yes (sent once as developer instructions on thread start) | | Multi-turn conversations | yes | yes | | `/goal` (Ralph loop) | yes | yes | | Kanban worker dispatch | yes | yes (via callback) | @@ -408,6 +410,7 @@ Known limitations: - **Hermes auth and codex auth are separate sessions.** You need both `codex login` AND `hermes auth add openai-codex` for the cleanest UX (the runtime uses codex's session for the LLM call). This is a deliberate design choice in Hermes' `_import_codex_cli_tokens` — Hermes won't share OAuth state with codex CLI to avoid clobbering each other on token refresh. - **`delegate_task`, `memory`, `session_search`, `todo` are unavailable on this runtime.** They need the running AIAgent context which a stateless MCP callback can't provide. Use `/codex-runtime auto` when you need these. - **No inline patch preview in approval prompts when codex doesn't track the changeset.** Codex's `fileChange` approval params don't always carry the changeset. Hermes caches the data from the corresponding `item/started` notification when possible, but if approval arrives before the item has streamed, the prompt falls back to whatever `reason` codex provides. +- **Conversation history is not projected into the codex thread.** The codex thread receives Hermes' system prompt when it starts plus each new user message; prior Hermes history (e.g. from a resumed session) is not replayed into it. Prompt changes made mid-session apply when the thread is next (re)created. - **Sub-second cancellation isn't guaranteed.** Mid-stream interrupts (Ctrl+C while codex is responding) are sent via `turn/interrupt`, but if codex has already flushed the final message, you get the response anyway. If you find a bug, [open an issue](https://github.com/NousResearch/hermes-agent/issues) with the output of `hermes logs --since 5m`. Mention `codex-runtime` in the title so it's easy to triage.