fix(agent): isolate build and preview chat conversations (#39405)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-07-22 17:17:55 +08:00 committed by GitHub
parent 187501f53e
commit 0f9fffb7a2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
12 changed files with 370 additions and 67 deletions

View File

@ -62,7 +62,7 @@ from libs.datetime_utils import parse_time_range
from libs.helper import dump_response
from libs.login import login_required
from models import Account
from models.agent import Agent, AgentStatus
from models.agent import Agent, AgentConfigDraftType, AgentStatus
from models.agent_config_entities import AgentSoulConfig
from models.enums import ApiTokenType
from models.model import ApiToken, App, IconType
@ -266,6 +266,13 @@ class AgentDebugConversationRefreshResponse(BaseModel):
debug_conversation_message_count: int = 0
class AgentDebugConversationRefreshPayload(BaseModel):
draft_type: AgentConfigDraftType = Field(
default=AgentConfigDraftType.DEBUG_BUILD,
description="Agent draft surface whose conversation should be refreshed",
)
class AgentPublishPayload(BaseModel):
version_note: str | None = Field(default=None, description="Optional note for this published Agent version")
@ -309,6 +316,7 @@ register_schema_models(
AgentAppCopyPayload,
AgentPublishPayload,
AgentBuildDraftCheckoutPayload,
AgentDebugConversationRefreshPayload,
ComposerSavePayload,
AgentApiStatusPayload,
AgentInviteOptionsQuery,
@ -392,6 +400,7 @@ def _serialize_agent_app_detail(
tenant_id=app_model.tenant_id,
agent_id=agent.id,
account_id=current_user.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
commit=False,
)
message_count = roster_service.count_agent_app_debug_conversation_messages(
@ -439,6 +448,7 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_
tenant_id=tenant_id,
agents=list(agents_by_app_id.values()),
account_id=current_user.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
)
payload = AgentAppPagination.model_validate(
app_pagination,
@ -655,6 +665,16 @@ class AgentAppApi(Resource):
@console_ns.route("/agent/<uuid:agent_id>/debug-conversation/refresh")
class AgentDebugConversationRefreshApi(Resource):
@console_ns.expect(console_ns.models[AgentDebugConversationRefreshPayload.__name__])
@console_ns.doc(
params={
"payload": {
"in": "body",
"required": False,
"schema": {"$ref": f"#/components/schemas/{AgentDebugConversationRefreshPayload.__name__}"},
}
}
)
@console_ns.response(
200,
"Agent debug conversation refreshed",
@ -669,10 +689,12 @@ class AgentDebugConversationRefreshApi(Resource):
@with_current_tenant_id
@with_session
def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID):
args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {})
debug_conversation_id = _agent_roster_service(session).refresh_agent_app_debug_conversation_id(
tenant_id=tenant_id,
agent_id=str(agent_id),
account_id=current_user.id,
draft_type=args.draft_type,
)
return AgentDebugConversationRefreshResponse(
debug_conversation_id=debug_conversation_id,

View File

@ -49,6 +49,7 @@ from libs import helper
from libs.helper import uuid_value
from libs.login import login_required
from models import Account
from models.agent import AgentConfigDraftType
from models.model import App, AppMode
from services.agent.errors import AgentNotFoundError
from services.agent.roster_service import AgentRosterService
@ -343,14 +344,23 @@ class AgentChatMessageStopApi(Resource):
def _resolve_current_user_agent_debug_conversation_id(
*, session: Session, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None
*,
session: Session,
current_tenant_id: str,
current_user: Account,
app_model: App,
agent_id: str | None,
draft_type: AgentConfigDraftType,
) -> str:
"""Resolve the current editor's conversation without crossing draft surfaces."""
roster_service = AgentRosterService(session)
if agent_id:
return roster_service.get_or_create_agent_app_debug_conversation_id(
tenant_id=current_tenant_id,
agent_id=agent_id,
account_id=current_user.id,
draft_type=draft_type,
)
agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id))
@ -360,6 +370,7 @@ def _resolve_current_user_agent_debug_conversation_id(
tenant_id=current_tenant_id,
agent_id=agent.id,
account_id=current_user.id,
draft_type=draft_type,
)
@ -382,6 +393,7 @@ def _create_chat_message(
current_user=current_user,
app_model=app_model,
agent_id=agent_id,
draft_type=AgentConfigDraftType(args_model.draft_type),
)
if args_model.conversation_id and args_model.conversation_id != debug_conversation_id:
raise NotFound("Conversation Not Exists.")
@ -418,6 +430,7 @@ def _create_build_chat_finalization_message(
current_user=current_user,
app_model=app_model,
agent_id=agent_id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
)
args: dict[str, Any] = {
"query": _BUILD_CHAT_FINALIZATION_QUERY,

View File

@ -0,0 +1,77 @@
"""scope agent debug conversations by draft type
Revision ID: d2825e7b9c10
Revises: b8c9d0e1f2a3
Create Date: 2026-07-22 15:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "d2825e7b9c10"
down_revision = "b8c9d0e1f2a3"
branch_labels = None
depends_on = None
def upgrade():
# Existing pointers have always represented Build chat because the Agent
# detail API exposes them as ``debug_conversation_id`` for that surface.
op.add_column(
"agent_debug_conversations",
sa.Column(
"draft_type",
sa.String(length=32),
nullable=False,
server_default=sa.text("'debug_build'"),
),
)
op.drop_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id", "draft_type"],
)
def downgrade():
debug_conversations = sa.table(
"agent_debug_conversations",
sa.column("tenant_id", models.types.StringUUID()),
sa.column("agent_id", models.types.StringUUID()),
sa.column("account_id", models.types.StringUUID()),
sa.column("draft_type", sa.String(length=32)),
)
build_conversations = debug_conversations.alias("build_conversations")
op.get_bind().execute(
sa.delete(debug_conversations).where(
debug_conversations.c.draft_type == "draft",
sa.exists(
sa.select(sa.literal(1)).where(
build_conversations.c.tenant_id == debug_conversations.c.tenant_id,
build_conversations.c.agent_id == debug_conversations.c.agent_id,
build_conversations.c.account_id == debug_conversations.c.account_id,
build_conversations.c.draft_type == "debug_build",
)
),
)
)
op.drop_constraint(
"agent_debug_conversation_agent_account_draft_type_unique",
"agent_debug_conversations",
type_="unique",
)
op.create_unique_constraint(
"agent_debug_conversation_agent_account_unique",
"agent_debug_conversations",
["tenant_id", "agent_id", "account_id"],
)
op.drop_column("agent_debug_conversations", "draft_type")

View File

@ -222,11 +222,13 @@ class Agent(DefaultFieldsMixin, Base):
class AgentDebugConversation(DefaultFieldsMixin, Base):
"""Per-account console debug conversation for an Agent App.
"""Per-account, per-draft console debug conversation for an Agent App.
Agent App preview state must be isolated by editor account. The Agent row is
shared by everyone in the workspace, so this table owns the user-specific
conversation pointer used by console debug chat.
conversation pointers used by console debug chat. ``draft`` is the Preview
conversation and ``debug_build`` is the Build conversation; they must never
share persisted messages or runtime sessions.
"""
__tablename__ = "agent_debug_conversations"
@ -236,7 +238,8 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
"tenant_id",
"agent_id",
"account_id",
name="agent_debug_conversation_agent_account_unique",
"draft_type",
name="agent_debug_conversation_agent_account_draft_type_unique",
),
Index("agent_debug_conversation_conversation_idx", "conversation_id"),
Index("agent_debug_conversation_account_idx", "tenant_id", "account_id"),
@ -246,6 +249,12 @@ class AgentDebugConversation(DefaultFieldsMixin, Base):
agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
draft_type: Mapped[AgentConfigDraftType] = mapped_column(
EnumText(AgentConfigDraftType, length=32),
nullable=False,
default=AgentConfigDraftType.DEBUG_BUILD,
server_default=sa.text("'debug_build'"),
)
conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False)

View File

@ -959,6 +959,12 @@ Stop a running Agent App chat message generation
| ---- | ---------- | ----------- | -------- | ------ |
| agent_id | path | | Yes | string (uuid) |
#### Request Body
| Required | Schema |
| -------- | ------ |
| No | **application/json**: [AgentDebugConversationRefreshPayload](#agentdebugconversationrefreshpayload)<br> |
#### Responses
| Code | Description | Schema |
@ -13906,6 +13912,12 @@ Stable Agent Soul reference to one normalized skill archive.
| date | string | | Yes |
| message_count | integer | | Yes |
#### AgentDebugConversationRefreshPayload
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | Agent draft surface whose conversation should be refreshed | No |
#### AgentDebugConversationRefreshResponse
| Name | Type | Description | Required |

View File

@ -430,7 +430,11 @@ class AgentRosterService:
agent.active_config_has_model = agent_soul_has_model(soul)
agent.active_config_is_published = False
self._session.flush()
self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
self._get_or_create_agent_app_debug_conversation(
agent=agent,
account_id=account_id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
)
return agent
def create_hidden_backing_app_for_workflow_agent(
@ -527,7 +531,11 @@ class AgentRosterService:
self._session.flush()
return backing_app.id
def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str:
def _get_or_create_agent_app_debug_conversation(
self, *, agent: Agent, account_id: str, draft_type: AgentConfigDraftType
) -> str:
"""Return the editor's conversation for one Agent draft surface."""
backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id)
if not backing_app_id:
raise AgentNotFoundError()
@ -537,6 +545,7 @@ class AgentRosterService:
AgentDebugConversation.tenant_id == agent.tenant_id,
AgentDebugConversation.agent_id == agent.id,
AgentDebugConversation.account_id == account_id,
AgentDebugConversation.draft_type == draft_type,
)
)
if mapping is not None:
@ -570,6 +579,7 @@ class AgentRosterService:
agent_id=agent.id,
app_id=backing_app_id,
account_id=account_id,
draft_type=draft_type,
conversation_id=conversation_id,
)
)
@ -577,9 +587,15 @@ class AgentRosterService:
return conversation_id
def get_or_create_agent_app_debug_conversation_id(
self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True
self,
*,
tenant_id: str,
agent_id: str,
account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
commit: bool = True,
) -> str:
"""Return the current editor's debug conversation for an Agent App."""
"""Return the current editor's Build or Preview conversation for an Agent App."""
agent = self._session.scalar(
select(Agent).where(
@ -591,13 +607,24 @@ class AgentRosterService:
if agent is None:
raise AgentNotFoundError()
conversation_id = self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id)
conversation_id = self._get_or_create_agent_app_debug_conversation(
agent=agent,
account_id=account_id,
draft_type=draft_type,
)
if commit:
self._session.commit()
return conversation_id
def load_agent_app_debug_conversation_id(self, *, tenant_id: str, agent_id: str, account_id: str) -> str | None:
"""Return the current editor's existing debug conversation without creating or repairing rows."""
def load_agent_app_debug_conversation_id(
self,
*,
tenant_id: str,
agent_id: str,
account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
) -> str | None:
"""Return the editor's existing scoped conversation without creating or repairing rows."""
return self._session.scalar(
select(Conversation.id)
@ -606,6 +633,7 @@ class AgentRosterService:
AgentDebugConversation.tenant_id == tenant_id,
AgentDebugConversation.agent_id == agent_id,
AgentDebugConversation.account_id == account_id,
AgentDebugConversation.draft_type == draft_type,
AgentDebugConversation.app_id == Conversation.app_id,
Conversation.from_source == ConversationFromSource.CONSOLE,
Conversation.from_account_id == account_id,
@ -626,16 +654,21 @@ class AgentRosterService:
)
def refresh_agent_app_debug_conversation_id(
self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True
self,
*,
tenant_id: str,
agent_id: str,
account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
commit: bool = True,
) -> str:
"""Start a new console debug conversation for the current Agent App editor.
"""Start a new scoped console conversation for the current Agent App editor.
If this account already has a debug conversation mapping, the previous
If this account already has a mapping for the requested draft surface, the previous
conversation is abandoned first: any ACTIVE conversation-owned Agent
runtime sessions for that old conversation are sent through best-effort
backend cleanup and then retired locally even when enqueueing fails.
The debug mapping is then repointed to the freshly created
conversation.
backend cleanup and then retired locally even when enqueueing fails. The
other draft surface is left untouched.
"""
agent = self._session.scalar(
@ -663,6 +696,7 @@ class AgentRosterService:
AgentDebugConversation.tenant_id == tenant_id,
AgentDebugConversation.agent_id == agent_id,
AgentDebugConversation.account_id == account_id,
AgentDebugConversation.draft_type == draft_type,
)
)
if mapping is None:
@ -672,6 +706,7 @@ class AgentRosterService:
agent_id=agent_id,
app_id=backing_app_id,
account_id=account_id,
draft_type=draft_type,
conversation_id=conversation_id,
)
)
@ -683,6 +718,7 @@ class AgentRosterService:
tenant_id=tenant_id,
agent_id=agent_id,
account_id=account_id,
draft_type=draft_type,
app_id=previous_app_id or backing_app_id,
conversation_id=previous_conversation_id,
)
@ -699,6 +735,7 @@ class AgentRosterService:
tenant_id: str,
agent_id: str,
account_id: str,
draft_type: AgentConfigDraftType,
app_id: str,
conversation_id: str,
) -> None:
@ -727,7 +764,8 @@ class AgentRosterService:
session_snapshot=stored_session.session_snapshot,
runtime_layer_specs=stored_session.runtime_layer_specs,
idempotency_key=(
f"{tenant_id}:{agent_id}:{account_id}:{conversation_id}:debug-session-cleanup:"
f"{tenant_id}:{agent_id}:{account_id}:{draft_type.value}:{conversation_id}:"
"debug-session-cleanup:"
f"{stored_session.scope.agent_id}:"
f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:"
f"{stored_session.backend_run_id or 'no-run'}"
@ -738,6 +776,7 @@ class AgentRosterService:
"conversation_id": stored_session.scope.conversation_id,
"agent_id": stored_session.scope.agent_id,
"agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id,
"draft_type": draft_type.value,
"previous_agent_backend_run_id": stored_session.backend_run_id,
},
)
@ -772,9 +811,14 @@ class AgentRosterService:
)
def load_or_create_agent_app_debug_conversation_ids_by_agent_id(
self, *, tenant_id: str, agents: list[Agent], account_id: str
self,
*,
tenant_id: str,
agents: list[Agent],
account_id: str,
draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD,
) -> dict[str, str]:
"""Return per-account debug conversations for a page of Agent Apps."""
"""Return per-account scoped conversations for a page of Agent Apps."""
conversation_ids_by_agent_id: dict[str, str] = {}
changed = False
@ -784,6 +828,7 @@ class AgentRosterService:
conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation(
agent=agent,
account_id=account_id,
draft_type=draft_type,
)
changed = True
if changed:

View File

@ -53,6 +53,7 @@ from controllers.console.app.message import (
AgentMessageFeedbackApi,
AgentMessageSuggestedQuestionApi,
)
from models.agent import AgentConfigDraftType
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
@ -371,6 +372,7 @@ def test_agent_app_list_and_create_use_agent_route(
"tenant_id": "tenant-1",
"agent_id": "agent-created",
"account_id": account_id,
"draft_type": AgentConfigDraftType.DEBUG_BUILD,
"commit": False,
}
@ -544,8 +546,19 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
}
def test_agent_debug_conversation_refresh_uses_current_user(
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
@pytest.mark.parametrize(
("payload", "expected_draft_type"),
[
(None, AgentConfigDraftType.DEBUG_BUILD),
({"draft_type": "draft"}, AgentConfigDraftType.DRAFT),
],
)
def test_agent_debug_conversation_refresh_uses_current_user_and_draft_type(
app: Flask,
monkeypatch: pytest.MonkeyPatch,
account_id: str,
payload: dict[str, str] | None,
expected_draft_type: AgentConfigDraftType,
) -> None:
agent_id = "00000000-0000-0000-0000-000000000001"
captured: dict[str, object] = {}
@ -557,7 +570,9 @@ def test_agent_debug_conversation_refresh_uses_current_user(
monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda *_args: FakeRosterService())
with app.test_request_context(
"/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", method="POST"
"/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh",
method="POST",
json=payload,
):
response = unwrap(AgentDebugConversationRefreshApi.post)(
AgentDebugConversationRefreshApi(), MagicMock(), "tenant-1", SimpleNamespace(id=account_id), agent_id
@ -567,7 +582,12 @@ def test_agent_debug_conversation_refresh_uses_current_user(
"debug_conversation_has_messages": False,
"debug_conversation_message_count": 0,
}
assert captured == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
assert captured == {
"tenant_id": "tenant-1",
"agent_id": agent_id,
"account_id": account_id,
"draft_type": expected_draft_type,
}
def test_agent_publish_and_build_draft_routes_call_composer_service(
@ -1456,6 +1476,7 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
"current_user": SimpleNamespace(id=account_id),
"app_model": app_model,
"agent_id": "agent-1",
"draft_type": AgentConfigDraftType.DEBUG_BUILD,
}
generate_call = cast(dict[str, object], captured["generate"])
assert generate_call["app_model"] is app_model
@ -1520,11 +1541,15 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace(
captured.update(kwargs)
return {"answer": "ok"}
def resolve_debug_conversation(**kwargs: object) -> str:
captured["resolve_debug_conversation"] = kwargs
return "debug-conversation-1"
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
monkeypatch.setattr(
completion_controller,
"_resolve_current_user_agent_debug_conversation_id",
lambda **kwargs: "debug-conversation-1",
resolve_debug_conversation,
)
monkeypatch.setattr(
completion_controller.helper, "compact_generate_response", lambda response: {"response": response}
@ -1544,6 +1569,7 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace(
assert args["conversation_id"] == "debug-conversation-1"
assert args["auto_generate_name"] is False
assert args["external_trace_id"] == "trace-1"
assert cast(dict[str, object], captured["resolve_debug_conversation"])["draft_type"] == AgentConfigDraftType.DRAFT
def test_agent_chat_helper_ignores_private_exit_intent_payload_key(
@ -1642,6 +1668,7 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
agent_id="agent-1",
draft_type=AgentConfigDraftType.DRAFT,
)
fallback_id = completion_controller._resolve_current_user_agent_debug_conversation_id(
session="session-1", # type: ignore[arg-type]
@ -1649,13 +1676,26 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app
current_user=SimpleNamespace(id="account-1"),
app_model=SimpleNamespace(id="app-1"),
agent_id=None,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
)
assert explicit_id == "debug-agent-1"
assert fallback_id == "debug-backing-agent"
assert calls[1] == {"get_or_create": {"tenant_id": "tenant-1", "agent_id": "agent-1", "account_id": "account-1"}}
assert calls[1] == {
"get_or_create": {
"tenant_id": "tenant-1",
"agent_id": "agent-1",
"account_id": "account-1",
"draft_type": AgentConfigDraftType.DRAFT,
}
}
assert calls[3] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}}
assert calls[4] == {
"get_or_create": {"tenant_id": "tenant-1", "agent_id": "backing-agent", "account_id": "account-1"}
"get_or_create": {
"tenant_id": "tenant-1",
"agent_id": "backing-agent",
"account_id": "account-1",
"draft_type": AgentConfigDraftType.DEBUG_BUILD,
}
}

View File

@ -574,6 +574,27 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest
assert params["avatar"]["required"] is True
def test_console_agent_debug_conversation_refresh_body_is_optional(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
from controllers.console import bp as console_bp
monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True)
app = Flask(__name__)
app.config["TESTING"] = True
app.register_blueprint(console_bp)
payload = app.test_client().get("/console/api/openapi.json").get_json()
operation = payload["paths"]["/agent/{agent_id}/debug-conversation/refresh"]["post"]
request_body = operation["requestBody"]
assert request_body["required"] is False
assert request_body["content"]["application/json"]["schema"] == {
"$ref": "#/components/schemas/AgentDebugConversationRefreshPayload"
}
assert "AgentDebugConversationRefreshPayload" in payload["components"]["schemas"]
def test_console_member_invite_documents_bad_request_response(monkeypatch: pytest.MonkeyPatch):
from configs import dify_config
from controllers.console import bp as console_bp

View File

@ -2624,7 +2624,7 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch
monkeypatch.setattr(
AgentRosterService,
"_get_or_create_agent_app_debug_conversation",
lambda self, *, agent, account_id: "debug-conversation-1",
lambda self, *, agent, account_id, draft_type: "debug-conversation-1",
)
payload = roster_service.RosterAgentCreatePayload(
name="Analyst",
@ -2730,6 +2730,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate():
assert created_mapping.tenant_id == "tenant-1"
assert created_mapping.agent_id == "agent-1"
assert created_mapping.account_id == "account-1"
assert created_mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD
assert create_session.commits == 1
existing_mapping = AgentDebugConversation(
@ -2737,6 +2738,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate():
agent_id="agent-1",
app_id="app-1",
account_id="account-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id="existing-conversation",
)
reuse_session = FakeSession(scalar=[agent, existing_mapping, "existing-conversation"])
@ -2754,6 +2756,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate():
agent_id="agent-1",
app_id="app-1",
account_id="account-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id="deleted-conversation",
)
recreate_session = FakeSession(scalar=[agent, stale_mapping, None])
@ -2768,6 +2771,42 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate():
assert recreate_session.commits == 1
def test_agent_app_debug_conversations_are_isolated_by_draft_type():
agent = Agent(
id="agent-1",
tenant_id="tenant-1",
app_id="app-1",
name="Analyst",
description="",
agent_kind=AgentKind.DIFY_AGENT,
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
)
session = FakeSession(scalar=[agent, None, agent, None])
service = AgentRosterService(session)
build_conversation_id = service.get_or_create_agent_app_debug_conversation_id(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
)
preview_conversation_id = service.get_or_create_agent_app_debug_conversation_id(
tenant_id="tenant-1",
agent_id="agent-1",
account_id="account-1",
draft_type=AgentConfigDraftType.DRAFT,
)
mappings = [value for value in session.added if isinstance(value, AgentDebugConversation)]
assert build_conversation_id != preview_conversation_id
assert {mapping.draft_type for mapping in mappings} == {
AgentConfigDraftType.DRAFT,
AgentConfigDraftType.DEBUG_BUILD,
}
def test_agent_app_debug_conversation_message_count():
session = FakeSession(scalar=[3])
@ -2794,6 +2833,7 @@ def test_agent_app_debug_conversation_requires_app_binding():
AgentRosterService(FakeSession())._get_or_create_agent_app_debug_conversation(
agent=agent,
account_id="account-1",
draft_type=AgentConfigDraftType.DEBUG_BUILD,
)
@ -2840,7 +2880,9 @@ def test_load_or_create_agent_app_debug_conversations_supports_runtime_backed_ag
assert result["agent-1"]
assert result["agent-3"]
assert fake_session.commits == 1
assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 2
mappings = [value for value in fake_session.added if isinstance(value, AgentDebugConversation)]
assert len(mappings) == 2
assert all(mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD for mapping in mappings)
def test_agent_app_visible_versions_exclude_draft_saves():
@ -3276,6 +3318,7 @@ class TestAgentAppBackingAgent:
assert mappings[0].agent_id == "agent-1"
assert mappings[0].app_id == "app-1"
assert mappings[0].account_id == "account-1"
assert mappings[0].draft_type == AgentConfigDraftType.DEBUG_BUILD
assert mappings[0].conversation_id == conversation_id
assert session.deleted == []
assert session.commits == 1
@ -3361,8 +3404,10 @@ class TestAgentAppBackingAgent:
payload = cleanup_delay.call_args.args[0]
assert payload["metadata"]["conversation_id"] == "old-conversation"
assert payload["metadata"]["agent_id"] == "agent-9"
assert payload["metadata"]["draft_type"] == "debug_build"
assert (
payload["idempotency_key"] == "tenant-1:agent-1:account-1:old-conversation:debug-session-cleanup:"
payload["idempotency_key"]
== "tenant-1:agent-1:account-1:debug_build:old-conversation:debug-session-cleanup:"
"agent-9:snap-9:run-old"
)
cleanup_store.mark_cleaned.assert_called_once_with(

View File

@ -143,6 +143,7 @@ import {
zPostAgentByAgentIdCopyBody,
zPostAgentByAgentIdCopyPath,
zPostAgentByAgentIdCopyResponse,
zPostAgentByAgentIdDebugConversationRefreshBody,
zPostAgentByAgentIdDebugConversationRefreshPath,
zPostAgentByAgentIdDebugConversationRefreshResponse,
zPostAgentByAgentIdFeaturesBody,
@ -852,7 +853,12 @@ export const post12 = oc
path: '/agent/{agent_id}/debug-conversation/refresh',
tags: ['console'],
})
.input(z.object({ params: zPostAgentByAgentIdDebugConversationRefreshPath }))
.input(
z.object({
body: zPostAgentByAgentIdDebugConversationRefreshBody.optional(),
params: zPostAgentByAgentIdDebugConversationRefreshPath,
}),
)
.output(zPostAgentByAgentIdDebugConversationRefreshResponse)
export const refresh = {

View File

@ -282,6 +282,10 @@ export type AgentAppCopyPayload = {
role?: string | null
}
export type AgentDebugConversationRefreshPayload = {
draft_type?: AgentConfigDraftType
}
export type AgentDebugConversationRefreshResponse = {
debug_conversation_has_messages?: boolean
debug_conversation_id: string
@ -818,6 +822,8 @@ export type AgentConfigSkillMarkdownResponse = {
truncated: boolean
}
export type AgentConfigDraftType = 'debug_build' | 'draft'
export type AgentDriveItemResponse = {
created_at?: number | null
file_kind: string
@ -1221,8 +1227,6 @@ export type AgentSoulToolsConfig = {
dify_tools?: Array<AgentSoulDifyToolConfig>
}
export type AgentConfigDraftType = 'debug_build' | 'draft'
export type DeclaredOutputConfig = {
array_item?: DeclaredArrayItem | null
check?: DeclaredOutputCheckConfig | null
@ -2753,7 +2757,7 @@ export type PostAgentByAgentIdCopyResponse =
PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses]
export type PostAgentByAgentIdDebugConversationRefreshData = {
body?: never
body?: AgentDebugConversationRefreshPayload
path: {
agent_id: string
}

View File

@ -651,6 +651,45 @@ export const zAgentConfigSkillInspectResponse = z.object({
warnings: z.array(z.string()).optional(),
})
/**
* AgentConfigDraftType
*
* Editable Agent Soul draft workspace type.
*/
export const zAgentConfigDraftType = z.enum(['debug_build', 'draft'])
/**
* AgentDebugConversationRefreshPayload
*/
export const zAgentDebugConversationRefreshPayload = z.object({
draft_type: zAgentConfigDraftType.optional().default('debug_build'),
})
/**
* AgentConfigDraftSummaryResponse
*/
export const zAgentConfigDraftSummaryResponse = z.object({
account_id: z.string().nullish(),
agent_id: z.string(),
base_snapshot_id: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
draft_type: zAgentConfigDraftType,
id: z.string(),
updated_at: z.int().nullish(),
updated_by: z.string().nullish(),
})
/**
* AgentPublishResponse
*/
export const zAgentPublishResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
active_config_snapshot_id: z.string(),
draft: zAgentConfigDraftSummaryResponse.nullish(),
result: z.string(),
})
/**
* AgentDriveItemResponse
*/
@ -1240,38 +1279,6 @@ export const zAgentSoulPromptConfig = z.object({
system_prompt: z.string().optional().default(''),
})
/**
* AgentConfigDraftType
*
* Editable Agent Soul draft workspace type.
*/
export const zAgentConfigDraftType = z.enum(['debug_build', 'draft'])
/**
* AgentConfigDraftSummaryResponse
*/
export const zAgentConfigDraftSummaryResponse = z.object({
account_id: z.string().nullish(),
agent_id: z.string(),
base_snapshot_id: z.string().nullish(),
created_at: z.int().nullish(),
created_by: z.string().nullish(),
draft_type: zAgentConfigDraftType,
id: z.string(),
updated_at: z.int().nullish(),
updated_by: z.string().nullish(),
})
/**
* AgentPublishResponse
*/
export const zAgentPublishResponse = z.object({
active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(),
active_config_snapshot_id: z.string(),
draft: zAgentConfigDraftSummaryResponse.nullish(),
result: z.string(),
})
/**
* AgentHumanContactConfig
*/
@ -3257,6 +3264,8 @@ export const zPostAgentByAgentIdCopyPath = z.object({
*/
export const zPostAgentByAgentIdCopyResponse = zAgentAppDetailWithSite
export const zPostAgentByAgentIdDebugConversationRefreshBody = zAgentDebugConversationRefreshPayload
export const zPostAgentByAgentIdDebugConversationRefreshPath = z.object({
agent_id: z.uuid(),
})