Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions python/tests/codegen/test_add_mcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ def test_basic(self, workspace):
assert server.transport == "stdio"
assert server.args == ["-y", "@modelcontextprotocol/server-filesystem", "."]

def test_annotated_entry_point(self, workspace):
"""add-mcp appends to the tools list of an annotated agent assignment."""
ws = workspace("""\
from timbal.core import Agent

agent: Agent = Agent(name="a", model="openai/gpt-4o-mini", tools=[])
""")
output = _run(ws, "--name", "fs", "--command", "npx")
assert "tools=[fs]" in output
ns = _exec_agent(output)
server = ns["agent"].tools[0]
assert server.name == "fs"
assert server.transport == "stdio"

def test_transport_inferred_from_command(self, workspace):
ws = workspace(AGENT_SOURCE)
output = _run(ws, "--name", "fs", "--command", "npx")
Expand Down
39 changes: 39 additions & 0 deletions python/tests/codegen/test_add_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,45 @@ def test_adds_agent_step(self, workspace):
assert 'name="agent_a"' in norm
assert "workflow.step(agent_a)" in output

def test_annotated_workflow_entry_point(self, workspace):
"""The new step variable must precede the .step() call on an annotated workflow."""
ws = workspace("""\
from timbal import Workflow

workflow: Workflow = Workflow(name="my_workflow")
""")
output = _run_dry(ws, "--type", "Agent", "--config", '{"name": "agent_a", "model": "openai/gpt-4o-mini"}')
assert "workflow.step(agent_a)" in output
assert output.index("agent_a = Agent") < output.index("workflow.step(agent_a)")
exec(output, {}) # no NameError → definition order is valid

def test_annotated_step_variable_idempotent_readd(self, workspace):
"""Re-adding a step whose variable is annotated updates it in place."""
ws = workspace("""\
from timbal import Agent, Workflow

agent_a: Agent = Agent(name="agent_a", model="openai/gpt-4o-mini")

workflow = Workflow(name="my_workflow")
workflow.step(agent_a)
""")
output = _run_dry(ws, "--type", "Agent", "--config", '{"name": "agent_a", "model": "openai/gpt-4o"}')
assert output.count("workflow.step(agent_a)") == 1
assert output.count("Agent(") <= 2 # annotation + constructor, no duplicate assignment
assert 'model="openai/gpt-4o"' in output

def test_missing_entry_point_fails_loudly(self, workspace):
"""A wrong fqn must not emit a dangling workflow.step(...) call."""
ws = workspace("""\
from timbal import Workflow

wf = Workflow(name="my_workflow")
""")
stderr = _run_dry_expect_error(
ws, "--type", "Agent", "--config", '{"name": "agent_a", "model": "openai/gpt-4o-mini"}'
)
assert "Entry point variable 'workflow' not found" in stderr

def test_rejects_agent_without_name(self, workspace):
"""Agent steps must have a name."""
ws = workspace("""\
Expand Down
12 changes: 12 additions & 0 deletions python/tests/codegen/test_add_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ def test_no_tools_kwarg(self, workspace):
ns = _exec_agent(output)
assert "web_search" in [t.name for t in ns["agent"].tools]

def test_annotated_entry_point(self, workspace):
"""add-tool appends to the tools list of an annotated agent assignment."""
ws = workspace("""\
from timbal.core import Agent

agent: Agent = Agent(name="a", model="openai/gpt-4o-mini", tools=[])
""")
output = _run_dry(ws, "--type", "WebSearch")
assert "tools=[web_search]" in output
ns = _exec_agent(output)
assert "web_search" in [t.name for t in ns["agent"].tools]

def test_idempotent(self, workspace):
"""Re-adding an existing variable-style tool doesn't duplicate it."""
ws = workspace("""\
Expand Down
71 changes: 71 additions & 0 deletions python/tests/codegen/test_get_flow.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
import textwrap
from pathlib import Path

Expand Down Expand Up @@ -144,6 +145,76 @@ def test_system_prompt_none(self, workspace):
config = _single_node(_flow(ws))["data"]["config"]
assert config["system_prompt"]["value"] is None

def test_voice_config_dict(self, workspace):
"""voice_config set in the source round-trips through get-flow."""
ws = workspace("""\
from timbal.core import Agent

agent = Agent(
name="a",
model="openai/gpt-4o-mini",
max_tokens=128,
voice_config={"voice": "CURRENT_VOICE", "tts_extra": {"auto_mode": True}},
)
""")
config = _single_node(_flow(ws))["data"]["config"]
assert config["voice_config"]["value"] == {
"voice": "CURRENT_VOICE",
"tts_extra": {"auto_mode": True},
}

def test_voice_config_absent(self, workspace):
"""Agents without voice_config still expose the key with a None value."""
ws = workspace("""\
from timbal.core import Agent

agent = Agent(name="a", model="openai/gpt-4o-mini", max_tokens=128)
""")
config = _single_node(_flow(ws))["data"]["config"]
assert "voice_config" in config
assert config["voice_config"]["value"] is None

def test_voice_config_callable(self, workspace):
"""A callable voice_config is rendered as an opaque placeholder."""
ws = workspace("""\
from timbal.core import Agent

def make_voice_config():
return {"voice": "CURRENT_VOICE"}

agent = Agent(
name="a",
model="openai/gpt-4o-mini",
max_tokens=128,
voice_config=make_voice_config,
)
""")
config = _single_node(_flow(ws))["data"]["config"]
assert config["voice_config"]["value"] == "<make_voice_config>"
# Schema must advertise the callable variant (mirrors system_prompt).
any_of_types = [v.get("type") for v in config["voice_config"]["anyOf"]]
assert "callable" in any_of_types
assert "object" in any_of_types

def test_voice_config_instance(self, workspace):
"""A VoiceConfig instance is dumped to a JSON-safe dict."""
ws = workspace("""\
from timbal.core import Agent
from timbal.voice.config import VoiceConfig

agent = Agent(
name="a",
model="openai/gpt-4o-mini",
max_tokens=128,
voice_config=VoiceConfig(voice="my-voice"),
)
""")
config = _single_node(_flow(ws))["data"]["config"]
value = config["voice_config"]["value"]
assert isinstance(value, dict)
assert value["voice"] == "my-voice"
json.dumps(value) # must stay JSON-serialisable

def test_has_params_and_return(self, workspace):
ws = workspace("""\
from timbal.core import Agent
Expand Down
17 changes: 17 additions & 0 deletions python/tests/codegen/test_remove_edge.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,23 @@ def test_remove_one_from_multiple_depends_on(self, wf_workspace):
assert '"agent_a"' not in normalized.split("depends_on")[1] if "depends_on" in normalized else True


class TestIdempotency:
def test_remove_absent_edge_is_noop_success(self, wf_workspace):
"""Removing an edge that is already gone succeeds without changes."""
ws = wf_workspace("""\
from timbal import Agent, Workflow

agent_a = Agent(name="agent_a", model="openai/gpt-4o-mini")
agent_b = Agent(name="agent_b", model="openai/gpt-4o-mini")

workflow = Workflow(name="wf")
workflow.step(agent_a)
workflow.step(agent_b)
""")
output = _run(ws, source="agent_a", target="agent_b")
assert "workflow.step(agent_b)" in output


class TestRemoveDataFlowEdge:
def test_remove_param_lambda(self, wf_workspace):
ws = wf_workspace("""\
Expand Down
27 changes: 27 additions & 0 deletions python/tests/codegen/test_remove_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,33 @@ def test_removes_agent_step(self, workspace):
# Variable and import should be cleaned up by remove_unused_code
assert "agent_a = Agent" not in output

def test_missing_entry_point_fails_loudly(self, workspace):
"""A wrong fqn must not phantom-succeed via allow_noop."""
ws = workspace("""\
from timbal import Agent, Workflow

agent_a = Agent(name="agent_a", model="openai/gpt-4o-mini")

wf = Workflow(name="my_workflow")
wf.step(agent_a)
""")
stderr = _run_dry_expect_error(ws, "agent_a")
assert "Entry point variable 'workflow' not found" in stderr

def test_aliased_workflow_with_step_calls_works(self, workspace):
"""An alias entry point whose .step() calls use the fqn name still works."""
ws = workspace("""\
from timbal import Agent, Workflow

agent_a = Agent(name="agent_a", model="openai/gpt-4o-mini")

_wf = Workflow(name="my_workflow")
workflow = _wf
workflow.step(agent_a)
""")
output = _run_dry(ws, "agent_a")
assert "workflow.step(agent_a)" not in output

def test_removes_one_keeps_other(self, workspace):
"""Remove one step but keep the other intact."""
ws = workspace("""\
Expand Down
57 changes: 57 additions & 0 deletions python/tests/codegen/test_remove_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ def _run_dry(workspace_path: Path, tool_name: str) -> str:
return result.stdout


def _run_dry_fail(workspace_path: Path, tool_name: str) -> subprocess.CompletedProcess:
"""Run codegen remove-tool with --dry-run and return the raw result."""
return subprocess.run(
codegen_cmd("--path", str(workspace_path), "--dry-run", "remove-tool", "--name", tool_name),
capture_output=True,
text=True,
)


def _exec_agent(code: str) -> dict:
"""Exec the generated code and return its globals."""
ns = {}
Expand All @@ -52,6 +61,18 @@ def test_removes_inline_web_search(self, workspace):
ns = _exec_agent(output)
assert len(ns["agent"].tools) == 0

def test_annotated_entry_point(self, workspace):
"""remove-tool prunes the tools list of an annotated agent assignment."""
ws = workspace("""\
from timbal.core import Agent
from timbal.tools import WebSearch

agent: Agent = Agent(name="a", model="openai/gpt-4o-mini", tools=[WebSearch()])
""")
output = _run_dry(ws, "web_search")
ns = _exec_agent(output)
assert len(ns["agent"].tools) == 0

def test_removes_one_keeps_others(self, workspace):
"""Remove WebSearch but keep other tools intact."""
ws = workspace("""\
Expand Down Expand Up @@ -348,3 +369,39 @@ def test_rejects_step_on_agent_entry_point(self, workspace):
)
assert result.returncode != 0
assert "--step requires a Workflow" in result.stderr


class TestEntryPointValidation:
def test_aliased_entry_point_fails_loudly(self, workspace):
"""An alias entry point (agent = _agent) must not phantom-succeed via allow_noop."""
ws = workspace("""\
from timbal.core import Agent
from timbal.tools import WebSearch

_agent = Agent(name="a", model="openai/gpt-4o-mini", tools=[WebSearch()])
agent = _agent
""")
result = _run_dry_fail(ws, "web_search")
assert result.returncode != 0
assert "Entry point variable 'agent' not found" in result.stderr

def test_unknown_step_fails_loudly(self, wf_workspace):
"""Removing from a nonexistent step is an error, not an idempotent no-op."""
ws = wf_workspace("""\
from timbal import Agent, Workflow
from timbal.tools import WebSearch

agent_a = Agent(name="agent_a", model="openai/gpt-4o-mini", tools=[WebSearch()])

workflow = Workflow(name="workflow")
workflow.step(agent_a)
""")
result = subprocess.run(
codegen_cmd("--path", str(ws), "--dry-run",
"remove-tool", "--name", "web_search", "--step", "nope",
),
capture_output=True,
text=True,
)
assert result.returncode != 0
assert "Workflow step 'nope' not found" in result.stderr
Loading