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
1 change: 0 additions & 1 deletion api/tests/unit_tests/.ruff.toml
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,6 @@ extend-select = ["ANN401", "ARG"]
"models/test_dataset.py" = ["ARG005"]
"services/agent/test_observability_service.py" = ["ARG002", "ARG005"]
"services/agent/test_roster_service.py" = ["ARG001", "ARG002", "ARG003", "ARG005"]
"services/agent/test_prompt_mentions.py" = ["ARG005"]
"../helpers/unit/services/controller_api.py" = ["ARG002"]
"services/data_migration/test_import_service.py" = ["ARG002", "ARG005"]
"../helpers/unit/services/dataset_service_test_helpers.py" = ["TID251"]
Expand Down
2 changes: 0 additions & 2 deletions api/tests/unit_tests/pyrefly.toml
Original file line number Diff line number Diff line change
Expand Up @@ -343,8 +343,6 @@ project-excludes = [
"repositories/test_sqlalchemy_api_workflow_run_repository.py",
"services/agent/test_dsl_service.py",
"services/agent/test_roster_service.py",
"services/agent/test_prompt_mentions.py",
"services/agent/test_skill_package_service.py",
"services/agent/test_workflow_publish_service.py",
"../helpers/unit/services/controller_api.py",
"services/data_migration/test_export_service.py",
Expand Down
53 changes: 27 additions & 26 deletions api/tests/unit_tests/services/agent/test_prompt_mentions.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
PromptMention,
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
Expand All @@ -29,7 +30,7 @@
# ── parse ─────────────────────────────────────────────────────────────────────


def test_parse_extracts_kind_id_and_optional_label():
def test_parse_extracts_kind_id_and_optional_label() -> None:
prompt = "Use [§skill:tender-analyzer%2FSKILL.md:tender-analyzer§] then ask [§human:c-1§]."
mentions = parse_prompt_mentions(prompt)

Expand All @@ -40,74 +41,74 @@ def test_parse_extracts_kind_id_and_optional_label():
assert prompt[mentions[0].start : mentions[0].end] == mentions[0].raw


def test_parse_supports_ids_with_slash_and_dot():
def test_parse_supports_ids_with_slash_and_dot() -> None:
mentions = parse_prompt_mentions("[§tool:langgenius/tavily/tavily_search:tavily§] [§node_output:node-1.tenders§]")
assert mentions[0].ref_id == "langgenius/tavily/tavily_search"
assert mentions[1].ref_id == "node-1.tenders"


def test_parse_supports_legacy_bare_output_tokens():
def test_parse_supports_legacy_bare_output_tokens() -> None:
mentions = parse_prompt_mentions("Use §output:summary:summary§")
assert [(mention.kind, mention.ref_id, mention.label) for mention in mentions] == [
(MentionKind.OUTPUT, "summary", "summary")
]


def test_parse_ignores_legacy_template_forms_and_unknown_kinds():
def test_parse_ignores_legacy_template_forms_and_unknown_kinds() -> None:
prompt = "{{var}} {{#context#}} {{#sys.query#}} [§bogus_kind:x§]"
assert parse_prompt_mentions(prompt) == []


def test_parse_skips_oversized_id_or_label():
def test_parse_skips_oversized_id_or_label() -> None:
long_id = "x" * (MAX_MENTION_REF_ID_LENGTH + 1)
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []


# ── expand + scrub ────────────────────────────────────────────────────────────


def test_expand_uses_resolver_and_degrades_unresolved_to_label_then_id():
def test_expand_uses_resolver_and_degrades_unresolved_to_label_then_id() -> None:
prompt = "A [§skill:s1:Skill One§] B [§human:h1:EMAIL · DAVE§] C [§knowledge:k1§]"

def resolver(mention):
def resolver(mention: PromptMention) -> str | None:
return "resolved-skill" if mention.kind == MentionKind.SKILL else None

expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == "A resolved-skill B EMAIL · DAVE C k1"
assert "[§" not in expanded


def test_expand_scrubs_unknown_kind_tokens_but_keeps_legacy_forms():
def test_expand_scrubs_unknown_kind_tokens_but_keeps_legacy_forms() -> None:
prompt = "x [§wat:id-1:Label§] y {{#context#}} z {{#node.var#}}"
expanded = expand_prompt_mentions(prompt, lambda m: None)
expanded = expand_prompt_mentions(prompt, lambda _mention: None)
# unknown mention-shaped token degraded to its label; legacy forms untouched
assert expanded == "x Label y {{#context#}} z {{#node.var#}}"


def test_scrub_degrades_colon_tokens_without_label_to_id_part():
def test_scrub_degrades_colon_tokens_without_label_to_id_part() -> None:
assert scrub_mention_markers("see [§weird_kind:some-id§]") == "see some-id"


def test_expand_empty_prompt_is_noop():
assert expand_prompt_mentions("", lambda m: "x") == ""
def test_expand_empty_prompt_is_noop() -> None:
assert expand_prompt_mentions("", lambda _mention: "x") == ""


def test_extract_workflow_variable_selectors_supports_frontend_agent_task_format():
def test_extract_workflow_variable_selectors_supports_frontend_agent_task_format() -> None:
selectors = extract_workflow_variable_selectors(
"Read {{#node-1.output#}}, compare {{#start.question#}}, leave {{#context#}} alone."
)
assert selectors == [("node-1", "output"), ("start", "question")]


def test_extract_workflow_node_output_selectors_supports_current_frontend_markers_only():
def test_extract_workflow_node_output_selectors_supports_current_frontend_markers_only() -> None:
selectors = extract_workflow_node_output_selectors(
"Read {{#node-1.output#}}, {{#sys.query#}}, [§node_output:legacy-node.report:PREV/report§], "
"and {{#node-1.output#}} again."
)
assert selectors == [("node-1", "output")]


def test_workflow_previous_node_output_refs_from_selectors_materializes_refs():
def test_workflow_previous_node_output_refs_from_selectors_materializes_refs() -> None:
refs = workflow_previous_node_output_refs_from_selectors([("node-1", "output"), ("node-2", "report", "url")])

assert refs == [
Expand Down Expand Up @@ -155,7 +156,7 @@ def soul() -> AgentSoulConfig:
)


def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig) -> None:
resolver = build_soul_mention_resolver(soul)
prompt = (
"Use [§tool:tavily/tavily_search:tavily§], run [§cli_tool:ct-1:ffmpeg§], "
Expand All @@ -167,12 +168,12 @@ def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
assert expanded == ("Use tavily_search, run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes.")


def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig) -> None:
expanded = expand_prompt_mentions("[§knowledge:missing:旧产品手册§]", build_soul_mention_resolver(soul))
assert expanded == "旧产品手册"


def test_soul_resolver_cli_tool_resolves_by_id_and_keeps_name_alias(soul: AgentSoulConfig):
def test_soul_resolver_cli_tool_resolves_by_id_and_keeps_name_alias(soul: AgentSoulConfig) -> None:
resolver = build_soul_mention_resolver(soul)
# id is the contract; the name alias keeps tokens minted before ids existed working
assert expand_prompt_mentions("[§cli_tool:ct-1§]", resolver) == "ffmpeg"
Expand All @@ -193,7 +194,7 @@ def soul_with_provider_entry(soul: AgentSoulConfig) -> AgentSoulConfig:
return soul


def test_soul_resolver_provider_all_tools_mention(soul_with_provider_entry: AgentSoulConfig):
def test_soul_resolver_provider_all_tools_mention(soul_with_provider_entry: AgentSoulConfig) -> None:
resolver = build_soul_mention_resolver(soul_with_provider_entry)
# [§tool:<provider>/*§] = all tools of that provider
assert expand_prompt_mentions("Use [§tool:duckduckgo/*:DuckDuckGo 全部§].", resolver) == (
Expand All @@ -206,7 +207,7 @@ def test_soul_resolver_provider_all_tools_mention(soul_with_provider_entry: Agen
assert expand_prompt_mentions("[§tool:duckduckgo/*:DuckDuckGo 全部§]", bare) == "DuckDuckGo 全部"


def test_soul_resolver_single_tool_resolves_via_provider_level_entry(soul_with_provider_entry: AgentSoulConfig):
def test_soul_resolver_single_tool_resolves_via_provider_level_entry(soul_with_provider_entry: AgentSoulConfig) -> None:
# one tool offered through the provider-level ("all") entry still resolves
resolver = build_soul_mention_resolver(soul_with_provider_entry)
assert expand_prompt_mentions("[§tool:duckduckgo/ddg_search§]", resolver) == "ddg_search"
Expand All @@ -228,7 +229,7 @@ def node_job() -> WorkflowNodeJobConfig:
)


def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig) -> None:
resolver = build_node_job_mention_resolver(node_job)
prompt = "Read [§node_output:start-1.tenders§] and produce [§output:qna_report§]; if unsure contact [§human:c-1§]."

Expand All @@ -244,23 +245,23 @@ def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
)


def test_node_job_resolver_accepts_legacy_reversed_output_token(node_job: WorkflowNodeJobConfig):
def test_node_job_resolver_accepts_legacy_reversed_output_token(node_job: WorkflowNodeJobConfig) -> None:
resolver = build_node_job_mention_resolver(node_job)

expanded = expand_prompt_mentions("[§qna_report:qna_report:output§]", resolver)

assert "final_output.qna_report" in expanded


def test_node_job_resolver_exposes_system_text_output():
def test_node_job_resolver_exposes_system_text_output() -> None:
node_job = WorkflowNodeJobConfig()

expanded = expand_prompt_mentions("[§output:text§]", build_node_job_mention_resolver(node_job))

assert expanded == "text (string)"


def test_node_job_resolver_matches_ref_by_node_id_and_output_fields():
def test_node_job_resolver_matches_ref_by_node_id_and_output_fields() -> None:
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "n-2", "output": "text"}]}
)
Expand All @@ -269,7 +270,7 @@ def test_node_job_resolver_matches_ref_by_node_id_and_output_fields():
assert expanded == "LLM/text"


def test_normalize_previous_node_output_selector_returns_canonical_selector():
def test_normalize_previous_node_output_selector_returns_canonical_selector() -> None:
assert normalize_previous_node_output_selector(
WorkflowPreviousNodeOutputRef(selector=["node-1", "report", "url"])
) == ("node-1", "report", "url")
Expand All @@ -283,7 +284,7 @@ def test_normalize_previous_node_output_selector_returns_canonical_selector():
# ── allowlists ────────────────────────────────────────────────────────────────


def test_per_surface_allowlists_match_design():
def test_per_surface_allowlists_match_design() -> None:
assert {
MentionKind.SKILL,
MentionKind.FILE,
Expand Down
26 changes: 14 additions & 12 deletions api/tests/unit_tests/services/agent/test_skill_package_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ def test_normalized_highly_compressible_skill_passes_reinspection() -> None:
assert archive.read("data.bin") == payload


def test_inspect_reports_uncompressed_size_without_rebuilding(monkeypatch: pytest.MonkeyPatch):
def test_inspect_reports_uncompressed_size_without_rebuilding(monkeypatch: pytest.MonkeyPatch) -> None:
members = {"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"print('hi')\n"}

def fail_if_called(*_args: object, **_kwargs: object) -> bytes:
Expand Down Expand Up @@ -141,7 +141,7 @@ def test_inspection_and_normalization_keep_distinct_size_meanings(prefix: str, c
assert inspection.uncompressed_size > normalized.manifest.size


def test_shallowest_skill_md_preferred_during_normalization():
def test_shallowest_skill_md_preferred_during_normalization() -> None:
manifest = _normalize({"SKILL.md": _SKILL_MD.encode(), "nested/SKILL.md": _SKILL_MD.encode()}).manifest
assert manifest.entry_path == "SKILL.md"
assert manifest.files == ["SKILL.md", "nested/SKILL.md"]
Expand Down Expand Up @@ -235,7 +235,7 @@ def test_normalization_preserves_selected_skill_contents(prefix: str, extra_memb
),
],
)
def test_invalid_packages_rejected(members: dict[str, bytes], filename: str, code: str):
def test_invalid_packages_rejected(members: dict[str, bytes], filename: str, code: str) -> None:
with pytest.raises(SkillPackageError) as exc_info:
_normalize(members, filename=filename)
assert exc_info.value.code == code
Expand Down Expand Up @@ -263,43 +263,43 @@ def test_opening_delimiter_with_trailing_text_is_not_frontmatter() -> None:
assert exc_info.value.code == "missing_skill_name"


def test_non_zip_content_rejected():
def test_non_zip_content_rejected() -> None:
with pytest.raises(SkillPackageError) as exc_info:
SkillPackageService().validate_and_normalize(content=b"not a zip", filename="skill.zip")
assert exc_info.value.code == "invalid_archive"


def test_empty_archive_rejected():
def test_empty_archive_rejected() -> None:
with pytest.raises(SkillPackageError) as exc_info:
SkillPackageService().validate_and_normalize(content=b"", filename="skill.zip")
assert exc_info.value.code == "empty_archive"


def test_validate_and_normalize_rejects_skill_md_too_large(monkeypatch: pytest.MonkeyPatch):
def test_validate_and_normalize_rejects_skill_md_too_large(monkeypatch: pytest.MonkeyPatch) -> None:
apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_SKILL_MD_BYTES=8)

with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": _SKILL_MD.encode()})
assert exc_info.value.code == "skill_md_too_large"


def test_validate_and_normalize_rejects_too_many_entries(monkeypatch: pytest.MonkeyPatch):
def test_validate_and_normalize_rejects_too_many_entries(monkeypatch: pytest.MonkeyPatch) -> None:
apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_ENTRIES=1)

with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": _SKILL_MD.encode(), "assets/": b""})
assert exc_info.value.code == "too_many_entries"


def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypatch: pytest.MonkeyPatch):
def test_validate_and_normalize_rejects_archive_too_large_uncompressed(monkeypatch: pytest.MonkeyPatch) -> None:
apply_config_overrides(monkeypatch, SKILL_PACKAGE_MAX_UNCOMPRESSED_BYTES=32)

with pytest.raises(SkillPackageError) as exc_info:
_normalize({"SKILL.md": _SKILL_MD.encode(), "scripts/run.py": b"x" * 33})
assert exc_info.value.code == "archive_too_large"


def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeypatch: pytest.MonkeyPatch):
def test_validate_and_normalize_rejects_archive_too_large_uploaded_bytes(monkeypatch: pytest.MonkeyPatch) -> None:
apply_config_overrides(monkeypatch, UPLOAD_SKILL_FILE_SIZE_LIMIT=1)

with pytest.raises(SkillPackageError) as exc_info:
Expand All @@ -317,14 +317,16 @@ def test_high_compression_ratio_uses_absolute_expansion_limit(monkeypatch: pytes
assert exc_info.value.code == "archive_too_large"


def test_validate_and_normalize_maps_member_decompression_failures_to_invalid_archive(monkeypatch: pytest.MonkeyPatch):
def test_validate_and_normalize_maps_member_decompression_failures_to_invalid_archive(
monkeypatch: pytest.MonkeyPatch,
) -> None:
original_read = zipfile.ZipFile.read

def corrupted_read(self: zipfile.ZipFile, member: str | zipfile.ZipInfo, *args: object, **kwargs: object) -> bytes:
def corrupted_read(self: zipfile.ZipFile, member: str | zipfile.ZipInfo, pwd: bytes | None = None) -> bytes:
filename = member.filename if isinstance(member, zipfile.ZipInfo) else member
if filename == "scripts/run.py":
raise zlib.error("invalid distance too far back")
return original_read(self, member, *args, **kwargs)
return original_read(self, member, pwd=pwd)

monkeypatch.setattr(zipfile.ZipFile, "read", corrupted_read)

Expand Down