diff --git a/api/controllers/console/app/app.py b/api/controllers/console/app/app.py
index 28baa5b01a5..21d71e6dc95 100644
--- a/api/controllers/console/app/app.py
+++ b/api/controllers/console/app/app.py
@@ -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,
diff --git a/api/controllers/console/dify_builder.py b/api/controllers/console/dify_builder.py
index 1497c40e29d..bf628bc2fc3 100644
--- a/api/controllers/console/dify_builder.py
+++ b/api/controllers/console/dify_builder.py
@@ -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,
diff --git a/api/controllers/console/dify_builder_fields.py b/api/controllers/console/dify_builder_fields.py
index e3016f48929..72474787faf 100644
--- a/api/controllers/console/dify_builder_fields.py
+++ b/api/controllers/console/dify_builder_fields.py
@@ -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
diff --git a/api/core/dify_builder/contract.py b/api/core/dify_builder/contract.py
index a932a349fab..d3c9b9953fd 100644
--- a/api/core/dify_builder/contract.py
+++ b/api/core/dify_builder/contract.py
@@ -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
diff --git a/api/core/dify_builder/handlers_build.py b/api/core/dify_builder/handlers_build.py
index e827804edf0..66cab56c7ec 100644
--- a/api/core/dify_builder/handlers_build.py
+++ b/api/core/dify_builder/handlers_build.py
@@ -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(
diff --git a/api/core/dify_builder/models.py b/api/core/dify_builder/models.py
index 58e050a57cc..a9836a16249 100644
--- a/api/core/dify_builder/models.py
+++ b/api/core/dify_builder/models.py
@@ -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)
diff --git a/api/core/dify_builder/naming.py b/api/core/dify_builder/naming.py
new file mode 100644
index 00000000000..345afaf7b75
--- /dev/null
+++ b/api/core/dify_builder/naming.py
@@ -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 ""
diff --git a/api/core/dify_builder/placeholder_agent.py b/api/core/dify_builder/placeholder_agent.py
index 04efd2c3b07..2523b0296f2 100644
--- a/api/core/dify_builder/placeholder_agent.py
+++ b/api/core/dify_builder/placeholder_agent.py
@@ -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",
diff --git a/api/core/dify_builder/ports.py b/api/core/dify_builder/ports.py
index bac8f28d4f7..55cd0815a19 100644
--- a/api/core/dify_builder/ports.py
+++ b/api/core/dify_builder/ports.py
@@ -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]: ...
diff --git a/api/core/dify_builder/runner.py b/api/core/dify_builder/runner.py
index 33029ced00c..f50e2168b26 100644
--- a/api/core/dify_builder/runner.py
+++ b/api/core/dify_builder/runner.py
@@ -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 = ""
diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md
index 1eeb5031c5d..ca75952f3eb 100644
--- a/api/openapi/markdown/console-openapi.md
+++ b/api/openapi/markdown/console-openapi.md
@@ -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,
**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,
**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | App mode
*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,
**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,
**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,
**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,
**Default:** 100 | | No |
+| min_length | integer | | No |
+| placeholder | string | | No |
+| required | boolean,
**Default:** true | | No |
+
#### OutputErrorStrategy
Per-output failure handling strategy.
diff --git a/api/services/app_service.py b/api/services/app_service.py
index 859f87c09a7..8e3f79a2234 100644
--- a/api/services/app_service.py
+++ b/api/services/app_service.py
@@ -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,
)
diff --git a/api/services/dify_builder/agent/build.py b/api/services/dify_builder/agent/build.py
index dea73a5d5a9..de8d0246547 100644
--- a/api/services/dify_builder/agent/build.py
+++ b/api/services/dify_builder/agent/build.py
@@ -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": []}],
diff --git a/api/services/dify_builder/agent/llm_agent.py b/api/services/dify_builder/agent/llm_agent.py
index 90abe620167..628b9655547 100644
--- a/api/services/dify_builder/agent/llm_agent.py
+++ b/api/services/dify_builder/agent/llm_agent.py
@@ -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(),
diff --git a/api/services/dify_builder/app_naming.py b/api/services/dify_builder/app_naming.py
new file mode 100644
index 00000000000..f3e7e7481d5
--- /dev/null
+++ b/api/services/dify_builder/app_naming.py
@@ -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))
diff --git a/api/services/dify_builder/serde.py b/api/services/dify_builder/serde.py
index cf938028ad0..70f83e884f0 100644
--- a/api/services/dify_builder/serde.py
+++ b/api/services/dify_builder/serde.py
@@ -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)),
diff --git a/api/services/dify_builder/service.py b/api/services/dify_builder/service.py
index da223ec77b8..6fd5104e1c3 100644
--- a/api/services/dify_builder/service.py
+++ b/api/services/dify_builder/service.py
@@ -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,
diff --git a/api/tasks/dify_builder_advance_task.py b/api/tasks/dify_builder_advance_task.py
index 95770b305cb..9aa4eaa0756 100644
--- a/api/tasks/dify_builder_advance_task.py
+++ b/api/tasks/dify_builder_advance_task.py
@@ -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)
diff --git a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py
index dfb2a4c1398..04d300a8fdb 100644
--- a/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py
+++ b/api/tests/unit_tests/controllers/console/app/test_create_app_payload.py
@@ -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"})
diff --git a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py
index 872534e3dda..c9739ffaf0c 100644
--- a/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py
+++ b/api/tests/unit_tests/controllers/console/test_dify_builder_routes.py
@@ -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"
diff --git a/api/tests/unit_tests/core/dify_builder/fakes.py b/api/tests/unit_tests/core/dify_builder/fakes.py
index 2e564a3ce3a..ad5fc88b771 100644
--- a/api/tests/unit_tests/core/dify_builder/fakes.py
+++ b/api/tests/unit_tests/core/dify_builder/fakes.py
@@ -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 []
diff --git a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py
index 629eb5d7fd6..06634d4df4e 100644
--- a/api/tests/unit_tests/core/dify_builder/test_handlers_build.py
+++ b/api/tests/unit_tests/core/dify_builder/test_handlers_build.py
@@ -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
diff --git a/api/tests/unit_tests/core/dify_builder/test_naming.py b/api/tests/unit_tests/core/dify_builder/test_naming.py
new file mode 100644
index 00000000000..d29bbb0b047
--- /dev/null
+++ b/api/tests/unit_tests/core/dify_builder/test_naming.py
@@ -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) == ""
diff --git a/api/tests/unit_tests/core/dify_builder/test_ports.py b/api/tests/unit_tests/core/dify_builder/test_ports.py
index 844e6b954c8..6abd6961bdf 100644
--- a/api/tests/unit_tests/core/dify_builder/test_ports.py
+++ b/api/tests/unit_tests/core/dify_builder/test_ports.py
@@ -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 []
diff --git a/api/tests/unit_tests/services/dify_builder/test_app_naming.py b/api/tests/unit_tests/services/dify_builder/test_app_naming.py
new file mode 100644
index 00000000000..9514b9e3afc
--- /dev/null
+++ b/api/tests/unit_tests/services/dify_builder/test_app_naming.py
@@ -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")
diff --git a/api/tests/unit_tests/services/dify_builder/test_card_options.py b/api/tests/unit_tests/services/dify_builder/test_card_options.py
new file mode 100644
index 00000000000..8b8a9854508
--- /dev/null
+++ b/api/tests/unit_tests/services/dify_builder/test_card_options.py
@@ -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) == ""
diff --git a/api/tests/unit_tests/services/dify_builder/test_serde_app_name_auto.py b/api/tests/unit_tests/services/dify_builder/test_serde_app_name_auto.py
new file mode 100644
index 00000000000..478a2be7c5d
--- /dev/null
+++ b/api/tests/unit_tests/services/dify_builder/test_serde_app_name_auto.py
@@ -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
diff --git a/api/tests/unit_tests/services/dify_builder/test_service.py b/api/tests/unit_tests/services/dify_builder/test_service.py
index f35e93a6650..8d1cce45aa6 100644
--- a/api/tests/unit_tests/services/dify_builder/test_service.py
+++ b/api/tests/unit_tests/services/dify_builder/test_service.py
@@ -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:
diff --git a/api/tests/unit_tests/services/dify_builder/test_wiring.py b/api/tests/unit_tests/services/dify_builder/test_wiring.py
index 4ebb08f1baf..15f065ffeb3 100644
--- a/api/tests/unit_tests/services/dify_builder/test_wiring.py
+++ b/api/tests/unit_tests/services/dify_builder/test_wiring.py
@@ -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,
diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py
index d568fb6742c..1f108f4241d 100644
--- a/api/tests/unit_tests/services/test_app_service.py
+++ b/api/tests/unit_tests/services/test_app_service.py
@@ -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"