mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 23:06:40 +08:00
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:
1 parent
cc9ca8b1dd
commit
aa2b4fe4ab
30 files changed
+1163
-18
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]: ...
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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))
|
||||
@@ -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)),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user