feat(dify-builder): name apps from their prompt, and turn gate actions into card options

Two App Builder spec changes that share the Builder session contract.

Naming (spec N1-N3): an app created from the homepage prompt is no longer named
by the user. core/dify_builder/naming.py cuts the prompt's opening clause at
create time -- instant and infallible, since it runs inside POST /apps -- and
CreateAppPayload.name becomes optional alongside a new prompt field. The Builder
then refines that name once during goal analysis, where a model and the detected
reply language are already in hand, so the judgement calls that are
English-shaped in pure Python (dropping an imperative lead-in, spotting a
contentless prompt) work in any language. Plan approval freezes the name.

Card options: SessionView gains a Decision carrying the gate's choice as options
inside the card plus a fixed confirm/cancel pair, replacing a button row whose
shape changed at every state. Option ids are the old action ids untouched, so
resolve_action_kind and the per-state legality check are unchanged, and
SessionView.actions keeps being populated -- the deployed web client goes on
working until it reads decision instead.

The regenerated console OpenAPI doc also picks up ErrorCard.diagnostics, missed
when cc9ca8b1dd landed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Charles YaoandClaude Opus 5 committed 2026-09-17 17:55:38 -07:00
1 parent cc9ca8b1dd
commit aa2b4fe4ab
30 files changed
+1163 -18

No files matched your search

+8 -1
View File
@@ -156,7 +156,13 @@ class StarredAppListQuery(AppListBaseQuery):
class CreateAppPayload(BaseModel):
name: str = Field(..., min_length=1, description="App name")
# Optional since App Builder: an app created from the homepage prompt sends
# ``prompt`` and no ``name``. A supplied name still wins -- that is
# create-from-blank, where the user names the app and Builder inherits it.
name: str | None = Field(default=None, min_length=1, description="App name; derived from prompt when omitted")
prompt: str | None = Field(
default=None, description="Goal prompt the app was created from; used to derive the name"
)
description: str | None = Field(default=None, description="App description (max 400 chars)", max_length=400)
mode: Literal["chat", "agent-chat", "advanced-chat", "workflow", "completion"] = Field(..., description="App mode")
icon_type: IconType | None = Field(default=None, description="Icon type")
@@ -708,6 +714,7 @@ class AppListApi(Resource):
"""Create app"""
params = CreateAppParams(
name=req_data.name,
prompt=req_data.prompt,
description=req_data.description,
mode=req_data.mode,
icon_type=req_data.icon_type,
+3 -2
View File
@@ -52,7 +52,7 @@ from core.dify_builder.models import Action, Actor
from libs.helper import dump_response
from libs.login import login_required
from services.dify_builder import progress_bus
from services.dify_builder.service import resolve_action_kind
from services.dify_builder.service import resolve_submitted_action
from services.dify_builder.wiring import (
build_service,
dify_builder_error_response,
@@ -140,6 +140,7 @@ def _create(body, actor: Actor) -> Response | tuple[dict, int]:
actor=actor,
goal_text=payload.goal_text,
model_config=model_config,
derive_app_name=payload.derive_app_name,
)
)
if isinstance(payload, DifyBuilderCreateEditSessionPayload):
@@ -179,7 +180,7 @@ def _action(session_id: str, body, actor: Actor) -> Response | tuple[dict, int]:
except ValidationError:
return {"code": "bad_request"}, 400
action = Action(
kind=resolve_action_kind(payload.action_id),
kind=resolve_submitted_action(payload.action_id, payload.payload),
payload=payload.payload,
base_version=payload.base_version,
base_app_revision=payload.base_app_revision,
@@ -16,6 +16,7 @@ from core.dify_builder.contract import (
ChangeSetCard,
CheckpointCard,
CheckpointRef,
Decision,
DecisionItem,
ErrorCard,
ErrorEventData,
@@ -52,6 +53,10 @@ class DifyBuilderCreateBuildSessionPayload(DifyBuilderPayload):
app_id: NonEmptyString
scenario: Literal["build"]
goal_text: NonEmptyString
# True when the app was created from this same prompt and still carries the
# name cut from it, so Builder may rewrite it once it understands the goal.
# False is create-from-blank: the user named the app, Builder inherits it.
derive_app_name: bool = False
model_config_data: SessionModel | None = Field(default=None, alias="model_config")
@@ -252,6 +257,7 @@ class DifyBuilderSessionViewResponse(ResponseModel):
entry_mode: EntryMode = EntryMode.FIX
phase: Phase = Phase.UNDERSTAND
actions: list[Action] = Field(default_factory=list)
decision: Decision | None = None
active_interaction: DifyBuilderActiveInteractionResponse | None = None
checkpoint: CheckpointRef | None = None
recovery: RecoveryRef | None = None
+66
View File
@@ -168,6 +168,69 @@ class Action:
canvas_event: str | None = None
# The only two buttons an interactive card shows; every real choice is an option
# inside it. One interaction shape at every gate, not one per state.
CONFIRM_ACTION_ID = "confirm"
CANCEL_ACTION_ID = "cancel"
@dataclass
class OptionInput:
"""Free text an option reveals once selected ("Something else", "Reject with a reason").
Bounds are declared for the client to enforce; see ``Decision`` for why the
server does not reject on them yet.
"""
placeholder: str = ""
min_length: int = 0
max_length: int = 100
required: bool = True
@dataclass
class CardOption:
"""One answer a gate offers, inside the card instead of as a button.
``id`` is the former standalone action id, unchanged, so an option resolves
to exactly the handler kind its button used to.
"""
id: str
label: str
# The second line under an option. Empty for now: the per-state action table
# carries labels only, and writing the copy here is a product decision.
description: str = ""
# Shown as a DEFAULT badge. Advisory only -- these gates have side effects,
# so declining to choose must never apply anything.
is_default: bool = False
tone: str = "neutral" # neutral | destructive
input: OptionInput | None = None
next_state: str | None = None
canvas_event: str | None = None
@dataclass
class Decision:
"""What the current gate asks: card options plus the fixed button pair.
Replaces the per-state action bar. The client renders ``options`` in the
active card, shows only ``confirm`` and ``cancel``, and on confirm posts
``confirm`` with ``{"option_id": ..., "free_text": ...}``. ``cancel`` is
presentational -- it dismisses without posting and the gate stays open.
While ``SessionView.actions`` is still populated, the server accepts the old
per-action ids too and does not enforce ``OptionInput`` bounds, so a client
posting a bare action id keeps working. Enforcement moves server-side when
``actions`` goes.
"""
options: list[CardOption] = field(default_factory=list)
confirm: Action | None = None
cancel: Action | None = None
default_option_id: str = ""
@dataclass
class CheckpointRef:
"""Active restore point on the SessionView (spec §8)."""
@@ -320,7 +383,10 @@ class SessionView:
conversation_last_seq: int
entry_mode: EntryMode = EntryMode.FIX
phase: Phase = Phase.UNDERSTAND
# DEPRECATED until the web client reads ``decision``: the same choice, in
# the standalone-button shape.
actions: list[Action] = field(default_factory=list)
decision: Decision | None = None
active_interaction: ActiveInteraction | None = None
checkpoint: CheckpointRef | None = None
recovery: RecoveryRef | None = None
+25
View File
@@ -98,6 +98,30 @@ def _emit_completion(fc: DifyBuilderContext) -> list[ConversationItem]:
return append_card(fc, SummaryCard(variant="completion", title="Build complete", rows=rows))
def _refine_app_name(env: Env, fc: DifyBuilderContext) -> None:
"""Replace the prompt-derived app name with a model-written one, once (spec N1).
Creation named the app by cutting the prompt's opening clause -- mechanical,
and English-shaped where it has to judge. The goal has just been understood
here and a model is in hand, so the name can be rewritten properly, in
whatever language the user wrote. Plan approval then freezes it (spec N3).
The flag is cleared whether or not the proposal was usable, so a session
never renames twice and never retries a model that gave nothing. It is
committed with the transition while the rename commits on its own, so an
advance that fails after this point proposes again next attempt and
overwrites with an equivalent title.
"""
if not fc.app_name_auto:
return
fc.app_name_auto = False
if env.rename_app is None:
return
proposed = env.agent.propose_app_name(fc.goal_text, fc.requirements)
if proposed and proposed.strip():
env.rename_app(proposed)
def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderContext) -> StepResult:
"""(waiting) Entry state. On ``send_goal`` reset the canvas, analyze the
goal into requirements, and transition to build.goal_analysis emitting its
@@ -125,6 +149,7 @@ def handle_capability_check(env: Env, turn: Turn, s: Session, fc: DifyBuilderCon
analysis = env.agent.analyze_goal(fc.goal_text)
fc.form_fields = list(analysis.get("fields") or [])
fc.requirements = dict(analysis.get("values") or {})
_refine_app_name(env, fc)
progress.activate("build-prepare-requirements")
form_items = append_card(
+4
View File
@@ -266,6 +266,10 @@ class DifyBuilderContext:
resource_selection: dict[str, Any] = field(default_factory=dict)
built_node_ids: list[str] = field(default_factory=list)
form_fields: list[dict] = field(default_factory=list)
# True while the app still carries the name cut from its creating prompt, so
# Builder may refine it once (spec N1); cleared the moment it does. Plan
# approval freezes the name for good (spec N3, 批准即定名).
app_name_auto: bool = False
# -- Edit fields (Slice 3, additive) --
edit_rules: dict[str, Any] = field(default_factory=dict)
+150
View File
@@ -0,0 +1,150 @@
"""Name an app after the goal prompt it was created from (App Builder spec, N1).
Pure stdlib, so the console create path and the Builder engine can both use it.
Extraction runs inside ``POST /apps``, so it is a clause cut rather than an
understanding of the sentence: instant and infallible. Anything needing
judgement is left to the Builder's goal-analysis pass, which has a model and the
detected reply language -- dropping an imperative lead-in is seeded for English
and Chinese only here, and a contentless prompt still yields a literal name
rather than the fallback. N3 freezes whatever name approval lands on.
"""
import re
from datetime import datetime
# Long enough to stay meaningful, short enough for a sidebar card. The spec's
# example cut ("Refund approval for ecommerce orders" -> "Refund approval for
# ecommerce") is this limit applied at the preceding word boundary.
MAX_NAME_LENGTH = 30
# Where the first clause ends; the rest is elaboration. ASCII terminators count
# only at a word break so "v1.5" and "gpt-4o-mini" survive, while CJK ones are
# unambiguous alone because CJK text puts no space after them.
_CLAUSE_BOUNDARY = re.compile(r"[—–;;\n\r]|[。!?…]|[.!?]+(?=\s|$)")
# Imperative lead-ins, dropped so the name reads as a thing rather than an
# instruction. An unlisted language keeps its lead-in until the Builder renames.
_LEAD_INS = (
r"i\s+(?:want|need|would\s+like)\s+(?:to\s+)?",
r"(?:can|could|would)\s+you\s+(?:please\s+)?",
r"please\s+",
r"help\s+me\s+(?:to\s+)?",
r"let'?s\s+",
r"(?:set\s+up|build|create|make|design|generate|write|add|implement)\s+(?:me\s+)?",
r"(?:a|an|the)\s+",
r"(?:请|帮我|帮忙|我想要|我想|我需要|我要)\s*",
r"(?:创建|新建|搭建|生成|设计|编写|做)(?:一个|一份|一套|个)?\s*",
r"(?:一个|一份|一套)\s*", # the Chinese article, once a pronoun above is gone
)
_LEAD_IN_RE = re.compile(r"^(?:" + "|".join(_LEAD_INS) + r")", re.IGNORECASE)
# Lead-ins nest ("Please help me build an expense report app"); bound the peeling.
_MAX_LEAD_IN_PEELS = 6
# Any Unicode letter or ideograph. Without one there is no name to be had.
_HAS_LETTER = re.compile(r"[^\W\d_]")
# "New app" per console locale (api/constants/languages.py). The fallback fires
# when the prompt had no words, so it speaks the console's language.
_FALLBACK_STEM: dict[str, str] = {
"en-US": "New app",
"zh-Hans": "新应用",
"zh-Hant": "新應用",
"pt-BR": "Novo app",
"es-ES": "Nueva aplicación",
"fr-FR": "Nouvelle application",
"de-DE": "Neue App",
"ja-JP": "新しいアプリ",
"ko-KR": "새 앱",
"lo-LA": "ແອັບໃໝ່",
"ru-RU": "Новое приложение",
"it-IT": "Nuova app",
"uk-UA": "Новий застосунок",
"vi-VN": "Ứng dụng mới",
"ro-RO": "Aplicație nouă",
"pl-PL": "Nowa aplikacja",
"hi-IN": "नया ऐप",
"tr-TR": "Yeni uygulama",
"fa-IR": "برنامه جدید",
"sl-SI": "Nova aplikacija",
"th-TH": "แอปใหม่",
"id-ID": "Aplikasi baru",
"ar-TN": "تطبيق جديد",
"nl-NL": "Nieuwe app",
}
_DEFAULT_LANGUAGE = "en-US"
# Only the stamp's shape varies. Other locales take the English month
# abbreviation: unambiguous everywhere, and cheaper to keep right than 24 month
# tables for a name meant to be replaced.
_CJK_STAMP = frozenset({"zh-Hans", "zh-Hant", "ja-JP"})
_MONTH_ABBR = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec")
def fallback_app_name(now: datetime, language: str = _DEFAULT_LANGUAGE) -> str:
"""The name for a prompt with nothing nameable in it: ``New app · Sep 7, 14:02``.
``now`` is used as given -- pass it in the account's own timezone, since the
stamp exists to be recognised by the person who just typed.
"""
stem = _FALLBACK_STEM.get(language, _FALLBACK_STEM[_DEFAULT_LANGUAGE])
if language in _CJK_STAMP:
stamp = f"{now.month}月{now.day}日 {now:%H:%M}"
elif language == "ko-KR":
stamp = f"{now.month}월 {now.day}일 {now:%H:%M}"
else:
stamp = f"{_MONTH_ABBR[now.month - 1]} {now.day}, {now:%H:%M}"
return f"{stem} · {stamp}"
def _first_clause(prompt: str) -> str:
return _CLAUSE_BOUNDARY.split(prompt.strip(), maxsplit=1)[0]
def _strip_lead_ins(text: str) -> str:
for _ in range(_MAX_LEAD_IN_PEELS):
peeled = _LEAD_IN_RE.sub("", text, count=1)
# Stop before peeling the name away entirely ("Build me an app" -> "app").
if peeled == text or not _HAS_LETTER.search(peeled):
return text
text = peeled
return text
def _truncate(text: str) -> str:
if len(text) <= MAX_NAME_LENGTH:
return text
head = text[:MAX_NAME_LENGTH]
# Back off to the preceding word boundary when the cut landed inside a word.
# Scripts without spaces (CJK, Thai) have none, and cutting at the character
# is already right there.
if not text[MAX_NAME_LENGTH].isspace() and (boundary := head.rfind(" ")) > 0:
head = head[:boundary]
return head.rstrip()
def derive_app_name(prompt: str, *, now: datetime, language: str = _DEFAULT_LANGUAGE) -> str:
"""Name an app after the prompt that created it. Never raises."""
clause = re.sub(r"\s+", " ", _first_clause(prompt or "")).strip()
candidate = _strip_lead_ins(clause)
peeled = candidate != clause
candidate = _truncate(candidate).strip(" ,.:;·-—–、,。")
if not _HAS_LETTER.search(candidate):
return fallback_app_name(now, language)
if peeled and candidate[:1].islower():
# Peeling "Set up an " leaves a fragment mid-sentence-case. Scripts
# without case are unaffected.
candidate = candidate[0].upper() + candidate[1:]
return candidate
def normalize_proposed_name(name: str) -> str:
"""Make a model-proposed name safe to store, or "" to keep the derived one.
The model is asked for a short title but not trusted to have produced one.
A refinement that cannot improve on the derived name must not replace it.
"""
candidate = re.sub(r"\s+", " ", name or "").strip().strip("\"'“”‘’「」『』")
candidate = _truncate(candidate).strip()
return candidate if _HAS_LETTER.search(candidate) else ""
@@ -103,6 +103,11 @@ class PlaceholderAgent:
},
}
def propose_app_name(self, goal_text: str, requirements: dict[str, Any]) -> str:
# No model, so nothing better than what the create path already cut out
# of the prompt. Returning "" leaves that derived name in place.
return ""
def propose_plan_v1(self, requirements: dict[str, Any]) -> list[str]:
return [
"Ingest source documents",
+4
View File
@@ -77,6 +77,10 @@ class DifyBuilderAgent(Protocol):
def analyze_goal(self, goal_text: str) -> dict[str, Any]: ...
# Short human title for a prompt-created app. Returns the raw proposal; the
# service layer normalizes and stores it.
def propose_app_name(self, goal_text: str, requirements: dict[str, Any]) -> str: ...
def propose_plan_v1(self, requirements: dict[str, Any]) -> list[str]: ...
def discover_resources(self, plan_items: list[str]) -> list[ResourceOption]: ...
+5
View File
@@ -115,6 +115,11 @@ class Env:
# MUST NOT raise: the engine does not guard this call, so the service-layer
# implementation is responsible for swallowing its own errors and returning a safe fallback.
localize_items: Callable[[list["ConversationItem"], str], list["ConversationItem"]] | None = None
# Renames the session's app to a model-proposed title (spec N1). None -> no-op.
# MUST NOT raise: the engine does not guard this call, so the service-layer
# implementation swallows its own errors -- a failed rename leaves the
# derived name in place and never blocks the build.
rename_app: Callable[[str], None] | None = None
# Correlation metadata for the handler transition currently running.
# The runner resets it before every independently committed step.
session_id: str = ""
+38 -1
View File
@@ -16452,6 +16452,19 @@ snapshot must reconstruct the same canvas.
| session_id | string | | Yes |
| stage_id | string | | Yes |
#### CardOption
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| canvas_event | string | | No |
| description | string | | No |
| id | string | | Yes |
| input | [OptionInput](#optioninput) | | No |
| is_default | boolean | | No |
| label | string | | Yes |
| next_state | string | | No |
| tone | string, <br>**Default:** neutral | | No |
#### ChallengeCard
| Name | Type | Description | Required |
@@ -17080,7 +17093,8 @@ Enum class for configurate method of provider model.
| icon_background | string | Icon background color | No |
| icon_type | [IconType](#icontype) | Icon type | No |
| mode | string, <br>**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | App mode<br>*Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes |
| name | string | App name | Yes |
| name | string | App name; derived from prompt when omitted | No |
| prompt | string | Goal prompt the app was created from; used to derive the name | No |
#### CreateSnippetPayload
@@ -17813,6 +17827,15 @@ Enum class for datasource provider
| start_node_id | string | | Yes |
| start_node_title | string | | Yes |
#### Decision
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| cancel | [Action](#action) | | No |
| confirm | [Action](#action) | | No |
| default_option_id | string | | No |
| options | [ [CardOption](#cardoption) ] | | No |
#### DecisionItem
| Name | Type | Description | Required |
@@ -18044,6 +18067,7 @@ Enum representing the deployment edition of the platform.
| canvas_read_only | boolean | | Yes |
| checkpoint | [CheckpointRef](#checkpointref) | | No |
| conversation_last_seq | integer | | Yes |
| decision | [Decision](#decision) | | No |
| entry_mode | [EntryMode](#entrymode) | | No |
| interrupted | boolean | | Yes |
| kind | string, <br>**Default:** command_started | | No |
@@ -18105,6 +18129,7 @@ Enum representing the deployment edition of the platform.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| app_id | string | | Yes |
| derive_app_name | boolean | | No |
| goal_text | string | | Yes |
| model_config | [SessionModel](#sessionmodel) | | No |
| scenario | string | | Yes |
@@ -18273,6 +18298,7 @@ Create a Build, Edit, failed-run Fix, or checklist Fix session.
| canvas_read_only | boolean | | Yes |
| checkpoint | [CheckpointRef](#checkpointref) | | No |
| conversation_last_seq | integer | | Yes |
| decision | [Decision](#decision) | | No |
| entry_mode | [EntryMode](#entrymode) | | No |
| interrupted | boolean | | Yes |
| model | [SessionModel](#sessionmodel) | | No |
@@ -18294,6 +18320,7 @@ Create a Build, Edit, failed-run Fix, or checklist Fix session.
| canvas_read_only | boolean | | Yes |
| checkpoint | [CheckpointRef](#checkpointref) | | No |
| conversation_last_seq | integer | | Yes |
| decision | [Decision](#decision) | | No |
| entry_mode | [EntryMode](#entrymode) | | No |
| interrupted | boolean | | Yes |
| kind | string, <br>**Default:** state | | No |
@@ -18930,6 +18957,7 @@ declaration of an endpoint group
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| body | string | | Yes |
| diagnostics | [ object ] | | No |
| node_id | string | | No |
| title | string | | Yes |
| tone | string, <br>**Default:** danger | | No |
@@ -20853,6 +20881,15 @@ OAuth schema
| label | [I18nObject](#i18nobject) | The label of the option | Yes |
| value | string | The value of the option | Yes |
#### OptionInput
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| max_length | integer, <br>**Default:** 100 | | No |
| min_length | integer | | No |
| placeholder | string | | No |
| required | boolean, <br>**Default:** true | | No |
#### OutputErrorStrategy
Per-output failure handling strategy.
+46 -5
View File
@@ -2,9 +2,10 @@ import json
import logging
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from datetime import datetime
from datetime import UTC, datetime
from typing import Any, Literal, NotRequired, TypedDict, cast, override
import pytz
import sqlalchemy as sa
from pydantic import BaseModel, Field
from sqlalchemy import ColumnElement, delete, select
@@ -15,6 +16,7 @@ from configs import dify_config
from constants.model_template import default_app_templates
from core.agent.entities import AgentToolEntity
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
from core.dify_builder import naming
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
from core.model_manager import ModelManager
from core.tools.tool_manager import ToolManager
@@ -149,8 +151,17 @@ class RecentAppListItem:
maintainer: str | None
# Only a prompt's opening clause can become a name, so nothing past this point
# can affect the result.
_NAME_PROMPT_SCAN_LIMIT = 2000
class CreateAppParams(BaseModel):
name: str = Field(min_length=1)
# Optional since App Builder: an app created from a goal prompt is named
# after it (``core.dify_builder.naming``). A blank pair still names the app
# rather than failing.
name: str | None = Field(default=None, min_length=1)
prompt: str | None = None
description: str | None = None
mode: Literal["chat", "agent-chat", "agent", "advanced-chat", "workflow", "completion"]
agent_role: str = Field(default="", max_length=255)
@@ -575,6 +586,27 @@ class AppService:
session.delete(existing_star)
@staticmethod
def _resolve_app_name(params: CreateAppParams, account: Account) -> str:
"""The name to store (App Builder spec, board N).
A name the user gave wins outright -- that is create-from-blank, where
Builder only inherits. Otherwise it is cut out of the goal prompt, with
the account's timezone and console language shaping the
``New app · Sep 7, 14:02`` fallback.
"""
if params.name:
return params.name
try:
now = datetime.now(pytz.timezone(account.timezone or "UTC"))
except Exception:
now = datetime.now(UTC)
return naming.derive_app_name(
(params.prompt or "")[:_NAME_PROMPT_SCAN_LIMIT],
now=now,
language=account.interface_language or "en-US",
)
def create_app(
self,
tenant_id: str,
@@ -659,7 +691,7 @@ class AppService:
default_model_config["model"] = json.dumps(default_model_dict)
app = App(**app_template["app"])
app.name = params.name
app.name = self._resolve_app_name(params, account)
app.description = params.description or ""
app.mode = app_mode
app.icon_type = IconType(params.icon_type) if params.icon_type else IconType.EMOJI
@@ -979,13 +1011,22 @@ class AppService:
:return: App instance
"""
assert current_user is not None
return self.rename_app(app, name, account_id=current_user.id, session=session)
def rename_app(self, app: App, name: str, *, account_id: str, session: Session) -> App:
"""Rename an app on behalf of ``account_id``.
:meth:`update_app_name` with the actor passed in rather than read off the
request, so callers outside a request context -- the Dify Builder advance
task -- can rename without reaching for ``current_user``.
"""
app.name = name
app.updated_by = current_user.id
app.updated_by = account_id
app.updated_at = naive_utc_now()
self._sync_backing_agent_identity(
app,
name=app.name,
account_id=current_user.id,
account_id=account_id,
updated_at=app.updated_at,
session=session,
)
+43
View File
@@ -5,6 +5,7 @@ Each degrades to an honest result on model-None / provider-error / parse-fail
rather than crashing the advance. build_nodes lives in the same module
(added in Task A6)."""
import json
import logging
from collections.abc import Callable, Sequence
from datetime import UTC, datetime
@@ -21,6 +22,11 @@ from services.workflow_generator_service import WorkflowGeneratorService
logger = logging.getLogger(__name__)
# A reply longer than this is prose, not a title. Well above
# ``naming.MAX_NAME_LENGTH`` so a good-but-long title is trimmed downstream
# rather than rejected here.
_MAX_PROPOSED_NAME_CHARS = 80
def analyze_goal(
model,
@@ -47,6 +53,43 @@ def analyze_goal(
return {"fields": form_schema.reconcile_form_fields(fields, values), "values": values}
def propose_app_name(
model,
goal_text: str,
requirements: dict[str, Any],
on_reasoning: Callable[[str], None] | None = None,
) -> str:
"""Name the app the way a person would title it (spec N1).
Returns "" whenever it cannot beat the name already cut from the prompt --
no model, a failed call, or a sentence instead of a title. The caller then
keeps the derived name, so this never has to be right, only safe.
"""
if model is None:
return ""
system = (
"You name Dify apps. Given the goal the user typed, reply with a short title for "
"the app -- a noun phrase naming what it does, at most 5 words, no trailing "
"punctuation, no quotes, not a sentence and not an instruction. "
"Write it in the same language the user wrote the goal in. "
"Reply with ONLY the title."
)
user = f"GOAL:\n{goal_text}"
if requirements:
user += f"\n\nREQUIREMENTS:\n{json.dumps(requirements, ensure_ascii=False)[:1000]}"
try:
raw = llm.invoke_text(model, system=system, user=user, on_reasoning=on_reasoning).strip()
except Exception:
logger.warning("dify_builder: app-name proposal failed; keeping the derived name", exc_info=True)
return ""
# A sentence ("Sure! Here is a good name for...") is worse than the
# mechanical cut, so accept only a title-shaped reply.
first_line = raw.splitlines()[0].strip() if raw else ""
if not first_line or len(first_line) > _MAX_PROPOSED_NAME_CHARS:
return ""
return first_line
def _degraded_form(goal_text: str) -> dict[str, Any]:
return {
"fields": [{"key": "goal", "label": "Goal", "type": "textarea", "options": []}],
@@ -102,6 +102,14 @@ class LlmBuilderAgent:
def analyze_goal(self, goal_text):
return build.analyze_goal(self.model_or_none(), goal_text, self._reasoning_for("analyze-goal"))
def propose_app_name(self, goal_text, requirements):
return build.propose_app_name(
self.model_or_none(),
goal_text,
requirements,
self._reasoning_for("propose-app-name"),
)
def propose_plan_v1(self, requirements):
return build.propose_plan_v1(
self.model_or_none(),
+46
View File
@@ -0,0 +1,46 @@
"""Store the Builder's proposed app name (spec N1).
The seam between the model's proposal and the app row. Strictly best-effort: a
build that is going fine must not fail because a cosmetic rename did, so every
failure is logged and swallowed and the app keeps its derived name.
"""
import logging
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.dify_builder import naming
from extensions.ext_database import db
from models.model import App
from services.app_service import AppService
logger = logging.getLogger(__name__)
def rename_app_from_proposal(*, app_id: str, tenant_id: str, account_id: str, proposed: str) -> None:
"""Store ``proposed`` as the app's name, if it is usable. Never raises.
Opens its own session: this runs in the Celery advance task, outside any
Flask request.
"""
name = naming.normalize_proposed_name(proposed)
if not name:
return
try:
factory = sessionmaker(bind=db.engine, expire_on_commit=False)
with factory() as session:
app = _load_app(session, app_id=app_id, tenant_id=tenant_id)
if app is None or app.name == name:
return
AppService().rename_app(app, name, account_id=account_id, session=session)
except Exception:
# Nothing downstream reads the name, so there is nothing to recover --
# only a clumsier title to look at.
logger.warning("dify_builder: renaming app %s failed; keeping the derived name", app_id, exc_info=True)
def _load_app(session: Session, *, app_id: str, tenant_id: str) -> App | None:
# Tenant-scoped: the actor's tenant is the only authority this path carries,
# so a cross-tenant id must find nothing rather than rename.
return session.scalar(select(App).where(App.id == app_id, App.tenant_id == tenant_id))
+1
View File
@@ -68,6 +68,7 @@ def context_from_dict(d: dict[str, Any]) -> DifyBuilderContext:
resource_selection=dict(d.get("resource_selection") or {}),
built_node_ids=list(d.get("built_node_ids") or []),
form_fields=list(d.get("form_fields") or []),
app_name_auto=bool(d.get("app_name_auto", False)),
edit_rules=dict(d.get("edit_rules") or {}),
edit_target_node_ids=list(d.get("edit_target_node_ids") or []),
paused=bool(d.get("paused", False)),
+95 -7
View File
@@ -15,14 +15,18 @@ from typing import Protocol
from uuid import uuid4
from core.dify_builder import recovery
from core.dify_builder.contract import Action as UiAction
from core.dify_builder.contract import (
CANCEL_ACTION_ID,
CONFIRM_ACTION_ID,
ActionKind,
ActiveInteraction,
AppRevision,
CardOption,
CheckpointRef,
ConversationPage,
Decision,
NoticeItem,
OptionInput,
Phase,
RunContextCard,
RunStatus,
@@ -30,6 +34,7 @@ from core.dify_builder.contract import (
SessionView,
UserItem,
)
from core.dify_builder.contract import Action as UiAction
from core.dify_builder.errors import BadRequestError, BusyError, ConflictError, NotFoundError
from core.dify_builder.models import (
Action,
@@ -45,7 +50,14 @@ from core.dify_builder.state import PcState, canvas_read_only, is_terminal, is_w
from services.dify_builder.agent.model_resolver import validate_model_config
from services.feature_service import FeatureService
__all__ = ["AppAccess", "DifyBuilderService", "SessionLock", "SessionView", "resolve_action_kind"]
__all__ = [
"AppAccess",
"DifyBuilderService",
"SessionLock",
"SessionView",
"resolve_action_kind",
"resolve_submitted_action",
]
class AppAccess(StrEnum):
@@ -401,6 +413,51 @@ _ACTION_ID_TO_KIND: dict[str, str] = {
}
# Options that need a sentence from the user to be an answer at all. Builder
# replans from the rejection text, so a bare "no" is not something it can act on.
_OPTION_INPUTS: dict[str, OptionInput] = {
"reject_repair": OptionInput(
placeholder="Explain the rejection -- Builder replans from it",
min_length=10,
max_length=200,
required=True,
),
}
def _decision_for(actions: list[UiAction]) -> Decision | None:
"""Project a gate's actions as card options plus the fixed buttons.
Same choice, different shape: a button row that changed per state becomes
options with one confirm and one cancel under them. Option ids are the old
action ids untouched, which keeps ``resolve_action_kind`` and the per-state
legality check working unchanged.
"""
renderable = [a for a in actions if a.kind != ActionKind.AUTOMATIC]
if not renderable:
return None
# Only the first primary is badged: two recommendations is no recommendation.
default_index = next((i for i, a in enumerate(renderable) if a.kind == ActionKind.PRIMARY), -1)
options = [
CardOption(
id=a.id,
label=a.label,
is_default=(i == default_index),
tone="destructive" if a.kind == ActionKind.DESTRUCTIVE else "neutral",
input=_OPTION_INPUTS.get(a.id),
next_state=a.next_state,
canvas_event=a.canvas_event,
)
for i, a in enumerate(renderable)
]
return Decision(
options=options,
confirm=UiAction(id=CONFIRM_ACTION_ID, label="Confirm", kind=ActionKind.PRIMARY),
cancel=UiAction(id=CANCEL_ACTION_ID, label="Cancel", kind=ActionKind.SECONDARY),
default_option_id=options[default_index].id if default_index >= 0 else "",
)
_ACTIVE_INTERACTION_CARD_FOR_ACTION: dict[str, tuple[str, str | None]] = {
"submit_requirements": ("form", "build_requirements"),
"submit_edit_rules": ("form", "edit_rules"),
@@ -520,6 +577,20 @@ def resolve_action_kind(raw: str) -> str:
return _ACTION_ID_TO_KIND.get(raw, raw)
def resolve_submitted_action(action_id: str, payload: dict | None) -> str:
"""Map what the client posted to the engine handler kind.
The card-options client posts ``confirm`` and names its choice in
``payload["option_id"]``; an action-bar client posts that id directly. Both
reach the same kind because an option id IS the action id it replaced, which
is what lets the two contracts run side by side. A ``confirm`` naming no
option resolves to "", which the per-state legality check then rejects.
"""
if action_id == CONFIRM_ACTION_ID:
action_id = str(payload.get("option_id") or "") if isinstance(payload, dict) else ""
return resolve_action_kind(action_id)
_WAITING_INPUT_STATES = frozenset(
{
PcState.FIX_AWAIT_TESTDATA,
@@ -779,7 +850,12 @@ class DifyBuilderService:
return stream_advance_frames(view_dict, subscription, expect_advance=True)
def _prepare_build_session(
self, app_id: str, actor: Actor, goal_text: str, model_config: dict | None = None
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
derive_app_name: bool = False,
) -> tuple[str, Action]:
"""Shared setup for ``create_build_session``/``create_build_session_stream``:
everything through persisting the session up to -- but not including --
@@ -795,6 +871,7 @@ class DifyBuilderService:
skill_learning_policy=policy,
model_config=model_config,
last_snapshot_hash=app_revision,
app_name_auto=derive_app_name,
)
s = Session(
app_id=app_id,
@@ -813,25 +890,35 @@ class DifyBuilderService:
)
def create_build_session(
self, app_id: str, actor: Actor, goal_text: str, model_config: dict | None = None
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
derive_app_name: bool = False,
) -> SessionView:
"""Start a Build session at build.capability_check and dispatch the
initial ``send_goal`` (parallels ``create_fix_session``). The goal is
seeded as the user's opening bubble; the first advance's
``handle_capability_check`` analyzes it into requirements."""
session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config)
session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config, derive_app_name)
if action is not None:
self.dispatch(session_id, action, actor)
return self.get_session_view(session_id, actor)
def create_build_session_stream(
self, app_id: str, actor: Actor, goal_text: str, model_config: dict | None = None
self,
app_id: str,
actor: Actor,
goal_text: str,
model_config: dict | None = None,
derive_app_name: bool = False,
) -> Iterator[str]:
"""Streaming counterpart of ``create_build_session``: subscribes BEFORE
dispatching the initial ``send_goal`` advance."""
from services.dify_builder.wiring import stream_advance_frames
session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config)
session_id, action = self._prepare_build_session(app_id, actor, goal_text, model_config, derive_app_name)
subscription = self._subscribe_fn(session_id) # BEFORE dispatch
try:
self.dispatch(session_id, action, actor)
@@ -1004,6 +1091,7 @@ class DifyBuilderService:
entry_mode=s.entry_mode,
phase=_phase_for(st),
actions=actions,
decision=_decision_for(actions),
active_interaction=self._active_interaction_for(s.id, s.version, actions),
checkpoint=checkpoint,
recovery=recovery_ref,
+13 -1
View File
@@ -46,7 +46,7 @@ from core.dify_builder.runner import CommittedTransition, Env, Runner
from core.dify_builder.state import PcState, is_terminal
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from services.dify_builder import progress_bus, session_lock
from services.dify_builder import app_naming, progress_bus, session_lock
from services.dify_builder.agent.localize import Localizer
from services.dify_builder.agent_factory import build_dify_builder_agent
from services.dify_builder.dify_port import WorkflowServiceDifyPort
@@ -217,6 +217,17 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token:
# `model_or_none` is part of the DifyBuilderAgent protocol; the Localizer
# uses it as its model provider (None -> reply-language localization is a no-op).
localizer = Localizer(agent.model_or_none)
def rename_app(proposed: str) -> None:
assert actor is not None
assert loaded_session is not None
app_naming.rename_app_from_proposal(
app_id=loaded_session.app_id,
tenant_id=actor.tenant_id,
account_id=actor.account_id,
proposed=proposed,
)
env = Env(
dify=dify,
agent=agent,
@@ -230,6 +241,7 @@ def advance_session(session_id: str, action_dict: dict, actor_dict: dict, token:
emit_reasoning=emit_reasoning,
detect_language=localizer.detect_language,
localize_items=localizer.localize_items,
rename_app=rename_app,
)
runner = Runner(env, fix_registry() | build_registry() | edit_registry())
env.begin_operation(loaded_session)
@@ -22,3 +22,24 @@ class TestCreateAppPayloadMode:
def test_rejects_unknown_mode(self):
with pytest.raises(ValidationError):
CreateAppPayload.model_validate({"name": "X", "mode": "not-a-mode"})
class TestCreateAppPayloadNaming:
"""App Builder creates from a prompt, so the client may send no name."""
def test_accepts_a_prompt_without_a_name(self):
payload = CreateAppPayload.model_validate(
{"mode": "workflow", "prompt": "Refund approval for ecommerce orders"}
)
assert payload.name is None
assert payload.prompt == "Refund approval for ecommerce orders"
def test_accepts_neither_name_nor_prompt(self):
payload = CreateAppPayload.model_validate({"mode": "workflow"})
assert payload.name is None
assert payload.prompt is None
def test_still_rejects_an_empty_name(self):
# Omitting the name asks the server to derive one; sending "" is a bug.
with pytest.raises(ValidationError):
CreateAppPayload.model_validate({"name": "", "mode": "workflow"})
@@ -127,7 +127,7 @@ def test_conversation_get_rejects_competing_cursors(monkeypatch):
(
{"scenario": "build", "app_id": "a1", "goal_text": "Build it"},
"create_build_session_stream",
{"app_id": "a1", "goal_text": "Build it", "model_config": None},
{"app_id": "a1", "goal_text": "Build it", "model_config": None, "derive_app_name": False},
),
(
{"scenario": "edit", "app_id": "a1", "goal_text": "Tighten risk"},
@@ -543,3 +543,64 @@ def test_decorator_stack_gate_blocks_when_feature_off(monkeypatch):
def test_session_routes_require_legacy_edit_permission(method):
gate = unwrap(method, stop=lambda wrapper: "edit_permission_required" in wrapper.__code__.co_qualname)
assert "edit_permission_required" in gate.__code__.co_qualname
def test_create_build_defaults_to_not_renaming_the_app(monkeypatch):
service = MagicMock()
service.create_build_session_stream.return_value = _frames()
monkeypatch.setattr(mod, "build_service", lambda: service)
mod._create({"scenario": "build", "app_id": "a1", "goal_text": "Build it"}, _actor())
assert service.create_build_session_stream.call_args.kwargs["derive_app_name"] is False
def test_create_build_forwards_the_rename_opt_in(monkeypatch):
service = MagicMock()
service.create_build_session_stream.return_value = _frames()
monkeypatch.setattr(mod, "build_service", lambda: service)
mod._create(
{"scenario": "build", "app_id": "a1", "goal_text": "Build it", "derive_app_name": True},
_actor(),
)
assert service.create_build_session_stream.call_args.kwargs["derive_app_name"] is True
def test_action_resolves_confirm_through_the_option_the_client_chose(monkeypatch):
service = MagicMock()
service.submit_action_stream.return_value = _frames()
monkeypatch.setattr(mod, "build_service", lambda: service)
response = mod._action(
"s1",
{
"action_id": "confirm",
"payload": {"option_id": "run_validation", "free_text": ""},
"base_version": 1,
"base_app_revision": "hash-1",
},
_actor(),
)
_assert_event_stream(response)
action = service.submit_action_stream.call_args.args[2]
assert action.kind == "run_verify"
# The chosen option and any free text travel on to the handler untouched.
assert action.payload == {"option_id": "run_validation", "free_text": ""}
def test_action_still_accepts_a_client_posting_the_bare_action_id(monkeypatch):
service = MagicMock()
service.submit_action_stream.return_value = _frames()
monkeypatch.setattr(mod, "build_service", lambda: service)
mod._action(
"s1",
{"action_id": "run_validation", "payload": {}, "base_version": 1, "base_app_revision": "hash-1"},
_actor(),
)
assert service.submit_action_stream.call_args.args[2].kind == "run_verify"
@@ -409,6 +409,9 @@ class StubAgent:
def analyze_goal(self, _goal_text):
return {}
def propose_app_name(self, _goal_text, _requirements):
return ""
def propose_plan_v1(self, _requirements):
return []
@@ -1332,3 +1332,68 @@ def test_recovery_restart_resets_interrupted_step_to_entry_state():
reloaded, fc = repo.get_session(s.id)
assert reloaded.current_state == entry_state_for(EntryMode.BUILD) # back at the flow entry
assert fc.staged_repair == [] # working fields reset
class _NamingAgent(PlaceholderAgent):
"""Placeholder cognition plus a model that has an opinion about the name."""
def __init__(self, proposal: str = "Expense reimbursement approval") -> None:
self.proposal = proposal
self.calls: list[tuple[str, dict]] = []
def propose_app_name(self, goal_text, requirements):
self.calls.append((goal_text, dict(requirements)))
return self.proposal
def _run_capability_check(agent, *, app_name_auto: bool, rename_app=None):
from core.dify_builder.handlers_build import build_registry
env, repo = _new_env(agent=agent)
env.rename_app = rename_app
s = _seed_build_session(repo, PcState.BUILD_CAPABILITY_CHECK, app_name_auto=app_name_auto)
runner = Runner(env, build_registry())
runner.advance(s.id, Turn(action=Action(kind="send_goal", payload={"text": "x"}, base_version=1), actor=_actor()))
_session_after, fc = repo.get_session(s.id)
return fc
def test_goal_analysis_renames_an_app_still_carrying_its_derived_name():
agent = _NamingAgent()
renamed: list[str] = []
fc = _run_capability_check(agent, app_name_auto=True, rename_app=renamed.append)
assert renamed == ["Expense reimbursement approval"]
# The goal and the just-analyzed requirements are both handed to the model.
assert agent.calls[0][0] == "x"
assert agent.calls[0][1] == fc.requirements
# Cleared, so a later advance through this state never renames again.
assert fc.app_name_auto is False
def test_an_app_named_by_its_user_is_never_renamed():
agent = _NamingAgent()
renamed: list[str] = []
fc = _run_capability_check(agent, app_name_auto=False, rename_app=renamed.append)
assert renamed == []
assert agent.calls == []
assert fc.app_name_auto is False
def test_a_model_with_no_proposal_leaves_the_derived_name_alone():
agent = _NamingAgent(proposal=" ")
renamed: list[str] = []
fc = _run_capability_check(agent, app_name_auto=True, rename_app=renamed.append)
assert renamed == []
# Still consumed: one shot per session, whether or not it produced anything.
assert fc.app_name_auto is False
def test_no_rename_callback_wired_costs_no_model_call():
agent = _NamingAgent()
fc = _run_capability_check(agent, app_name_auto=True, rename_app=None)
assert agent.calls == []
assert fc.app_name_auto is False
@@ -0,0 +1,113 @@
"""App names derived from the creating prompt (App Builder spec, board N)."""
from datetime import datetime
import pytest
from core.dify_builder import naming
NOW = datetime(2026, 9, 7, 14, 2)
def test_derives_the_name_from_the_first_clause():
# The spec's own example: the trailing noun is dropped at the word boundary
# that keeps the name inside MAX_NAME_LENGTH.
assert naming.derive_app_name("Refund approval for ecommerce orders", now=NOW) == "Refund approval for ecommerce"
def test_drops_an_imperative_lead_in_and_recases_the_fragment():
assert (
naming.derive_app_name(
"Set up an expense reimbursement process — check claims against our policy",
now=NOW,
)
== "Expense reimbursement process"
)
def test_peels_nested_lead_ins():
assert naming.derive_app_name("Please help me build an expense report app", now=NOW) == "Expense report app"
def test_keeps_a_name_that_is_already_a_noun_phrase():
assert naming.derive_app_name("Daily ticket summary", now=NOW) == "Daily ticket summary"
@pytest.mark.parametrize(
("prompt", "expected"),
[
("帮我创建一个报销审批流程。检查金额并通知经理", "报销审批流程"),
("请设计一份文章摘要生成器", "文章摘要生成器"),
# A quantifier left behind by the pronoun is peeled like an article.
("我要一个报销审批流程", "报销审批流程"),
("我想要一套客服机器人", "客服机器人"),
],
)
def test_chinese_prompts_keep_their_own_language(prompt: str, expected: str):
# The name is cut out of the prompt, so it needs no translation.
assert naming.derive_app_name(prompt, now=NOW) == expected
def test_a_version_or_model_string_is_not_a_clause_boundary():
assert naming.derive_app_name("Build a workflow using gpt-4o-mini. Classify tickets.", now=NOW) == (
"Workflow using gpt-4o-mini"
)
def test_a_cjk_terminator_ends_the_clause_without_a_trailing_space():
assert naming.derive_app_name("文章摘要生成器。它读取网页", now=NOW) == "文章摘要生成器"
@pytest.mark.parametrize("prompt", ["", " ", "??? !!!", "123 456"])
def test_a_prompt_with_nothing_nameable_falls_back(prompt: str):
assert naming.derive_app_name(prompt, now=NOW) == "New app · Sep 7, 14:02"
def test_peeling_never_empties_the_name():
# "app" is all that is left; peeling the article too would leave nothing.
assert naming.derive_app_name("Build me an app", now=NOW) == "App"
def test_a_long_unspaced_name_is_cut_at_the_character():
# CJK has no word boundary to back off to, so the cut lands on the limit.
prompt = "报" * 50
assert naming.derive_app_name(prompt, now=NOW) == "报" * naming.MAX_NAME_LENGTH
def test_the_cut_never_severs_a_word_in_a_spaced_script():
name = naming.derive_app_name("Invoices to spreadsheet for my daily expense in company", now=NOW)
assert len(name) <= naming.MAX_NAME_LENGTH
assert not name.endswith(" ")
assert name == "Invoices to spreadsheet for my"
@pytest.mark.parametrize(
("language", "expected"),
[
("en-US", "New app · Sep 7, 14:02"),
("zh-Hans", "新应用 · 9月7日 14:02"),
("ja-JP", "新しいアプリ · 9月7日 14:02"),
("ko-KR", "새 앱 · 9월 7일 14:02"),
("fr-FR", "Nouvelle application · Sep 7, 14:02"),
# An unknown locale takes the English stem rather than raising.
("xx-YY", "New app · Sep 7, 14:02"),
],
)
def test_the_fallback_is_localized(language: str, expected: str):
assert naming.fallback_app_name(NOW, language) == expected
def test_every_console_locale_has_a_fallback_stem():
from constants.languages import languages
assert set(languages) <= set(naming._FALLBACK_STEM)
def test_normalize_proposed_name_unwraps_and_caps_a_model_answer():
assert naming.normalize_proposed_name(' "Expense Reimbursement Approval" ') == "Expense Reimbursement Approval"
@pytest.mark.parametrize("proposal", ["", " ", '""', "!!!"])
def test_normalize_proposed_name_yields_nothing_for_an_unusable_answer(proposal: str):
# "" tells the caller to keep the name already derived from the prompt.
assert naming.normalize_proposed_name(proposal) == ""
@@ -116,6 +116,9 @@ class _StubAgent:
def analyze_goal(self, _goal_text):
return {}
def propose_app_name(self, _goal_text, _requirements):
return ""
def propose_plan_v1(self, _requirements):
return []
@@ -0,0 +1,80 @@
"""Builder-driven app rename: usable proposals only, and never fatal."""
import contextlib
from unittest.mock import MagicMock, patch
import pytest
from services.dify_builder import app_naming
@pytest.fixture
def db_stubbed():
"""Stub the engine so nothing here needs a Flask app context.
Without it the real ``db.engine`` raises, ``rename_app_from_proposal``
swallows that, and every assertion below passes for the wrong reason.
"""
with (
patch.object(app_naming, "db", MagicMock()),
patch.object(app_naming, "sessionmaker") as factory,
):
factory.return_value.return_value = contextlib.nullcontext(MagicMock(name="session"))
yield factory
def test_an_unusable_proposal_never_touches_the_database(db_stubbed):
# No session is opened at all -- the derived name simply stands.
app_naming.rename_app_from_proposal(app_id="a", tenant_id="t", account_id="acc", proposed=' "" ')
db_stubbed.assert_not_called()
@pytest.mark.usefixtures("db_stubbed")
def test_a_usable_proposal_is_normalized_before_it_is_stored():
app = MagicMock()
app.name = "Set up an expense"
with (
patch.object(app_naming, "AppService") as app_service,
patch.object(app_naming, "_load_app", return_value=app),
):
app_naming.rename_app_from_proposal(
app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed=' "Expense approval"\n'
)
app_service.return_value.rename_app.assert_called_once()
args, kwargs = app_service.return_value.rename_app.call_args
assert args[1] == "Expense approval"
assert kwargs["account_id"] == "acc-1"
@pytest.mark.usefixtures("db_stubbed")
def test_a_proposal_equal_to_the_current_name_is_not_written():
app = MagicMock()
app.name = "Expense approval"
with (
patch.object(app_naming, "AppService") as app_service,
patch.object(app_naming, "_load_app", return_value=app),
):
app_naming.rename_app_from_proposal(
app_id="app-1", tenant_id="tenant-1", account_id="acc-1", proposed="Expense approval"
)
app_service.return_value.rename_app.assert_not_called()
@pytest.mark.usefixtures("db_stubbed")
def test_a_missing_app_is_not_an_error():
with (
patch.object(app_naming, "AppService") as app_service,
patch.object(app_naming, "_load_app", return_value=None),
):
app_naming.rename_app_from_proposal(app_id="gone", tenant_id="t", account_id="acc", proposed="Anything")
app_service.return_value.rename_app.assert_not_called()
@pytest.mark.usefixtures("db_stubbed")
def test_a_failing_rename_is_swallowed():
# A cosmetic rename must never take the build down with it.
with (
patch.object(app_naming, "AppService", side_effect=RuntimeError("boom")),
patch.object(app_naming, "_load_app", return_value=MagicMock()),
):
app_naming.rename_app_from_proposal(app_id="a", tenant_id="t", account_id="acc", proposed="Expense approval")
@@ -0,0 +1,137 @@
"""A gate's choice as card options plus one fixed button pair.
The action bar used to change shape per gate -- four buttons at review, one at
plan approval. These project the same choice, identically everywhere.
"""
import pytest
from core.dify_builder.contract import CANCEL_ACTION_ID, CONFIRM_ACTION_ID, ActionKind
from core.dify_builder.state import PcState
from services.dify_builder.service import (
_actions_for,
_decision_for,
resolve_action_kind,
resolve_submitted_action,
)
def _decision(state: PcState):
return _decision_for(_actions_for(state))
def test_a_gate_offers_its_actions_as_options_not_buttons():
decision = _decision(PcState.BUILD_REVIEW)
assert [o.id for o in decision.options] == [
"publish_workflow",
"keep_draft",
"continue_adjusting",
"revert",
]
# Whatever the gate, the buttons are the same two.
assert decision.confirm.id == CONFIRM_ACTION_ID
assert decision.cancel.id == CANCEL_ACTION_ID
def test_exactly_one_option_carries_the_default_badge():
decision = _decision(PcState.BUILD_REVIEW)
defaults = [o.id for o in decision.options if o.is_default]
assert defaults == ["publish_workflow"]
assert decision.default_option_id == "publish_workflow"
def test_a_destructive_action_keeps_its_tone_as_an_option():
decision = _decision(PcState.BUILD_REVIEW)
tones = {o.id: o.tone for o in decision.options}
assert tones["revert"] == "destructive"
assert tones["keep_draft"] == "neutral"
def test_an_option_carries_the_state_map_hints_its_action_had():
decision = _decision(PcState.BUILD_REVIEW)
publish = next(o for o in decision.options if o.id == "publish_workflow")
assert publish.next_state == "build.publish"
assert publish.canvas_event == "publish_workflow"
def test_a_single_action_gate_still_becomes_an_option():
decision = _decision(PcState.BUILD_PLAN_APPROVAL)
assert [o.id for o in decision.options] == ["approve_plan"]
assert decision.options[0].is_default is True
def test_a_gate_offering_nothing_has_no_decision():
# Nothing to choose means no card options and no buttons -- not an empty row.
assert _decision_for([]) is None
def test_rejecting_a_repair_asks_for_a_reason():
decision = _decision(PcState.FIX_AWAIT_APPROVAL)
reject = next(o for o in decision.options if o.id == "reject_repair")
assert reject.input is not None
assert (reject.input.min_length, reject.input.max_length) == (10, 200)
assert reject.input.required is True
def test_options_without_free_text_declare_none():
decision = _decision(PcState.FIX_AWAIT_APPROVAL)
approve = next(o for o in decision.options if o.id == "approve_plan")
assert approve.input is None
def test_an_automatic_action_is_never_offered_as_an_option():
from core.dify_builder.contract import Action as UiAction
decision = _decision_for(
[
UiAction(id="visible", label="Visible", kind=ActionKind.PRIMARY),
UiAction(id="auto", label="Auto", kind=ActionKind.AUTOMATIC),
]
)
assert [o.id for o in decision.options] == ["visible"]
def test_only_the_first_primary_is_the_default():
from core.dify_builder.contract import Action as UiAction
decision = _decision_for(
[
UiAction(id="first", label="First", kind=ActionKind.PRIMARY),
UiAction(id="second", label="Second", kind=ActionKind.PRIMARY),
]
)
# Two recommendations is no recommendation.
assert [o.id for o in decision.options if o.is_default] == ["first"]
class TestResolvingWhatTheClientPosted:
def test_confirm_resolves_through_the_named_option(self):
# "publish_fix" is renamed to the handler kind "publish"; the option id
# goes through exactly the map the button id used to.
assert resolve_submitted_action(CONFIRM_ACTION_ID, {"option_id": "publish_fix"}) == "publish"
def test_an_option_id_that_is_already_a_kind_passes_through(self):
assert resolve_submitted_action(CONFIRM_ACTION_ID, {"option_id": "publish_workflow"}) == "publish_workflow"
def test_a_legacy_client_posting_the_action_id_still_resolves(self):
assert resolve_submitted_action("publish_fix", {}) == "publish"
def test_both_contracts_land_on_the_same_kind(self):
for action_id in ("publish_fix", "run_validation", "revert", "restart"):
assert resolve_submitted_action(CONFIRM_ACTION_ID, {"option_id": action_id}) == resolve_action_kind(
action_id
)
@pytest.mark.parametrize("payload", [{}, None, {"option_id": ""}, {"option_id": None}, "not-a-dict"])
def test_confirm_naming_no_option_resolves_to_nothing(self, payload):
# "" is not legal in any state, so the existing per-state check rejects it.
assert resolve_submitted_action(CONFIRM_ACTION_ID, payload) == ""
@@ -0,0 +1,19 @@
"""The one-shot app-name refine flag survives the commit round-trip."""
from core.dify_builder.models import DifyBuilderContext
from services.dify_builder.serde import context_from_dict, context_to_dict
def test_app_name_auto_defaults_off():
# Off by default: an app is only renamed when its creator asked for it.
assert DifyBuilderContext().app_name_auto is False
def test_app_name_auto_round_trips():
fc = DifyBuilderContext(app_name_auto=True)
assert context_from_dict(context_to_dict(fc)).app_name_auto is True
def test_app_name_auto_absent_in_an_older_row_defaults_off():
# Sessions started before this field must not suddenly rename their apps.
assert context_from_dict({"goal_text": "x"}).app_name_auto is False
@@ -238,6 +238,27 @@ def test_create_build_session_rejects_blank_goal(service: DifyBuilderService, en
assert enqueued == []
@pytest.mark.parametrize(("derive", "expected"), [(True, True), (False, False)])
def test_create_build_session_records_whether_builder_may_rename_the_app(
service: DifyBuilderService, repo: SqlDifyBuilderRepository, derive: bool, expected: bool
) -> None:
# The client knows which entry it came from: the homepage prompt (Builder
# renames once) or create-from-blank (the user already named it).
view = service.create_build_session(APP_ID, _actor(), goal_text="Build it", derive_app_name=derive)
_session, fc = repo.get_session(view.session_id)
assert fc.app_name_auto is expected
def test_create_build_session_does_not_rename_by_default(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
view = service.create_build_session(APP_ID, _actor(), goal_text="Build it")
_session, fc = repo.get_session(view.session_id)
assert fc.app_name_auto is False
def test_create_fix_session_converts_valid_checklist_dicts_before_persisting(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
@@ -772,6 +793,32 @@ def test_get_session_view_actions_for_fix_await_decision(
]
def test_session_view_carries_the_same_choice_as_options_and_as_actions(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.FIX_AWAIT_DECISION)
view = service.get_session_view(s.id, _actor())
# Both contracts are live while the web client migrates, and they must never
# disagree about what this gate is asking.
assert [o.id for o in view.decision.options] == [a.id for a in view.actions]
assert view.decision.default_option_id == "publish_fix"
assert (view.decision.confirm.id, view.decision.cancel.id) == ("confirm", "cancel")
def test_a_session_view_with_nothing_to_decide_has_no_decision(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
s = _seed_session_at(repo, PcState.BUILD_COMPLETE)
view = service.get_session_view(s.id, _actor())
# A finished session asks nothing, so there is no card to put options in.
assert view.actions == []
assert view.decision is None
def test_get_session_view_actions_for_fix_await_verify(
service: DifyBuilderService, repo: SqlDifyBuilderRepository
) -> None:
@@ -39,6 +39,7 @@ def test_session_view_to_dict_round_trips_fields():
"entry_mode": "fix",
"phase": "understand",
"actions": [],
"decision": None,
"active_interaction": None,
"checkpoint": None,
"recovery": None,
@@ -1038,3 +1038,49 @@ class TestAgentAppType:
assert exc_info.value is error
retire_unowned.assert_not_called()
enqueue_collection.assert_not_called()
class TestResolveAppName:
"""Naming an app created from a goal prompt (App Builder spec, board N)."""
@staticmethod
def _account(*, timezone: str | None = "UTC", language: str | None = "en-US") -> Account:
account = Account(name="Namer", email=f"namer-{uuid4()}@example.com")
account.timezone = timezone
account.interface_language = language
return account
def test_a_user_supplied_name_wins_over_the_prompt(self):
# Create-from-blank: the user names the app and Builder only inherits it.
params = CreateAppParams(name="Refund approval", prompt="Something else entirely", mode="workflow")
assert AppService._resolve_app_name(params, self._account()) == "Refund approval"
def test_the_name_is_derived_from_the_prompt_when_omitted(self):
params = CreateAppParams(prompt="Refund approval for ecommerce orders", mode="workflow")
assert AppService._resolve_app_name(params, self._account()) == "Refund approval for ecommerce"
def test_a_prompt_with_nothing_nameable_falls_back_in_the_console_language(self):
params = CreateAppParams(prompt="???", mode="workflow")
name = AppService._resolve_app_name(params, self._account(language="zh-Hans"))
assert name.startswith("新应用 · ")
def test_neither_name_nor_prompt_still_names_the_app(self):
# Creation must never fail for want of a name.
params = CreateAppParams(mode="workflow")
assert AppService._resolve_app_name(params, self._account()).startswith("New app · ")
def test_an_unparseable_account_timezone_does_not_break_creation(self):
params = CreateAppParams(prompt=" ", mode="workflow")
name = AppService._resolve_app_name(params, self._account(timezone="Mars/Olympus_Mons"))
assert name.startswith("New app · ")
def test_the_fallback_stamp_uses_the_account_timezone(self):
params = CreateAppParams(prompt="!!!", mode="workflow")
tokyo = AppService._resolve_app_name(params, self._account(timezone="Asia/Tokyo"))
honolulu = AppService._resolve_app_name(params, self._account(timezone="Pacific/Honolulu"))
# 19 hours apart: the two stamps can never render the same clock time.
assert tokyo != honolulu
def test_only_the_opening_clause_of_a_long_prompt_is_scanned(self):
params = CreateAppParams(prompt="Refund approval for ecommerce orders. " + "x" * 50_000, mode="workflow")
assert AppService._resolve_app_name(params, self._account()) == "Refund approval for ecommerce"