From 804822b00a7cfce193c00a092c4c6648cff22ed0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 19 Oct 2026 20:14:00 +0000 Subject: [PATCH] feat(opencode-agents): render configured message templates Non-system config messages go through parse_template. History and the user turn stay raw. The resolver is not a place that writes instructions. --- .../handler.py | 12 +- .../opencode-agents/tests/test_handler.py | 136 ++++++++++++++++++ 2 files changed, 145 insertions(+), 3 deletions(-) diff --git a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py index 08d165cd..f1e35fd3 100644 --- a/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py +++ b/packages/opencode-agents/src/launchdarkly_ai_opencode_agents/handler.py @@ -576,7 +576,7 @@ async def _stream_impl( config if isinstance(config, dict) else {} ) system = _system_text(config, variables) - prompt = _user_prompt(config, user_input, history) + prompt = _user_prompt(config, user_input, history, variables) try: set_input_content_attributes( span, @@ -723,14 +723,20 @@ def _user_prompt( config: AiConfigRep, user_input: str | None, history: list[dict[str, Any]] | None, + variables: dict[str, Any] | None = None, ) -> str: + values = variables or {} messages: list[dict[str, Any]] = [] if isinstance(config, dict): raw_messages = config.get("messages") if isinstance(raw_messages, list): for message in raw_messages: - if isinstance(message, dict) and message.get("role") != "system": - messages.append(message) + if not isinstance(message, dict) or message.get("role") == "system": + continue + content = message.get("content") + if isinstance(content, str): + message = {**message, "content": parse_template(content, values)} + messages.append(message) turns = compose_history( history=[item for item in (history or []) if isinstance(item, dict)], user_input=user_input, diff --git a/packages/opencode-agents/tests/test_handler.py b/packages/opencode-agents/tests/test_handler.py index 2d7ccf18..9cc37ad7 100644 --- a/packages/opencode-agents/tests/test_handler.py +++ b/packages/opencode-agents/tests/test_handler.py @@ -826,6 +826,142 @@ async def test_events_receives_the_prompt_and_the_session(self) -> None: assert script.sessions == ["ses"] assert events_out[-1]["output"] == "Hi" + async def test_agent_instructions_render_and_history_does_not(self) -> None: + handler, script = playing( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ] + ) + assert handler.provides_for == ("*", "agent") + events_out = [ + event + async for event in await handler.stream( + { + "instructions": "Hello {{caller_email}}", + "model": {"name": "claude"}, + "provider": {"name": "Anthropic"}, + }, + "next {{caller_email}}", + None, + {"caller_email": "ada@example.com"}, + [{"role": "user", "content": "earlier {{caller_email}}"}], + ) + ] + posted = script.messages[0] + assert posted["system"] == "Hello ada@example.com" + assert posted["parts"] == [ + { + "type": "text", + "text": "User: earlier {{caller_email}}\n\nUser: next {{caller_email}}", + } + ] + assert events_out[-1]["output"] == "Hi" + + async def test_templates_render_in_instructions_and_config_messages( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + from launchdarkly_ai_opencode_agents import spans as span_module + + class _Span: + def __init__(self, name: str, context: Any = None) -> None: + self.name = name + self.attributes: dict[str, Any] = {} + + def set_attribute(self, key: str, value: Any) -> None: + self.attributes[key] = value + + def add_event( + self, name: str, attributes: dict[str, Any] | None = None + ) -> None: + return None + + def set_status(self, code: Any, description: str | None = None) -> None: + return None + + def record_exception(self, exc: BaseException) -> None: + return None + + def end(self) -> None: + return None + + class _Tracer: + def __init__(self) -> None: + self.spans: list[_Span] = [] + + def get_tracer(self, name: str) -> _Tracer: + return self + + def start_span(self, name: str, context: Any = None) -> _Span: + span = _Span(name, context) + self.spans.append(span) + return span + + def set_span_in_context(self, span: _Span) -> Any: + return span + + tracer = _Tracer() + monkeypatch.setattr(span_module, "trace", tracer) + handler, script = playing( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ], + capture_content=True, + ) + config = { + "instructions": "Hello {{user.name}} {{missing}}", + "messages": [ + {"role": "user", "content": "Assist {{caller_email}}"}, + {"role": "system", "content": "skip {{caller_email}}"}, + ], + "model": {"name": "claude"}, + "provider": {"name": "Anthropic"}, + } + variables = {"user": {"name": "Ada"}, "caller_email": "ada@example.com"} + await _drain( + await handler.stream( + config, + "ask {{caller_email}}", + None, + variables, + [{"role": "user", "content": "earlier {{caller_email}}"}], + ) + ) + posted = script.messages[0] + assert posted["system"] == "Hello Ada {{missing}}" + assert posted["parts"][0]["text"] == ( + "User: Assist ada@example.com\n\n" + "User: earlier {{caller_email}}\n\n" + "User: ask {{caller_email}}" + ) + recorded = tracer.spans[0].attributes["gen_ai.system_instructions"] + assert "Hello Ada {{missing}}" in recorded + assert "{{user.name}}" not in recorded + + bare, bare_script = playing( + [ + assistant_open("m1"), + text_delta("m1", "p1", "Hi"), + assistant_completed("m1"), + ] + ) + await _drain( + await bare.stream( + { + "instructions": "Hello {{caller_email}}", + "model": {"name": "claude"}, + "provider": {"name": "Acme"}, + }, + "hi", + None, + None, + ) + ) + assert bare_script.messages[0]["system"] == "Hello {{caller_email}}" + async def test_other_sessions_are_ignored_and_idle_ends_the_run(self) -> None: events = await collect( [